Skip to content

Implementing Feature Flags at Scale

Build a feature flag system with gradual rollouts, user targeting, A/B testing and kill switches — plus lifecycle patterns that prevent flag debt.

5 min read
Dashboard showing feature flag states with percentage rollouts, user targeting rules, and kill switch controls

Feature flags decouple deployment from release. You deploy code to production at any time, but the feature only activates for users when you flip the flag. This transforms deployment from a risky event into a routine operation—and it transforms releases from all-or-nothing launches into gradual, measurable rollouts you can reverse in seconds.

But feature flags that aren't managed become technical debt. Every flag is a branch in your code that doubles testing paths. Teams that adopt flags without lifecycle management end up with thousands of stale flags, code that's impossible to reason about, and "temporary" flags that have been in production for three years. The system needs to be designed with cleanup as a first-class concern from day one.

Flag Evaluation Architecture

The core of a feature flag system is the evaluation engine: given a flag key, a user context, and a set of rules, should the flag be on or off?

tstypescript
// Core flag evaluation types
interface FlagContext {
  userId: string;
  email?: string;
  country?: string;
  plan?: string;
  attributes: Record<string, string | number | boolean>;
}
 
interface FlagRule {
  conditions: FlagCondition[];
  percentage?: number;
  variant?: string;
}
 
interface FlagCondition {
  attribute: string;
  operator: 'eq' | 'neq' | 'contains' | 'gt' | 'lt' | 'in';
  value: string | number | string[];
}
 
interface FeatureFlag {
  key: string;
  enabled: boolean;
  rules: FlagRule[];
  defaultVariant: string;
  killSwitch: boolean;
  owner: string;
  expiresAt?: Date;
}
 
// ❌ Naive flag check: just a boolean
function isFeatureEnabled(flagKey: string): boolean {
  return flags[flagKey] === true; // No targeting, no gradual rollout
}
 
// ✅ Full evaluation with targeting and percentage rollout
class FlagEvaluator {
  evaluate(flag: FeatureFlag, context: FlagContext): FlagResult {
    // Kill switch overrides everything
    if (flag.killSwitch) {
      return { enabled: false, variant: 'off', reason: 'kill-switch' };
    }
 
    // Global toggle
    if (!flag.enabled) {
      return { enabled: false, variant: flag.defaultVariant, reason: 'disabled' };
    }
 
    // Evaluate rules in order — first match wins
    for (const rule of flag.rules) {
      if (this.matchesConditions(rule.conditions, context)) {
        if (rule.percentage !== undefined) {
          const hash = this.consistentHash(flag.key, context.userId);
          if (hash <= rule.percentage) {
            return { enabled: true, variant: rule.variant ?? 'on', reason: 'rule-match' };
          }
        } else {
          return { enabled: true, variant: rule.variant ?? 'on', reason: 'rule-match' };
        }
      }
    }
 
    return { enabled: false, variant: flag.defaultVariant, reason: 'no-match' };
  }
 
  // Consistent hashing: same user always gets same result
  private consistentHash(flagKey: string, userId: string): number {
    const input = `${flagKey}:${userId}`;
    let hash = 0;
    for (let i = 0; i < input.length; i++) {
      hash = ((hash << 5) - hash + input.charCodeAt(i)) | 0;
    }
    return (Math.abs(hash) % 100) + 1;
  }
 
  private matchesConditions(
    conditions: FlagCondition[],
    context: FlagContext
  ): boolean {
    return conditions.every((condition) => {
      const value = context.attributes[condition.attribute]
        ?? (context as Record<string, unknown>)[condition.attribute];
 
      switch (condition.operator) {
        case 'eq': return value === condition.value;
        case 'neq': return value !== condition.value;
        case 'contains': return String(value).includes(String(condition.value));
        case 'in': return Array.isArray(condition.value) && condition.value.includes(String(value));
        case 'gt': return Number(value) > Number(condition.value);
        case 'lt': return Number(value) < Number(condition.value);
        default: return false;
      }
    });
  }
}

Gradual Rollout Patterns

The real power of feature flags is gradual rollout: start with 1% of users, watch error rates, increase to 10%, watch again, and so on until you reach 100%.

tstypescript
// Progressive rollout configuration
interface RolloutPlan {
  flagKey: string;
  stages: RolloutStage[];
  currentStage: number;
  metrics: RolloutMetrics;
}
 
interface RolloutStage {
  percentage: number;
  durationHours: number;
  autoAdvance: boolean;
  rollbackConditions: {
    errorRateThreshold: number;
    latencyP99Threshold: number;
  };
}
 
// Example rollout plan for a new checkout flow
const checkoutRollout: RolloutPlan = {
  flagKey: 'new-checkout-flow',
  currentStage: 0,
  stages: [
    {
      percentage: 1,
      durationHours: 24,
      autoAdvance: false,  // Manual review first
      rollbackConditions: {
        errorRateThreshold: 0.05,
        latencyP99Threshold: 3000,
      },
    },
    {
      percentage: 10,
      durationHours: 48,
      autoAdvance: true,
      rollbackConditions: {
        errorRateThreshold: 0.02,
        latencyP99Threshold: 2000,
      },
    },
    {
      percentage: 50,
      durationHours: 72,
      autoAdvance: true,
      rollbackConditions: {
        errorRateThreshold: 0.01,
        latencyP99Threshold: 1500,
      },
    },
    {
      percentage: 100,
      durationHours: 0,
      autoAdvance: false,
      rollbackConditions: {
        errorRateThreshold: 0.01,
        latencyP99Threshold: 1500,
      },
    },
  ],
  metrics: {} as RolloutMetrics,
};

Flag Lifecycle Management

The difference between a useful flag system and a codebase nightmare: every flag must have an owner and an expiration date.

tstypescript
// Flag with lifecycle metadata
interface ManagedFlag extends FeatureFlag {
  createdAt: Date;
  createdBy: string;
  expiresAt: Date;       // When should this flag be removed?
  lastEvaluated?: Date;  // Is anyone still checking this flag?
  category: FlagCategory;
  jiraTicket: string;    // Link to cleanup ticket
}
 
type FlagCategory =
  | 'release'       // Temporary: remove after full rollout
  | 'experiment'    // Temporary: remove after A/B test concludes
  | 'ops'           // Semi-permanent: kill switches, circuit breakers
  | 'permission';   // Permanent: gating features by plan/role
 
// Automated stale flag detection
class FlagHealthChecker {
  async findStaleFlags(flags: ManagedFlag[]): Promise<StaleReport[]> {
    const now = new Date();
    const reports: StaleReport[] = [];
 
    for (const flag of flags) {
      const issues: string[] = [];
 
      // Expired flags that haven't been cleaned up
      if (flag.expiresAt < now && flag.category !== 'ops') {
        issues.push(
          `Expired ${this.daysSince(flag.expiresAt)} days ago`
        );
      }
 
      // Flags at 100% rollout that should be removed
      if (
        flag.category === 'release' &&
        flag.enabled &&
        flag.rules.length === 0
      ) {
        const age = this.daysSince(flag.createdAt);
        if (age > 30) {
          issues.push(
            `At 100% for ${age} days — remove flag and dead code`
          );
        }
      }
 
      // Flags that haven't been evaluated recently
      if (
        flag.lastEvaluated &&
        this.daysSince(flag.lastEvaluated) > 14
      ) {
        issues.push('Not evaluated in 14 days — possibly dead code');
      }
 
      if (issues.length > 0) {
        reports.push({
          flag: flag.key,
          owner: flag.owner,
          issues,
        });
      }
    }
 
    return reports;
  }
 
  private daysSince(date: Date): number {
    return Math.floor(
      (Date.now() - date.getTime()) / (1000 * 60 * 60 * 24)
    );
  }
}

Testing with Feature Flags

Feature flags multiply your test matrix. Without discipline, you end up testing neither the on nor the off state thoroughly.

tstypescript
// ❌ Testing without considering flag states
describe('checkout', () => {
  it('should process payment', async () => {
    // Which checkout flow does this test?
    // If the flag is on in the test environment, it tests new flow
    // If off, it tests old flow
    // Nobody knows which one CI is testing
    const result = await processCheckout(order);
    expect(result.status).toBe('success');
  });
});
 
// ✅ Explicit flag state testing
describe('checkout', () => {
  describe('with new-checkout-flow enabled', () => {
    beforeEach(() => {
      flagService.override('new-checkout-flow', true);
    });
 
    afterEach(() => {
      flagService.clearOverrides();
    });
 
    it('should use stripe payment intent API', async () => {
      const result = await processCheckout(order);
      expect(stripeClient.createPaymentIntent).toHaveBeenCalled();
    });
  });
 
  describe('with new-checkout-flow disabled', () => {
    beforeEach(() => {
      flagService.override('new-checkout-flow', false);
    });
 
    afterEach(() => {
      flagService.clearOverrides();
    });
 
    it('should use legacy charge API', async () => {
      const result = await processCheckout(order);
      expect(stripeClient.createCharge).toHaveBeenCalled();
    });
  });
});

Client-Side Flag Evaluation

For frontend applications, flag evaluation should be fast (no network calls on every render) and consistent (no flickering between states).

tstypescript
// Client-side flag SDK pattern
class FeatureFlagClient {
  private flags: Map<string, FlagResult> = new Map();
  private listeners: Map<string, Set<() => void>> = new Map();
 
  async initialize(context: FlagContext): Promise<void> {
    // Fetch all flags once on initialization
    const response = await fetch('/api/flags', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(context),
    });
    const flags = await response.json();
 
    for (const [key, value] of Object.entries(flags)) {
      this.flags.set(key, value as FlagResult);
    }
 
    // Stream updates for real-time flag changes
    this.connectStream(context);
  }
 
  isEnabled(flagKey: string): boolean {
    return this.flags.get(flagKey)?.enabled ?? false;
  }
 
  // React hook integration
  onFlagChange(flagKey: string, callback: () => void): () => void {
    if (!this.listeners.has(flagKey)) {
      this.listeners.set(flagKey, new Set());
    }
    this.listeners.get(flagKey)!.add(callback);
    return () => this.listeners.get(flagKey)?.delete(callback);
  }
 
  private connectStream(context: FlagContext): void {
    const source = new EventSource(
      `/api/flags/stream?userId=${context.userId}`
    );
    source.onmessage = (event) => {
      const update = JSON.parse(event.data);
      this.flags.set(update.key, update.value);
      this.listeners.get(update.key)?.forEach((cb) => cb());
    };
  }
}

Key Takeaways

Feature flags decouple deployment from release—deploy code anytime and activate features gradually through percentage-based rollouts that start at 1%, advance through 10% and 50% with automated metric checks, and roll back instantly if error rates or latency exceed thresholds. Every feature flag needs lifecycle metadata: an owner, expiration date, category (release, experiment, ops, permission), and a linked cleanup ticket—without this discipline, stale flags accumulate into a codebase where nobody knows which code paths are actually active. Use consistent hashing for percentage rollouts so the same user always gets the same flag state across requests—this prevents flickering experiences and makes user-reported bugs reproducible by checking which variant that user's hash resolves to. Test both flag states explicitly in your test suite by overriding flags in test setup, not relying on environment defaults, because a flag that works when enabled but crashes when disabled (or vice versa) will eventually hit production when the rollout changes.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX