Skip to content
~/sph.sh

Feature Flags at Scale: Implementation Patterns and Platform Comparison

A production-focused guide to implementing feature flags in distributed systems, comparing LaunchDarkly, Unleash, and AWS AppConfig with working examples for gradual rollouts, A/B testing, and managing technical debt.

Abstract

Feature flags enable deploying code to production while controlling feature visibility at runtime. This guide examines implementation patterns for feature flags at scale, comparing LaunchDarkly, Unleash, and AWS AppConfig platforms. I'll cover SDK integration, targeting rules, A/B testing integration, circuit breaker patterns, and the critical challenge of managing flag technical debt. Working TypeScript examples demonstrate gradual rollouts, kill switches, and lifecycle management strategies that prevent flags from becoming unmaintainable.

The Deployment Coordination Problem

Working with distributed systems reveals a persistent challenge: coordinating feature releases across teams creates bottlenecks. When multiple teams need to deploy features simultaneously, the typical approach involves careful scheduling, late-night deployments, and crossing fingers that nothing breaks.

The traditional solution; long-lived feature branches; brings its own problems. Branches diverge from main for weeks, merge conflicts multiply, and integration becomes increasingly painful. By the time the feature merges, it's been tested against code that no longer resembles production.

Feature flags offer a different approach: deploy code continuously to production, control feature visibility through runtime configuration. Instead of coordinating deployments, you coordinate flag rollouts. Instead of all-or-nothing releases, you enable features gradually for specific user segments.

Here's what we'll build toward: deploying incomplete features to production behind disabled flags, testing in production with real data, rolling out progressively from 1% to 100% of users, and having instant rollback capability when issues appear.

Understanding Feature Flag Types

Not all feature flags serve the same purpose. Understanding these types helps establish clear lifecycle expectations from the start.

Release Flags (Temporary)

Release flags control gradual rollout of new features. These flags have a clear lifecycle: created during development, enabled progressively during rollout, removed after reaching 100% adoption. Keeping these flags longer than necessary creates technical debt.

typescript
interface ReleaseFlag {  key: 'new-checkout-flow';  type: 'release';  defaultValue: false;  temporary: true;  expiresAt: '2025-03-01'; // Set expiration when creating}

Experiment Flags (Temporary)

Experiment flags support A/B testing and multivariate experiments. Like release flags, these are temporary; they exist for the experiment duration and should be removed once the winning variation is implemented.

typescript
interface ExperimentFlag {  key: 'cta-button-color-experiment';  type: 'experiment';  variations: {    control: { color: 'blue' },    treatmentA: { color: 'green' },    treatmentB: { color: 'red' }  };  temporary: true;  expiresAt: '2025-02-15';}

Ops Flags (Permanent)

Ops flags act as circuit breakers and kill switches. These are long-lived flags that provide operational control; the ability to disable features during incidents or high load conditions without deploying new code.

typescript
interface OpsFlag {  key: 'enable-recommendation-engine';  type: 'ops';  defaultValue: true;  permanent: true;  purpose: 'Disable recommendation engine during high load';}

Permission Flags (Permanent)

Permission flags control feature access based on user attributes, subscription tiers, or entitlements. In SaaS applications, these flags manage which features are available to different customer segments.

typescript
interface PermissionFlag {  key: 'premium-analytics';  type: 'permission';  defaultValue: false;  permanent: true;  targetingRules: {    subscriptionTier: ['premium', 'enterprise']  };}

Flag Lifecycle

Different flag types follow different lifecycle patterns:

Platform Comparison: LaunchDarkly vs Unleash vs AWS AppConfig

Choosing a feature flag platform involves evaluating trade-offs between cost, features, and operational complexity. Here's what I've learned from working with each platform.

Feature Comparison Matrix

FeatureLaunchDarklyUnleashAWS AppConfig
HostingSaaS onlySelf-hosted or SaaSAWS managed
PricingHigh (per seat + MAU)Free (OSS) or paid SaaSPay per request
SDK MaturityExcellent (15+ languages)Good (15+ SDKs)AWS SDK only
Targeting RulesVery advancedGoodBasic
A/B TestingNativeVia integrationsManual
Local EvaluationYesYesYes (with extension)
Real-time UpdatesYesYesPolling (45s default)
Audit LogsComprehensiveBasic (paid tier)CloudTrail

LaunchDarkly: Enterprise Platform

LaunchDarkly provides the most mature feature flag platform with excellent UI/UX and comprehensive features.

Strengths:

  • Advanced targeting rules with powerful segment capabilities
  • Built-in experimentation platform
  • Real-time flag updates via streaming connections
  • Comprehensive audit logs and change history
  • Strong enterprise features (RBAC, SSO, compliance)

Limitations:

  • Expensive at scale (pricing combines per-seat and per-MAU costs)
  • SaaS-only deployment (no self-hosted option)
  • Vendor lock-in considerations

Cost Example (10 developers, 1M MAU, Pro tier):

  • Note: LaunchDarkly pricing model has changed. Contact sales for current pricing.
  • Historical reference: Pricing combined per-seat and per-MAU costs

Unleash: Open Source Alternative

Unleash offers open-source feature flags with the option to self-host or use their managed SaaS.

Strengths:

  • Open source (Apache 2.0 license)
  • Full control with self-hosting option
  • Good SDK coverage across languages
  • Active community
  • Cost-effective for large user bases

Limitations:

  • Less mature UI compared to LaunchDarkly
  • Self-hosting adds operational overhead
  • Limited experimentation features compared to LaunchDarkly
  • Basic targeting capabilities

Cost Example (self-hosted):

  • Infrastructure: ~$200/month (ECS/EC2, RDS)
  • Operational overhead: ~$500/month (engineering time)
  • Total: ~700/month( 700/month (~8,400/year)

AWS AppConfig: AWS-Native Solution

AWS AppConfig provides feature flags integrated with AWS services.

Strengths:

  • Native AWS integration (Lambda, ECS, etc.)
  • Pay-per-request pricing (cost-effective)
  • FedRAMP certified
  • No external service dependency
  • Built-in validation and rollback

Limitations:

  • Basic targeting capabilities
  • Polling-based updates (not real-time)
  • Limited to AWS SDK
  • No native A/B testing support
  • Basic dashboard compared to alternatives

Cost Example (1M requests/month):

  • Requests: 1M × 0.0000002=0.0000002 = 0.20/month
  • Configurations: 10 × 0.50=0.50 = 5/month
  • Total: ~5.20/month( 5.20/month (~62/year)

Platform Selection Framework

LaunchDarkly SDK Integration

LaunchDarkly provides mature SDKs with local evaluation capabilities. Here's a production-ready integration pattern.

SDK Initialization

typescript
import { init, LDClient, LDFlagSet } from '@launchdarkly/node-server-sdk';
// Singleton client initializationlet ldClient: LDClient | null = null;
export async function initializeLaunchDarkly(): Promise<LDClient> {  if (ldClient) {    return ldClient;  }
  ldClient = init(process.env.LAUNCHDARKLY_SDK_KEY!, {    // Performance optimization: reduce network calls    streamInitialReconnectDelay: 1000,    // Local evaluation for lower latency    sendEvents: true,    // Timeout configuration    timeout: 5,  });
  await ldClient.waitForInitialization({ timeout: 5 });  console.log('LaunchDarkly initialized');
  return ldClient;}

Type-Safe Flag Evaluation

typescript
// User context for targetinginterface UserContext {  key: string;  email?: string;  country?: string;  customAttributes?: Record<string, any>;}
// Type-safe flag evaluationexport async function evaluateFlag<T>(  flagKey: string,  user: UserContext,  defaultValue: T): Promise<T> {  const client = await initializeLaunchDarkly();
  const ldUser = {    key: user.key,    email: user.email,    country: user.country,    custom: user.customAttributes,  };
  return client.variation(flagKey, ldUser, defaultValue);}

Express Middleware Integration

typescript
import { Request, Response, NextFunction } from 'express';
export async function checkFeatureFlag(flagKey: string) {  return async (req: Request, res: Response, next: NextFunction) => {    const user = {      key: req.user?.id || 'anonymous',      email: req.user?.email,      country: req.headers['cloudfront-viewer-country'] as string,    };
    const isEnabled = await evaluateFlag(flagKey, user, false);
    if (!isEnabled) {      return res.status(403).json({        error: 'Feature not available'      });    }
    next();  };}
// Usage in routeapp.post('/api/checkout',  checkFeatureFlag('new-checkout-flow'),  async (req, res) => {    // New checkout implementation  });

Performance Note: Local evaluation reduces latency from 100-500ms (remote calls) to 1-5ms (local cache lookups).

Unleash SDK Integration

Unleash provides open-source SDKs with good performance characteristics.

SDK Setup

typescript
import { initialize, isEnabled, getVariant } from 'unleash-client';
const unleash = initialize({  url: process.env.UNLEASH_URL!,  appName: 'order-service',  instanceId: process.env.HOSTNAME || 'local',  customHeaders: {    Authorization: process.env.UNLEASH_API_TOKEN!,  },  // Performance: local caching  refreshInterval: 15000, // 15 seconds  metricsInterval: 60000, // 1 minute});
// Wait for SDK to be readyunleash.on('ready', () => {  console.log('Unleash client ready');});
unleash.on('error', (err) => {  console.error('Unleash error:', err);});

Context-Based Evaluation

typescript
interface UnleashContext {  userId?: string;  sessionId?: string;  remoteAddress?: string;  properties?: Record<string, string>;}
export function checkFlag(  flagName: string,  context: UnleashContext,  defaultValue = false): boolean {  return isEnabled(flagName, context, defaultValue);}
export function getFlagVariant(  flagName: string,  context: UnleashContext): { name: string; payload?: any } {  return getVariant(flagName, context);}

Gradual Rollout Example

typescript
export async function processOrder(orderId: string, userId: string) {  const context = {    userId,    sessionId: orderId,    properties: {      userTier: await getUserTier(userId),      region: await getUserRegion(userId),    },  };
  // Simple on/off flag  const useNewPaymentGateway = checkFlag(    'new-payment-gateway',    context,    false  );
  // Multivariate flag for A/B testing  const checkoutVariant = getFlagVariant('checkout-layout', context);
  if (useNewPaymentGateway) {    return processWithNewGateway(orderId, checkoutVariant);  } else {    return processWithLegacyGateway(orderId);  }}

AWS AppConfig with Lambda Extension

AWS AppConfig integrates well with Lambda functions using the AppConfig Lambda Extension.

SDK Integration

typescript
import { AppConfigDataClient, StartConfigurationSessionCommand, GetLatestConfigurationCommand } from '@aws-sdk/client-appconfigdata';
interface FeatureFlagConfig {  flags: Record<string, {    enabled: boolean;    attributes?: Record<string, any>;  }>;  version: string;}
let cachedConfig: FeatureFlagConfig | null = null;let configToken: string | null = null;
export async function initializeAppConfig() {  const client = new AppConfigDataClient({ region: process.env.AWS_REGION });
  const sessionCommand = new StartConfigurationSessionCommand({    ApplicationIdentifier: process.env.APPCONFIG_APPLICATION!,    EnvironmentIdentifier: process.env.APPCONFIG_ENVIRONMENT!,    ConfigurationProfileIdentifier: process.env.APPCONFIG_PROFILE!,  });
  const response = await client.send(sessionCommand);  configToken = response.InitialConfigurationToken!;}
export async function fetchFeatureFlags(): Promise<FeatureFlagConfig> {  if (!configToken) {    await initializeAppConfig();  }
  const client = new AppConfigDataClient({ region: process.env.AWS_REGION });
  const command = new GetLatestConfigurationCommand({    ConfigurationToken: configToken!,  });
  const response = await client.send(command);  configToken = response.NextPollConfigurationToken!;
  if (response.Configuration) {    const configString = new TextDecoder().decode(response.Configuration);    cachedConfig = JSON.parse(configString);  }
  return cachedConfig!;}

Lambda Handler with Flags

typescript
export const handler = async (event: any) => {  const config = await fetchFeatureFlags();
  const userId = event.requestContext.authorizer.claims.sub;
  // Simple flag check  const isNewFeatureEnabled = config.flags['new-dashboard']?.enabled || false;
  // Attribute-based targeting  const userTier = await getUserTier(userId);  const premiumFeaturesFlag = config.flags['premium-features'];  const hasPremiumAccess =    premiumFeaturesFlag?.enabled &&    premiumFeaturesFlag?.attributes?.allowedTiers?.includes(userTier);
  if (isNewFeatureEnabled && hasPremiumAccess) {    return {      statusCode: 200,      body: JSON.stringify({ dashboard: 'new', premium: true }),    };  }
  return {    statusCode: 200,    body: JSON.stringify({ dashboard: 'legacy', premium: false }),  };};

Performance Improvement: Using the Lambda extension reduces cold start impact by caching configuration locally. The extension polls AppConfig every 45 seconds and serves requests from local cache.

Note: Add the AWS AppConfig Lambda Extension layer to your function:

Layer ARN: arn:aws:lambda:us-east-1:027255383542:layer:AWS-AppConfig-Extension:279

The extension runs as a sidecar process and handles configuration fetching/caching automatically.

Targeting Rules and User Segmentation

Advanced targeting enables progressive rollouts and user-specific feature access.

Targeting Rule Implementation

typescript
interface TargetingRule {  attribute: string;  operator: 'equals' | 'contains' | 'greaterThan' | 'lessThan' | 'regex' | 'in';  values: any[];}
interface Segment {  name: string;  rules: TargetingRule[];  rolloutPercentage?: number;}
interface FeatureFlagDefinition {  key: string;  defaultValue: boolean;  segments: Segment[];}

Flag Evaluator

typescript
class FeatureFlagEvaluator {  evaluateRule(rule: TargetingRule, context: Record<string, any>): boolean {    const attributeValue = context[rule.attribute];
    if (attributeValue === undefined) {      return false;    }
    switch (rule.operator) {      case 'equals':        return attributeValue === rule.values[0];
      case 'in':        return rule.values.includes(attributeValue);
      case 'contains':        return String(attributeValue).includes(String(rule.values[0]));
      case 'greaterThan':        return Number(attributeValue) > Number(rule.values[0]);
      case 'lessThan':        return Number(attributeValue) < Number(rule.values[0]);
      case 'regex':        const pattern = new RegExp(rule.values[0]);        return pattern.test(String(attributeValue));
      default:        return false;    }  }
  evaluateSegment(segment: Segment, context: Record<string, any>): boolean {    // All rules in segment must match (AND logic)    const rulesMatch = segment.rules.every(rule =>      this.evaluateRule(rule, context)    );
    if (!rulesMatch) {      return false;    }
    // Apply percentage rollout if specified    if (segment.rolloutPercentage !== undefined) {      const hash = this.hashUserId(context.userId);      const bucket = hash % 100;      return bucket < segment.rolloutPercentage;    }
    return true;  }
  evaluateFlag(    flag: FeatureFlagDefinition,    context: Record<string, any>  ): boolean {    // Check segments in order, return first match    for (const segment of flag.segments) {      if (this.evaluateSegment(segment, context)) {        return true;      }    }
    return flag.defaultValue;  }
  // Consistent hashing for percentage rollouts  private hashUserId(userId: string): number {    let hash = 0;    for (let i = 0; i < userId.length; i++) {      const char = userId.charCodeAt(i);      hash = ((hash << 5) - hash) + char;      hash = hash & hash; // Convert to 32-bit integer    }    return Math.abs(hash);  }}

Progressive Rollout Configuration

typescript
const evaluator = new FeatureFlagEvaluator();
const premiumFeatureFlag: FeatureFlagDefinition = {  key: 'premium-analytics',  defaultValue: false,  segments: [    {      name: 'Internal employees',      rules: [        { attribute: 'email', operator: 'contains', values: ['@company.com'] }      ],    },    {      name: 'Premium tier users',      rules: [        { attribute: 'subscriptionTier', operator: 'in', values: ['premium', 'enterprise'] }      ],    },    {      name: 'Beta users gradual rollout',      rules: [        { attribute: 'betaOptIn', operator: 'equals', values: [true] }      ],      rolloutPercentage: 20, // 20% of beta users    },  ],};
const userContext = {  userId: 'user-123',  email: '[email protected]',  subscriptionTier: 'premium',  betaOptIn: true,};
const isEnabled = evaluator.evaluateFlag(premiumFeatureFlag, userContext);

Percentage Rollout Strategy: Use consistent hashing on user ID to ensure the same user always sees the same experience. This prevents users from seeing feature state flip between enabled/disabled on different requests.

A/B Testing Integration

Feature flags work well with analytics platforms for experimentation.

Analytics Integration

typescript
import { init, LDClient } from '@launchdarkly/node-server-sdk';import * as Amplitude from '@amplitude/node';
interface ExperimentContext {  userId: string;  userProperties: Record<string, any>;  eventProperties?: Record<string, any>;}
class FeatureFlagAnalytics {  private ldClient: LDClient;  private amplitudeClient: Amplitude.Types.NodeClient;
  constructor(ldKey: string, amplitudeKey: string) {    this.ldClient = init(ldKey);    this.amplitudeClient = Amplitude.init(amplitudeKey);  }
  async evaluateExperiment(    experimentKey: string,    context: ExperimentContext,    defaultVariation: string  ): Promise<string> {    const ldContext = {      key: context.userId,      custom: context.userProperties,    };
    // Get variation from LaunchDarkly    const variation = await this.ldClient.variation(      experimentKey,      ldContext,      defaultVariation    );
    // Track experiment exposure in Amplitude    await this.amplitudeClient.logEvent({      event_type: 'Experiment Viewed',      user_id: context.userId,      event_properties: {        experiment_name: experimentKey,        variation_name: variation,        ...context.eventProperties,      },      user_properties: context.userProperties,    });
    return variation;  }
  async trackConversion(    experimentKey: string,    context: ExperimentContext,    conversionMetric: string,    value?: number  ) {    await this.amplitudeClient.logEvent({      event_type: conversionMetric,      user_id: context.userId,      event_properties: {        experiment_name: experimentKey,        value,        ...context.eventProperties,      },      user_properties: context.userProperties,    });  }}

Experiment Implementation

typescript
const analytics = new FeatureFlagAnalytics(  process.env.LAUNCHDARKLY_KEY!,  process.env.AMPLITUDE_KEY!);
export async function renderCheckoutButton(userId: string) {  const context = {    userId,    userProperties: {      accountAge: await getAccountAge(userId),      previousPurchases: await getPurchaseCount(userId),    },  };
  // Get button color variation (control, green, red)  const buttonColor = await analytics.evaluateExperiment(    'checkout-button-color',    context,    'control' // Default blue button  );
  return {    color: buttonColor === 'control' ? 'blue' : buttonColor,    experimentKey: 'checkout-button-color',  };}
export async function handleCheckoutClick(userId: string, experimentKey: string) {  const context = {    userId,    userProperties: {},    eventProperties: {      page: 'checkout',    },  };
  await analytics.trackConversion(    experimentKey,    context,    'Checkout Button Clicked'  );}
export async function handlePurchaseComplete(  userId: string,  experimentKey: string,  amount: number) {  const context = {    userId,    userProperties: {},    eventProperties: {      purchaseAmount: amount,    },  };
  await analytics.trackConversion(    experimentKey,    context,    'Purchase Completed',    amount  );}

Warning: A/B testing requires proper sample size calculation and statistical significance testing. Don't declare a winner after 100 users; wait for p-value < 0.05 and sufficient sample size. Consider using tools like Evan Miller's A/B test calculator to determine required sample size before starting experiments.

Kill Switches and Circuit Breakers

Operational flags enable quick response to incidents without deploying new code.

Circuit Breaker Implementation

typescript
import { EventEmitter } from 'events';
interface CircuitBreakerConfig {  flagKey: string;  errorThreshold: number; // Percentage of errors before opening  timeWindow: number; // Time window in ms  checkInterval: number; // How often to check flag state}
enum CircuitState {  CLOSED = 'CLOSED', // Normal operation  OPEN = 'OPEN',     // Circuit breaker triggered  HALF_OPEN = 'HALF_OPEN', // Testing if service recovered}
class FeatureFlagCircuitBreaker extends EventEmitter {  private state: CircuitState = CircuitState.CLOSED;  private errors: number[] = [];  private requests: number[] = [];  private flagEnabled: boolean = true;
  constructor(    private config: CircuitBreakerConfig,    private flagClient: any  ) {    super();    this.startFlagMonitoring();  }
  private startFlagMonitoring() {    setInterval(async () => {      // Check if flag was manually disabled (kill switch)      this.flagEnabled = await this.flagClient.variation(        this.config.flagKey,        { key: 'system' },        true      );
      if (!this.flagEnabled && this.state !== CircuitState.OPEN) {        this.openCircuit('Manual kill switch activated');      } else if (this.flagEnabled && this.state === CircuitState.OPEN) {        this.halfOpenCircuit();      }    }, this.config.checkInterval);  }
  async executeWithCircuitBreaker<T>(    operation: () => Promise<T>,    fallback: () => T  ): Promise<T> {    // If circuit is open or flag disabled, use fallback    if (this.state === CircuitState.OPEN || !this.flagEnabled) {      return fallback();    }
    const now = Date.now();    this.requests.push(now);
    try {      const result = await operation();
      // Success in HALF_OPEN state closes circuit      if (this.state === CircuitState.HALF_OPEN) {        this.closeCircuit();      }
      return result;    } catch (error) {      this.errors.push(now);      this.checkErrorThreshold();      throw error;    } finally {      this.cleanupOldMetrics(now);    }  }
  private checkErrorThreshold() {    const now = Date.now();    const recentRequests = this.requests.filter(      t => now - t < this.config.timeWindow    );    const recentErrors = this.errors.filter(      t => now - t < this.config.timeWindow    );
    if (recentRequests.length === 0) return;
    const errorRate = (recentErrors.length / recentRequests.length) * 100;
    if (errorRate >= this.config.errorThreshold) {      this.openCircuit(`Error rate ${errorRate.toFixed(2)}% exceeded threshold`);    }  }
  private openCircuit(reason: string) {    this.state = CircuitState.OPEN;    this.emit('circuit-opened', { reason, flagKey: this.config.flagKey });    console.error(`Circuit breaker OPEN: ${reason}`);  }
  private halfOpenCircuit() {    this.state = CircuitState.HALF_OPEN;    this.emit('circuit-half-open', { flagKey: this.config.flagKey });    console.log('Circuit breaker HALF_OPEN: testing recovery');  }
  private closeCircuit() {    this.state = CircuitState.CLOSED;    this.errors = [];    this.emit('circuit-closed', { flagKey: this.config.flagKey });    console.log('Circuit breaker CLOSED: service recovered');  }
  private cleanupOldMetrics(now: number) {    this.requests = this.requests.filter(      t => now - t < this.config.timeWindow    );    this.errors = this.errors.filter(      t => now - t < this.config.timeWindow    );  }
  getState(): CircuitState {    return this.state;  }}

Production Usage

typescript
const recommendationEngineBreaker = new FeatureFlagCircuitBreaker(  {    flagKey: 'enable-recommendation-engine',    errorThreshold: 50, // 50% error rate    timeWindow: 60000, // 1 minute    checkInterval: 5000, // Check flag every 5 seconds  },  ldClient);
// Monitor circuit breaker eventsrecommendationEngineBreaker.on('circuit-opened', ({ reason }) => {  console.error('ALERT: Recommendation engine circuit breaker opened:', reason);  sendPagerDutyAlert('Recommendation engine disabled', reason);});
export async function getRecommendations(userId: string) {  return recommendationEngineBreaker.executeWithCircuitBreaker(    // Primary operation: call recommendation engine    async () => {      const response = await fetch(`https://api.recommendations.com/users/${userId}`);      if (!response.ok) throw new Error('Recommendation API failed');      return response.json();    },    // Fallback: return popular items    () => {      return getPopularItems(); // Simple fallback    }  );}

Circuit Breaker States:

Flag Lifecycle Management

Technical debt from abandoned feature flags represents a significant challenge. Without active lifecycle management, flag count grows exponentially.

Lifecycle Tracking

typescript
interface FlagMetadata {  key: string;  type: 'release' | 'experiment' | 'ops' | 'permission';  createdAt: Date;  createdBy: string;  expiresAt?: Date;  status: 'active' | 'inactive' | 'launched' | 'deprecated';  evaluationCount: number;  lastEvaluated?: Date;}
class FlagLifecycleManager {  private metadata: Map<string, FlagMetadata> = new Map();  private readonly INACTIVE_THRESHOLD_DAYS = 30;  private readonly STALE_FLAG_THRESHOLD_DAYS = 90;
  constructor(private flagClient: any) {    this.startLifecycleMonitoring();  }
  async evaluateFlag(    flagKey: string,    context: any,    defaultValue: any  ): Promise<any> {    const value = await this.flagClient.variation(flagKey, context, defaultValue);
    // Update metadata    const metadata = this.metadata.get(flagKey);    if (metadata) {      metadata.evaluationCount++;      metadata.lastEvaluated = new Date();    }
    return value;  }
  registerFlag(metadata: Omit<FlagMetadata, 'evaluationCount' | 'lastEvaluated'>) {    this.metadata.set(metadata.key, {      ...metadata,      evaluationCount: 0,    });  }
  findStaleFlags(): FlagMetadata[] {    const now = new Date();    const staleFlags: FlagMetadata[] = [];
    this.metadata.forEach(flag => {      // Skip permanent flags (ops, permission)      if (flag.type === 'ops' || flag.type === 'permission') {        return;      }
      // Check if expired      if (flag.expiresAt && now > flag.expiresAt) {        staleFlags.push(flag);        return;      }
      // Check if inactive (no evaluations in 30 days)      if (flag.lastEvaluated) {        const daysSinceEvaluation =          (now.getTime() - flag.lastEvaluated.getTime()) / (1000 * 60 * 60 * 24);
        if (daysSinceEvaluation > this.INACTIVE_THRESHOLD_DAYS) {          flag.status = 'inactive';          staleFlags.push(flag);        }      }
      // Check if flag is old and never evaluated      const flagAge =        (now.getTime() - flag.createdAt.getTime()) / (1000 * 60 * 60 * 24);
      if (flagAge > this.STALE_FLAG_THRESHOLD_DAYS && flag.evaluationCount === 0) {        staleFlags.push(flag);      }    });
    return staleFlags;  }
  async generateCleanupReport(): Promise<string> {    const staleFlags = this.findStaleFlags();    const report: string[] = [      '# Feature Flag Cleanup Report',      `Generated: ${new Date().toISOString()}`,      '',      '## Flags Ready for Removal',      '',    ];
    for (const flag of staleFlags) {      report.push(`### ${flag.key}`);      report.push(`- Type: ${flag.type}`);      report.push(`- Created: ${flag.createdAt.toISOString()}`);      report.push(`- Status: ${flag.status}`);      report.push(`- Evaluations: ${flag.evaluationCount}`);      report.push(`- Last evaluated: ${flag.lastEvaluated?.toISOString() || 'Never'}`);
      if (flag.expiresAt) {        report.push(`- Expired: ${flag.expiresAt.toISOString()}`);      }
      report.push('');    }
    return report.join('\n');  }
  private startLifecycleMonitoring() {    // Weekly cleanup check    setInterval(async () => {      const report = await this.generateCleanupReport();      console.log(report);      // In production: send to Slack, create Jira ticket, etc.    }, 7 * 24 * 60 * 60 * 1000); // Weekly  }}

Flag Removal Process

Cleanup Strategy:

  1. Identify flags that have reached 100% rollout (launched state)
  2. Verify flag always returns the same value
  3. Create pull request to remove flag code
  4. Deploy and monitor for issues
  5. Archive flag in platform
  6. Update documentation

Trunk-Based Development Integration

Feature flags enable trunk-based development by allowing incomplete features in the main branch.

Feature Toggle Pattern

typescript
export class FeatureToggle {  constructor(private flagClient: any) {}
  async withFeature<T>(    flagKey: string,    context: any,    newImplementation: () => Promise<T>,    legacyImplementation: () => Promise<T>  ): Promise<T> {    const isEnabled = await this.flagClient.variation(      flagKey,      context,      false // Default: disabled    );
    if (isEnabled) {      try {        return await newImplementation();      } catch (error) {        console.error(`Feature ${flagKey} failed, falling back:`, error);        // Automatic fallback on error        return await legacyImplementation();      }    }
    return await legacyImplementation();  }}

Progressive Implementation

typescript
const toggle = new FeatureToggle(ldClient);
export async function processPayment(orderId: string, userId: string) {  const context = { key: userId };
  return toggle.withFeature(    'new-payment-processor',    context,    // New implementation (under development)    async () => {      // Incomplete feature can be merged to main      // because it's behind flag (disabled by default)      return newPaymentProcessor.process(orderId);    },    // Legacy implementation (production)    async () => {      return legacyPaymentProcessor.process(orderId);    }  );}

Benefits:

  • No long-lived feature branches
  • Continuous integration with main branch
  • Smaller, more frequent merges
  • Reduced merge conflicts
  • Faster feedback loops

Testing Strategies

Testing feature-flagged code requires testing both enabled and disabled states.

Mock Flag Client

typescript
class MockFlagClient {  private flags: Map<string, any> = new Map();
  setFlag(key: string, value: any) {    this.flags.set(key, value);  }
  async variation(key: string, context: any, defaultValue: any): Promise<any> {    return this.flags.get(key) ?? defaultValue;  }
  reset() {    this.flags.clear();  }}

Test Both States

typescript
describe('Payment Processing', () => {  let mockFlags: MockFlagClient;  let paymentService: PaymentService;
  beforeEach(() => {    mockFlags = new MockFlagClient();    paymentService = new PaymentService(mockFlags);  });
  describe('with new payment processor ENABLED', () => {    beforeEach(() => {      mockFlags.setFlag('new-payment-processor', true);    });
    it('should use new payment processor', async () => {      const result = await paymentService.processPayment('order-123', 'user-456');      expect(result.processor).toBe('new');    });
    it('should handle new processor errors gracefully', async () => {      mockNewProcessor.process = jest.fn().mockRejectedValue(new Error('API Error'));
      // Should fallback to legacy      const result = await paymentService.processPayment('order-123', 'user-456');      expect(result.processor).toBe('legacy');    });  });
  describe('with new payment processor DISABLED', () => {    beforeEach(() => {      mockFlags.setFlag('new-payment-processor', false);    });
    it('should use legacy payment processor', async () => {      const result = await paymentService.processPayment('order-123', 'user-456');      expect(result.processor).toBe('legacy');    });
    it('should not call new processor', async () => {      const newProcessorSpy = jest.spyOn(mockNewProcessor, 'process');      await paymentService.processPayment('order-123', 'user-456');      expect(newProcessorSpy).not.toHaveBeenCalled();    });  });});

Warning: Don't test every combination of feature flags. With 10 flags, that's 1,024 test cases. Instead:

  • Test critical features with flags both ON and OFF
  • Use risk-based testing (test risky features more thoroughly)
  • Mock flag client for predictable behavior
  • Integration tests use dedicated test environment flags

Key Takeaways

Start with Clear Flag Types: Distinguish between release (temporary), experiment (temporary), ops (permanent), and permission (permanent) flags from the beginning. This establishes clear lifecycle expectations.

Choose Platform Based on Needs: AWS-native applications benefit from AppConfig's cost structure and integration. Complex targeting requirements favor LaunchDarkly's advanced capabilities. Budget-conscious teams with technical expertise should consider self-hosted Unleash.

Performance Requires Local Evaluation: SDK caching and local evaluation reduces latency from 100-500ms (remote calls) to 1-5ms (local lookups). This matters for high-traffic applications.

Flag Debt Accumulates Quickly: Without lifecycle management, flag count grows exponentially. Set expiration dates when creating flags, automate stale flag detection, and schedule regular cleanup.

Default to Safe Values: Feature flags should default to stable/legacy behavior. This ensures graceful degradation if the flag service becomes unavailable.

Test Both Code Paths: Every flag creates two execution paths. Test both in your CI/CD pipeline to catch issues before production.

Gradual Rollout Requires Monitoring: Progressive rollouts (1% → 5% → 25% → 50% → 100%) need comprehensive monitoring and clear rollback criteria. Define error rate thresholds before starting rollouts.

Kill Switches Reduce MTTR: Circuit breakers with automatic feature disabling can reduce mean time to recovery from hours to minutes during incidents.

Trunk-Based Development Works: Feature flags enable continuous integration without long-lived feature branches. Deploy incomplete features to production behind disabled flags.

A/B Testing Needs Statistical Rigor: Proper experiment design includes sample size calculation, statistical significance testing (p < 0.05), and avoiding common biases like the novelty effect.


Feature flags transform how teams deploy software; from coordinated big-bang releases to continuous, controlled rollouts. The key is treating flags as first-class citizens in your architecture with proper lifecycle management, rather than letting them accumulate into technical debt.

Related Posts