Skip to content
Unverified — AI-generated content. Help verify this page

Feature Flags Deep Dive

Feature flags (also called feature toggles, feature switches, or feature gates) decouple deployment from release. You deploy code that is invisible to users, then reveal it gradually — by percentage, by user segment, by geography — with a configuration change instead of a code change. If something goes wrong, you flip the flag off. No rollback, no deploy, no downtime.

This sounds simple. In practice, feature flags introduce a combinatorial explosion of code paths, create testing nightmares, accumulate as technical debt, and require careful lifecycle management. This page covers the full depth: flag types, evaluation strategies, progressive rollout patterns, platform comparison, and the critical topic of flag debt.

Related: Feature Flags Deployment | A/B Testing | Feature Flag Service Blueprint


Flag Types

Not all flags serve the same purpose. Understanding the type determines the flag's lifecycle and ownership.

TypePurposeLifetimeOwnerExample
Release flagHide incomplete featuresDays to weeksEngineeringnew-checkout-flow
Experiment flagA/B test variantsWeeks to monthsProductpricing-page-variant-b
Ops flagKill switches, circuit breakersPermanentSRE/Opsdisable-recommendations
Permission flagFeature gating by plan/rolePermanentProductenterprise-sso
Migration flagGradual data/code migrationWeeksEngineeringuse-new-payment-processor

The Most Common Mistake

Treating all flags as permanent. Release flags and experiment flags must have a sunset date. If you don't remove them, you accumulate flag debt — dead code paths that no one dares to remove because no one knows if the flag is still evaluated somewhere.


Evaluation Logic

Basic Boolean Evaluation

typescript
// Simplest flag: on/off for everyone
if (featureFlags.isEnabled('new-dashboard')) {
  renderNewDashboard();
} else {
  renderOldDashboard();
}

Percentage Rollout

Roll out to a percentage of users. The key: the assignment must be sticky — the same user always gets the same result.

typescript
class FlagEvaluator {
  isEnabled(flagKey: string, userId: string): boolean {
    const flag = this.getFlag(flagKey);
    if (!flag.enabled) return false;

    // Sticky assignment via deterministic hash
    const hash = this.murmurHash(`${flagKey}:${userId}`);
    const bucket = hash % 100; // 0-99

    return bucket < flag.rolloutPercentage;
  }

  private murmurHash(key: string): number {
    let hash = 0;
    for (let i = 0; i < key.length; i++) {
      hash = (hash * 31 + key.charCodeAt(i)) | 0;
    }
    return Math.abs(hash);
  }
}

Why Hash, Not Random?

Using Math.random() means a user might see the new feature on one request and the old feature on the next. A deterministic hash of flagKey + userId ensures the same user always lands in the same bucket, even across multiple servers with no shared state.

Targeting Rules

typescript
interface FlagRule {
  attribute: string;         // 'country', 'plan', 'email', 'percentage'
  operator: 'eq' | 'neq' | 'in' | 'contains' | 'gte' | 'lte' | 'matches';
  value: string | string[] | number;
  variation: string;         // Which variant to serve
}

interface FlagConfig {
  key: string;
  enabled: boolean;
  defaultVariation: string;
  rules: FlagRule[];
  rolloutPercentage: number;
}

// Example flag configuration:
// {
//   key: "new-checkout",
//   enabled: true,
//   defaultVariation: "control",
//   rules: [
//     { attribute: "email", operator: "contains", value: "@example.com", variation: "treatment" },
//     { attribute: "country", operator: "in", value: ["US", "CA"], variation: "treatment" },
//     { attribute: "plan", operator: "eq", value: "enterprise", variation: "treatment" }
//   ],
//   rolloutPercentage: 25
// }

Evaluation Order


Progressive Rollout Strategies

Canary Rollout

Day 1:  1% (internal employees)     → Monitor errors, latency
Day 2:  5% (beta users)             → Monitor feature metrics
Day 3:  25% (random users)          → Monitor business KPIs
Day 5:  50%                         → Watch for edge cases
Day 7:  100%                        → Full rollout
Day 14: Remove flag                 → Clean up code

Ring-Based Rollout

Geography-Based Rollout

typescript
// Roll out to smaller markets first
const geoRollout = {
  'new-payment-flow': {
    phase1: { countries: ['NZ', 'IE'], percentage: 100 },  // Small markets
    phase2: { countries: ['AU', 'CA'], percentage: 100 },   // Medium markets
    phase3: { countries: ['US', 'GB'], percentage: 25 },    // Large markets (gradual)
    phase4: { countries: ['*'], percentage: 100 },           // Global
  }
};

Kill Switch Pattern

Ops flags that let you instantly disable expensive features during incidents:

typescript
class RecommendationService {
  async getRecommendations(userId: string): Promise<Product[]> {
    // Kill switch: disable during high load
    if (!featureFlags.isEnabled('enable-recommendations')) {
      return []; // Return empty, don't call the expensive ML service
    }

    return this.mlService.getRecommendations(userId);
  }
}

Platform Comparison

FeatureLaunchDarklyUnleashFlagsmithFliptPostHog
Pricing$$$$Open source + paidOpen source + paidOpen sourceOpen source + paid
Self-hostedNoYesYesYesYes
Streaming updatesSSESSE / WebhookPolling / SSEgRPC / PollingPolling
SDKs25+ languages15+ languages12+ languagesGo, Node, Python, RubyJS, Python, Go
A/B testingVia ExperimentationExternal onlyBuilt-inNoBuilt-in
Audit logYesYesYesYesYes
Segments/targetingAdvancedGoodGoodBasicGood
Edge evaluationYes (Relay Proxy)Yes (Edge)YesYes (Hybrid)No
Flag lifecycleStale detectionManualManualManualManual

Architecture: Server-Side vs. Client-Side Evaluation

Client-Side Security

Client-side SDKs receive the evaluated flag value for the current user, not the raw rules. This prevents users from seeing targeting rules, percentages, or other users' segments. Never send server-side flag configurations to the client.


Implementation Patterns

Feature Flag SDK Design

typescript
class FeatureFlagClient {
  private flags: Map<string, FlagConfig> = new Map();
  private refreshInterval: NodeJS.Timeout;

  constructor(private config: { apiKey: string; serverUrl: string }) {}

  async initialize(): Promise<void> {
    await this.fetchFlags();
    // Poll for updates every 30 seconds
    this.refreshInterval = setInterval(() => this.fetchFlags(), 30_000);
  }

  isEnabled(key: string, context?: EvaluationContext): boolean {
    const flag = this.flags.get(key);
    if (!flag || !flag.enabled) return false;

    if (!context) return flag.defaultVariation === 'treatment';

    // Evaluate targeting rules
    for (const rule of flag.rules) {
      if (this.matchesRule(rule, context)) {
        return rule.variation === 'treatment';
      }
    }

    // Fall through to percentage rollout
    if (context.userId) {
      const bucket = this.hash(`${key}:${context.userId}`) % 100;
      return bucket < flag.rolloutPercentage;
    }

    return false;
  }

  getVariation(key: string, context: EvaluationContext): string {
    const flag = this.flags.get(key);
    if (!flag || !flag.enabled) return flag?.defaultVariation ?? 'control';

    for (const rule of flag.rules) {
      if (this.matchesRule(rule, context)) {
        return rule.variation;
      }
    }

    if (context.userId) {
      const bucket = this.hash(`${key}:${context.userId}`) % 100;
      return bucket < flag.rolloutPercentage ? 'treatment' : 'control';
    }

    return flag.defaultVariation;
  }

  private matchesRule(rule: FlagRule, context: EvaluationContext): boolean {
    const value = context[rule.attribute as keyof EvaluationContext];
    if (value === undefined) return false;

    switch (rule.operator) {
      case 'eq': return value === rule.value;
      case 'neq': return value !== rule.value;
      case 'in': return Array.isArray(rule.value) && rule.value.includes(String(value));
      case 'contains': return String(value).includes(String(rule.value));
      default: return false;
    }
  }

  private hash(input: string): number {
    let h = 0;
    for (let i = 0; i < input.length; i++) {
      h = (h * 31 + input.charCodeAt(i)) | 0;
    }
    return Math.abs(h);
  }

  private async fetchFlags(): Promise<void> {
    const res = await fetch(`${this.config.serverUrl}/api/flags`, {
      headers: { Authorization: `Bearer ${this.config.apiKey}` },
    });
    const data = await res.json();
    this.flags = new Map(data.flags.map((f: FlagConfig) => [f.key, f]));
  }

  destroy(): void {
    clearInterval(this.refreshInterval);
  }
}

interface EvaluationContext {
  userId?: string;
  email?: string;
  country?: string;
  plan?: string;
  [key: string]: string | number | boolean | undefined;
}

Testing with Feature Flags

typescript
// Testing: override flags in tests
class TestFlagClient extends FeatureFlagClient {
  private overrides: Map<string, boolean> = new Map();

  withFlag(key: string, enabled: boolean): TestFlagClient {
    this.overrides.set(key, enabled);
    return this;
  }

  isEnabled(key: string): boolean {
    return this.overrides.get(key) ?? false;
  }
}

// Usage in tests:
const flags = new TestFlagClient()
  .withFlag('new-checkout', true)
  .withFlag('dark-mode', false);

Test Combinatorial Explosion

With N flags, you have 2N possible code paths. You cannot test all combinations. Focus on:

  1. All flags OFF (baseline)
  2. Each flag ON individually
  3. All flags ON (full integration)
  4. Known risky combinations

Flag Debt Management

Flag debt is the technical debt created by feature flags that outlive their purpose. Unused flags pollute the codebase with dead code paths.

The Flag Lifecycle

Stale Flag Detection

typescript
class FlagAuditor {
  async findStaleFlags(maxAgeDays: number = 30): Promise<StaleFlag[]> {
    const flags = await this.getAllFlags();
    const stale: StaleFlag[] = [];

    for (const flag of flags) {
      const daysSinceCreated = this.daysSince(flag.createdAt);
      const daysSinceModified = this.daysSince(flag.updatedAt);

      if (flag.rolloutPercentage === 100 && daysSinceModified > maxAgeDays) {
        stale.push({
          key: flag.key,
          reason: 'Fully rolled out for 30+ days — remove flag and dead code path',
          age: daysSinceCreated,
          owner: flag.createdBy,
        });
      }

      if (flag.rolloutPercentage === 0 && daysSinceModified > maxAgeDays) {
        stale.push({
          key: flag.key,
          reason: 'Disabled for 30+ days — remove flag and feature code',
          age: daysSinceCreated,
          owner: flag.createdBy,
        });
      }
    }

    return stale;
  }

  private daysSince(date: Date): number {
    return Math.floor((Date.now() - date.getTime()) / 86_400_000);
  }
}

Flag Hygiene Rules

RuleRationale
Every release flag must have a sunset datePrevents indefinite accumulation
Flag owner is responsible for removalClear accountability
Monthly stale flag reviewCatch forgotten flags
Max 50 active flags per servicePrevent combinatorial explosion
Flag naming convention: {type}-{feature}-{date}release-new-checkout-2026-03
Remove flag code within 2 sprints of 100% rolloutBefore context is lost

Summary

AspectDetail
PurposeDecouple deployment from release
Flag typesRelease, experiment, ops, permission, migration
EvaluationTargeting rules + sticky percentage rollout (deterministic hash)
Rollout strategiesCanary, ring-based, geography-based, percentage ramp
Kill switchesPermanent ops flags for incident response
PlatformsLaunchDarkly (SaaS), Unleash/Flagsmith/Flipt (self-hosted)
Key riskFlag debt — unused flags accumulating dead code paths
MitigationSunset dates, stale flag detection, monthly audits

Related: Feature Flags Deployment | A/B Testing | Canary Deployments | Release Engineering

"What I cannot create, I do not understand." — Richard Feynman