Published on

Feature Flags at Scale — Beyond Simple On/Off Toggles

Authors

Introduction

Feature flags decouple deployment from release, enabling trunk-based development, gradual rollouts, and instant rollbacks. Beyond simple on/off toggles, production systems use five flag types: release (new features), experiment (A/B tests), ops (infrastructure), permission (authorization), and kill switches (emergency stops). This post covers flag types, vendor options, percentage and user-based targeting, flag lifecycle, stale flag detection, and how flags enable trunk-based development without feature branches.

Flag Types and Decision Tree

Not all flags are created equal. Each type has different lifecycle, evaluation frequency, and business meaning.

// Flag evaluation context
interface EvaluationContext {
  userId?: string;
  organizationId?: string;
  environment: 'development' | 'staging' | 'production';
  userAttributes?: Record<string, string | number | boolean>;
}

// Flag types with distinct characteristics
enum FlagType {
  // New feature being rolled out gradually
  RELEASE = 'release',
  // A/B test comparing variations
  EXPERIMENT = 'experiment',
  // Infrastructure/performance flag
  OPS = 'ops',
  // Permission/entitlement flag
  PERMISSION = 'permission',
  // Emergency kill switch
  KILL_SWITCH = 'kill_switch',
}

interface FeatureFlag {
  id: string;
  key: string; // Code reference: 'new_dashboard_ui'
  type: FlagType;
  description: string;
  defaultValue: boolean | string;
  variations?: Record<string, unknown>;
  rules: FlagRule[];
  targets: FlagTarget[];
  percentage?: number; // 0-100
  createdAt: Date;
  createdBy: string;
  deprecatedAt?: Date; // When flag is flagged for removal
  expiresAt?: Date; // When flag auto-disables
}

interface FlagRule {
  id: string;
  name: string;
  conditions: Condition[]; // AND'd together
  variations: string[]; // Which variations can match
  priority: number; // Higher = evaluated first
}

interface Condition {
  attribute: string; // 'userId', 'plan', 'region'
  operator: 'equals' | 'contains' | 'gt' | 'lt' | 'in';
  value: unknown;
}

interface FlagTarget {
  userId?: string;
  organizationId?: string;
  variation: string | boolean;
}

class FeatureFlagService {
  constructor(
    private flagStore: Map<string, FeatureFlag>,
    private cache: Cache,
    private analytics: AnalyticsClient
  ) {}

  async evaluate(
    flagKey: string,
    context: EvaluationContext
  ): Promise<boolean | string> {
    // Check cache first (5 minute TTL)
    const cacheKey = `flag:${flagKey}:${context.userId || 'anon'}`;
    const cached = await this.cache.get(cacheKey);
    if (cached !== null) {
      this.analytics.track('flag_evaluated', {
        flagKey,
        userId: context.userId,
        cached: true,
      });
      return cached;
    }

    const flag = this.flagStore.get(flagKey);
    if (!flag) {
      throw new Error(`Flag not found: ${flagKey}`);
    }

    // Flags auto-disable when expired
    if (flag.expiresAt && flag.expiresAt < new Date()) {
      return flag.defaultValue;
    }

    let variation = flag.defaultValue;

    // Evaluate in priority order
    const rules = flag.rules.sort((a, b) => b.priority - a.priority);

    for (const rule of rules) {
      if (this.ruleMatches(rule, context)) {
        // Deterministically select variation for this user
        variation = this.selectVariation(
          rule.variations,
          context.userId || context.organizationId || 'anon'
        );
        break;
      }
    }

    // Check explicit targets (highest priority)
    const target = flag.targets.find(
      t => (t.userId && t.userId === context.userId) ||
           (t.organizationId && t.organizationId === context.organizationId)
    );

    if (target) {
      variation = target.variation;
    }

    // Percentage-based rollout (if no explicit target)
    if (flag.percentage && !target) {
      const hash = this.hashContext(flagKey, context);
      variation = hash < flag.percentage ? true : false;
    }

    // Cache result
    await this.cache.set(cacheKey, variation, 300);

    // Record evaluation
    this.analytics.track('flag_evaluated', {
      flagKey,
      userId: context.userId,
      variation,
    });

    return variation;
  }

  private ruleMatches(rule: FlagRule, context: EvaluationContext): boolean {
    return rule.conditions.every(condition => {
      const value = this.getContextValue(context, condition.attribute);

      switch (condition.operator) {
        case 'equals':
          return value === condition.value;
        case 'contains':
          return String(value).includes(String(condition.value));
        case 'gt':
          return Number(value) > Number(condition.value);
        case 'lt':
          return Number(value) < Number(condition.value);
        case 'in':
          return Array.isArray(condition.value) &&
                 condition.value.includes(value);
        default:
          return false;
      }
    });
  }

  private selectVariation(
    variations: string[],
    seed: string
  ): string {
    // Deterministic: same user always gets same variation
    const hash = this.simpleHash(seed);
    const index = hash % variations.length;
    return variations[index];
  }

  private hashContext(flagKey: string, context: EvaluationContext): number {
    // Percentage calculation: hash(flag + user) % 100
    const seed = `${flagKey}:${context.userId || context.organizationId}`;
    return Math.abs(this.simpleHash(seed)) % 100;
  }

  private simpleHash(str: string): number {
    let hash = 0;
    for (let i = 0; i < str.length; i++) {
      hash = ((hash << 5) - hash) + str.charCodeAt(i);
      hash = hash & hash;
    }
    return hash;
  }

  private getContextValue(context: EvaluationContext, attribute: string): any {
    if (attribute === 'userId') return context.userId;
    if (attribute === 'organizationId') return context.organizationId;
    if (attribute === 'environment') return context.environment;
    return context.userAttributes?.[attribute];
  }
}

LaunchDarkly vs OpenFeature vs Custom

Compare production flag services and standards.

// LaunchDarkly integration (SaaS, battle-tested)
import LaunchDarkly from 'launchdarkly-node-server-sdk';

const client = LaunchDarkly.init('sdk-key');

const user = { key: 'user-123', email: 'user@example.com' };
const flagValue = client.variation('new-dashboard', user, false);

// OpenFeature: vendor-agnostic standard
import { OpenFeature } from '@openfeature/js-sdk';
import { LaunchDarlyProvider } from '@openfeature/launchdarkly-provider';

OpenFeature.setProvider(new LaunchDarlyProvider('sdk-key'));
const client = OpenFeature.getClient();

const context = { userId: 'user-123', orgId: 'org-456' };
const { value } = await client.getBooleanDetails('new-dashboard', false, context);

// Custom implementation (when you own the flag platform)
class SelfHostedFlagService {
  private flags: Map<string, FeatureFlag>;
  private redis: RedisClient;
  private configRepo: GitHubRepository;

  constructor() {
    this.flags = new Map();
    this.loadFlagsFromGit();
  }

  private async loadFlagsFromGit() {
    // Flags defined in git (flags/production.yml)
    const content = await this.configRepo.getFileContent('flags/production.yml');
    const config = yaml.parse(content);

    for (const [key, flag] of Object.entries(config.flags)) {
      this.flags.set(key, {
        key,
        type: flag.type,
        defaultValue: flag.default,
        percentage: flag.percentage,
        rules: flag.rules || [],
        targets: flag.targets || [],
        createdAt: new Date(flag.created_at),
        createdBy: flag.created_by,
      });
    }
  }

  async evaluate(key: string, context: EvaluationContext): Promise<boolean> {
    // Check cache first
    const cached = await this.redis.get(`flag:${key}:${context.userId}`);
    if (cached !== null) return JSON.parse(cached);

    const flag = this.flags.get(key);
    if (!flag) return false;

    const result = this.evaluateFlag(flag, context);
    await this.redis.setex(`flag:${key}:${context.userId}`, 300, JSON.stringify(result));

    return result;
  }

  private evaluateFlag(flag: FeatureFlag, context: EvaluationContext): boolean {
    // Implementation similar to earlier
    return true;
  }
}

// Comparison
const comparison = {
  'LaunchDarkly': {
    hostingModel: 'SaaS',
    cost: 'Per user',
    setup: 'Minutes',
    support: 'Enterprise',
    analytics: 'Built-in',
    permissions: 'RBAC',
  },
  'OpenFeature': {
    hostingModel: 'Standard',
    cost: 'Depends on provider',
    setup: 'Via provider',
    support: 'CNCF',
    analytics: 'Provider-specific',
    permissions: 'Provider-specific',
  },
  'Custom/Self-Hosted': {
    hostingModel: 'Self-hosted',
    cost: 'Infrastructure',
    setup: 'Weeks',
    support: 'Internal',
    analytics: 'Must build',
    permissions: 'DIY',
  },
};

Percentage Rollouts

Gradually expose features to percentages of users.

class PercentageRollout {
  // Deterministic: same user always gets same bucket
  // Hash-based: user 123 is always in 45% if rollout is 50%
  static calculatePercentile(userId: string, flagKey: string): number {
    const combined = `${flagKey}:${userId}`;
    let hash = 0;

    for (let i = 0; i < combined.length; i++) {
      hash = ((hash << 5) - hash) + combined.charCodeAt(i);
      hash = hash & hash;
    }

    return Math.abs(hash) % 100;
  }

  static isUserIncluded(
    userId: string,
    flagKey: string,
    percentage: number
  ): boolean {
    const percentile = this.calculatePercentile(userId, flagKey);
    return percentile < percentage;
  }

  // Canary: gradually increase percentage
  static getCanaryPercentage(
    startDate: Date,
    currentDate: Date,
    canaryDurationDays: number,
    targetPercentage: number
  ): number {
    const elapsedDays = (currentDate.getTime() - startDate.getTime()) /
                        (1000 * 60 * 60 * 24);

    if (elapsedDays >= canaryDurationDays) {
      return targetPercentage;
    }

    // Linear increase
    return Math.floor((elapsedDays / canaryDurationDays) * targetPercentage);
  }
}

// Rollout strategy
interface RolloutStrategy {
  key: string;
  startDate: Date;
  targetPercentage: number;
  canaryDurationDays?: number;
  rolloutSchedule?: {
    date: Date;
    percentage: number;
  }[];
}

class RolloutManager {
  private strategies: Map<string, RolloutStrategy> = new Map();

  createCanaryRollout(
    flagKey: string,
    startDate: Date,
    targetPercentage: number,
    canaryDurationDays: number = 7
  ): void {
    this.strategies.set(flagKey, {
      key: flagKey,
      startDate,
      targetPercentage,
      canaryDurationDays,
    });
  }

  createScheduledRollout(
    flagKey: string,
    schedule: { date: Date; percentage: number }[]
  ): void {
    this.strategies.set(flagKey, {
      key: flagKey,
      startDate: schedule[0].date,
      targetPercentage: schedule[schedule.length - 1].percentage,
      rolloutSchedule: schedule,
    });
  }

  getPercentageForFlag(flagKey: string, now: Date = new Date()): number {
    const strategy = this.strategies.get(flagKey);
    if (!strategy) return 0;

    if (strategy.rolloutSchedule) {
      // Find current percentage based on schedule
      for (let i = strategy.rolloutSchedule.length - 1; i >= 0; i--) {
        if (now >= strategy.rolloutSchedule[i].date) {
          return strategy.rolloutSchedule[i].percentage;
        }
      }
      return strategy.rolloutSchedule[0].percentage;
    }

    if (strategy.canaryDurationDays) {
      return PercentageRollout.getCanaryPercentage(
        strategy.startDate,
        now,
        strategy.canaryDurationDays,
        strategy.targetPercentage
      );
    }

    return strategy.targetPercentage;
  }

  isUserInRollout(
    userId: string,
    flagKey: string,
    now: Date = new Date()
  ): boolean {
    const percentage = this.getPercentageForFlag(flagKey, now);
    return PercentageRollout.isUserIncluded(userId, flagKey, percentage);
  }
}

// Test determinism
describe('Percentage Rollouts', () => {
  it('same user always gets same bucket', () => {
    const userId = 'user-123';
    const flagKey = 'new_feature';

    const percentile1 = PercentageRollout.calculatePercentile(userId, flagKey);
    const percentile2 = PercentageRollout.calculatePercentile(userId, flagKey);

    expect(percentile1).toBe(percentile2);
  });

  it('consistent distribution across users', () => {
    const flagKey = 'test_flag';
    let count = 0;

    for (let i = 0; i < 10000; i++) {
      if (PercentageRollout.isUserIncluded(`user-${i}`, flagKey, 50)) {
        count++;
      }
    }

    // Should be approximately 50% (allow 1% variance)
    expect(count).toBeGreaterThan(4900);
    expect(count).toBeLessThan(5100);
  });

  it('canary rollout increases over time', () => {
    const startDate = new Date('2026-03-01');
    const flagKey = 'new_dashboard';

    // Day 1: ~0%
    let percentage = RolloutManager.getCanaryPercentage(
      startDate,
      new Date('2026-03-02'),
      7,
      100
    );
    expect(percentage).toBeLessThan(20);

    // Day 4: ~50%
    percentage = RolloutManager.getCanaryPercentage(
      startDate,
      new Date('2026-03-05'),
      7,
      100
    );
    expect(percentage).toBeGreaterThan(40);
    expect(percentage).toBeLessThan(60);

    // Day 8: 100%
    percentage = RolloutManager.getCanaryPercentage(
      startDate,
      new Date('2026-03-09'),
      7,
      100
    );
    expect(percentage).toBe(100);
  });
});

User Targeting Rules

Target specific users, organizations, or segments.

interface TargetingRule {
  id: string;
  name: string;
  enabled: boolean;
  conditions: TargetingCondition[];
  variation: string | boolean;
  priority: number; // Higher = evaluated first
}

interface TargetingCondition {
  attribute: string; // 'userId', 'email', 'plan', 'region'
  operator: 'equals' | 'startsWith' | 'endsWith' | 'contains' | 'in' | 'regex';
  value: string | string[];
  caseSensitive?: boolean;
}

class TargetingEngine {
  evaluateRules(
    context: EvaluationContext,
    rules: TargetingRule[]
  ): string | boolean | null {
    // Sort by priority (higher first)
    const sorted = [...rules].sort((a, b) => b.priority - a.priority);

    for (const rule of sorted) {
      if (!rule.enabled) continue;

      if (this.matchesAllConditions(context, rule.conditions)) {
        return rule.variation;
      }
    }

    return null;
  }

  private matchesAllConditions(
    context: EvaluationContext,
    conditions: TargetingCondition[]
  ): boolean {
    return conditions.every(condition =>
      this.matchesCondition(context, condition)
    );
  }

  private matchesCondition(
    context: EvaluationContext,
    condition: TargetingCondition
  ): boolean {
    const value = this.getAttribute(context, condition.attribute);
    if (value === undefined) return false;

    const strValue = String(value);
    const strCondition = condition.value;

    switch (condition.operator) {
      case 'equals':
        return condition.caseSensitive
          ? strValue === strCondition
          : strValue.toLowerCase() === String(strCondition).toLowerCase();

      case 'startsWith':
        return strValue.startsWith(String(strCondition));

      case 'endsWith':
        return strValue.endsWith(String(strCondition));

      case 'contains':
        return strValue.includes(String(strCondition));

      case 'in':
        return Array.isArray(condition.value) &&
               condition.value.includes(strValue);

      case 'regex':
        return new RegExp(String(strCondition)).test(strValue);

      default:
        return false;
    }
  }

  private getAttribute(context: EvaluationContext, attribute: string): any {
    // Standard attributes
    if (attribute === 'userId') return context.userId;
    if (attribute === 'organizationId') return context.organizationId;
    if (attribute === 'environment') return context.environment;

    // Custom attributes from userAttributes
    return context.userAttributes?.[attribute];
  }
}

// Example targeting rules
const rules: TargetingRule[] = [
  {
    id: 'rule-1',
    name: 'Beta testers',
    enabled: true,
    conditions: [
      {
        attribute: 'email',
        operator: 'endsWith',
        value: '@betatesters.com',
      },
    ],
    variation: 'variation_b',
    priority: 10,
  },
  {
    id: 'rule-2',
    name: 'Enterprise plan',
    enabled: true,
    conditions: [
      {
        attribute: 'plan',
        operator: 'equals',
        value: 'enterprise',
        caseSensitive: false,
      },
    ],
    variation: true,
    priority: 5,
  },
  {
    id: 'rule-3',
    name: 'Specific organizations',
    enabled: true,
    conditions: [
      {
        attribute: 'organizationId',
        operator: 'in',
        value: [
          'org-123',
          'org-456',
          'org-789',
        ],
      },
    ],
    variation: true,
    priority: 20,
  },
];

Stale Flag Detection and Cleanup

Identify and remove flags that are no longer needed.

interface FlagMetrics {
  flagKey: string;
  evaluations: number;
  lastEvaluatedAt: Date;
  usersAffected: number;
  variations: Record<string, number>; // Count per variation
  createdAt: Date;
  deprecatedAt?: Date;
}

class StaleFlagDetector {
  private metricsStore: MetricsDatabase;
  private threshold = {
    noEvaluationsForDays: 7,
    onlyDefaultVariation: true,
    evaluationThreshold: 100, // Minimum evaluations
  };

  async detectStaleFlags(): Promise<string[]> {
    const allFlags = await this.getFlags();
    const staleFlags: string[] = [];

    for (const flag of allFlags) {
      const metrics = await this.metricsStore.getMetrics(flag.key);

      // Not evaluated in last 7 days
      const daysSinceEval = this.daysSince(metrics.lastEvaluatedAt);
      if (daysSinceEval > this.threshold.noEvaluationsForDays) {
        staleFlags.push(flag.key);
        continue;
      }

      // Very low evaluation count (typo in flag key?)
      if (metrics.evaluations < this.threshold.evaluationThreshold) {
        staleFlags.push(flag.key);
        continue;
      }

      // Only ever evaluates to default variation
      if (
        this.threshold.onlyDefaultVariation &&
        Object.values(metrics.variations).every(
          (count, _, arr) => count === 0 || count === arr[0]
        )
      ) {
        staleFlags.push(flag.key);
      }
    }

    return staleFlags;
  }

  async deprecateFlag(
    flagKey: string,
    removalDate: Date = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days
  ): Promise<void> {
    const flag = await this.getFlag(flagKey);

    flag.deprecatedAt = new Date();
    flag.expiresAt = removalDate;

    await this.updateFlag(flag);

    // Send notifications
    await this.notifyTeam(
      `Flag ${flagKey} deprecated. Will be removed on ${removalDate.toISOString()}`
    );

    // Create PR removing from code
    await this.createCleanupPR(flagKey, removalDate);
  }

  private async createCleanupPR(
    flagKey: string,
    removalDate: Date
  ): Promise<void> {
    // Find usages
    const usages = await this.searchCodebase(flagKey);

    if (usages.length === 0) {
      console.log(`No code usages found for ${flagKey}`);
      return;
    }

    // Create branch
    const branchName = `cleanup/remove-flag-${flagKey}`;
    await this.git.createBranch(branchName);

    // Remove flag from code
    for (const usage of usages) {
      await this.removeUsage(usage);
    }

    // Create PR with deadline
    await this.git.createPullRequest({
      title: `Remove feature flag: ${flagKey}`,
      body: `This flag was deprecated on ${new Date().toISOString()} and should be removed by ${removalDate.toISOString()}`,
      labels: ['cleanup', 'feature-flags'],
    });
  }

  private daysSince(date: Date): number {
    return (Date.now() - date.getTime()) / (1000 * 60 * 60 * 24);
  }
}

// Automated cleanup job
async function cleanupStaleFlags(): Promise<void> {
  const detector = new StaleFlagDetector(metricsStore);
  const staleFlags = await detector.detectStaleFlags();

  console.log(`Found ${staleFlags.length} stale flags`);

  for (const flagKey of staleFlags) {
    await detector.deprecateFlag(flagKey);
  }
}

Trunk-Based Development with Flags

Use flags to enable continuous deployment without feature branches.

// No feature branches - just flags
// Branch from main, make changes, merge to main same day
// Gate new code behind flag

// src/features/new-dashboard.ts
const NewDashboardFeature = ({ user }: { user: User }) => {
  const [isEnabled] = useFeatureFlag('new_dashboard_ui', { userId: user.id });

  return isEnabled ? <NewDashboard /> : <LegacyDashboard />;
};

// Merge directly to main - flag off by default
// Gradually increase rollout percentage over days
// If issues detected, instant rollback by toggling flag

// Rollout schedule in git
// flags/production.yml
flags:
  new_dashboard_ui:
    type: release
    default: false
    rollout:
      schedule:
        - date: 2026-03-20
          percentage: 5 # canary
        - date: 2026-03-21
          percentage: 25
        - date: 2026-03-22
          percentage: 50
        - date: 2026-03-23
          percentage: 100
    canary_duration_days: 3

// Monitoring
// Alert if error rate increases when flag is enabled
// Alert if performance metrics degrade
// Auto-rollback if threshold breached

class RolloutMonitor {
  async monitorFlagRollout(flagKey: string): Promise<void> {
    const baseline = await this.getMetricsBeforeFlagEnable(flagKey);

    // Enable flag gradually
    await this.graduallyEnableFlag(flagKey);

    // Monitor metrics every minute
    const interval = setInterval(async () => {
      const current = await this.getCurrentMetrics(flagKey);

      const errorRateIncrease =
        (current.errorRate - baseline.errorRate) / baseline.errorRate * 100;

      if (errorRateIncrease > 50) {
        console.error(
          `Error rate increased ${errorRateIncrease}% for ${flagKey}`
        );
        await this.disableFlag(flagKey);
        await this.alertTeam(`Flag ${flagKey} auto-disabled due to errors`);
        clearInterval(interval);
        return;
      }

      const p99Regression =
        (current.p99Latency - baseline.p99Latency) / baseline.p99Latency * 100;

      if (p99Regression > 30) {
        console.error(`P99 latency increased ${p99Regression}% for ${flagKey}`);
        await this.disableFlag(flagKey);
        await this.alertTeam(`Flag ${flagKey} auto-disabled due to latency`);
        clearInterval(interval);
      }
    }, 60000);
  }

  private async graduallyEnableFlag(flagKey: string): Promise<void> {
    // Increase percentage slowly
    for (let percentage of [5, 10, 25, 50, 100]) {
      await this.setFlagPercentage(flagKey, percentage);
      // Wait for stabilization
      await new Promise(resolve => setTimeout(resolve, 300000)); // 5 minutes
    }
  }
}

Feature Flags Checklist

  • All flags have clear type (release, experiment, ops, permission, kill-switch)
  • Flags have expiration dates (auto-disable if not removed)
  • Default values are safe (usually false/disabled)
  • Targeting rules evaluated in priority order
  • Percentage rollouts use deterministic hashing (same user always same bucket)
  • Metrics tracked: evaluations, variations, affected users
  • Stale flags detected and deprecated automatically
  • Code cleanup PRs created for deprecated flags
  • Rollout monitored for error rate and performance regression
  • Flag failures logged without breaking application
  • Kill switch flags bypass all logic to stop feature instantly

Conclusion

Feature flags enable trunk-based development, safe rollouts, and instant rollbacks. Start with simple percentage-based rollouts, add user targeting, monitor metrics, and automate detection of stale flags. Use LaunchDarkly for managed SaaS, OpenFeature for vendor neutrality, or build custom when you control the entire platform.