Skip to content

Feature Flags at Scale

Feature flags decouple deployment from release — here's how to implement them without creating a tangled mess of conditional logic across your codebase.

3 min read
Feature flag decision tree showing how flags control code paths in production

Feature flags are one of the most powerful tools in modern software delivery. They let you deploy incomplete features to production, run A/B tests, gradually roll out changes, and kill problematic features instantly — without redeploying. But without discipline, flags turn into technical debt that makes every code path harder to reason about.

The Simplest Implementation

At its core, a feature flag is a conditional. The question is where the flag value comes from and how it's managed.

tstypescript
// ❌ Hard-coded flag — requires redeployment to change
const NEW_CHECKOUT = true;
 
if (NEW_CHECKOUT) {
  renderNewCheckout();
} else {
  renderLegacyCheckout();
}
 
// ✅ Runtime flag — changeable without deployment
const flags = await getFeatureFlags(userId);
 
if (flags.isEnabled("new-checkout")) {
  renderNewCheckout();
} else {
  renderLegacyCheckout();
}

Runtime flags are the goal. They can be toggled in seconds through a dashboard, compared to minutes or hours for a deployment.

Flag Types

Not all flags serve the same purpose. Categorizing them determines lifecycle and cleanup strategy.

TypePurposeLifetimeExample
Release flagGate incomplete featuresDays to weeksnew-checkout
Experiment flagA/B testingWeeks to monthscheckout-redesign-v2
Ops flagKill switch for loadPermanentdisable-search-indexing
Permission flagUser-tier featuresPermanentpremium-analytics

Release flags should be short-lived and cleaned up aggressively. Experiment flags live through the test period. Ops and permission flags are permanent infrastructure.

Building a Flag Service

tstypescript
interface FeatureFlag {
  key: string;
  enabled: boolean;
  rolloutPercentage: number;
  allowedUserIds: string[];
  rules: FlagRule[];
}
 
interface FlagRule {
  attribute: string;
  operator: "eq" | "in" | "gt" | "lt";
  value: unknown;
}
 
class FeatureFlagService {
  private flags: Map<string, FeatureFlag>;
 
  constructor(flags: FeatureFlag[]) {
    this.flags = new Map(flags.map((f) => [f.key, f]));
  }
 
  isEnabled(key: string, context: FlagContext): boolean {
    const flag = this.flags.get(key);
    if (!flag) return false;
    if (!flag.enabled) return false;
 
    // Explicit allow list
    if (flag.allowedUserIds.includes(context.userId)) return true;
 
    // Rule-based targeting
    if (flag.rules.length > 0) {
      return flag.rules.every((rule) => this.evaluateRule(rule, context));
    }
 
    // Percentage rollout — deterministic per user
    if (flag.rolloutPercentage < 100) {
      const hash = this.hashUserForFlag(context.userId, key);
      return hash < flag.rolloutPercentage;
    }
 
    return true;
  }
 
  private hashUserForFlag(userId: string, flagKey: string): number {
    // Deterministic hash — same user always gets same result
    let hash = 0;
    const input = `${userId}:${flagKey}`;
    for (let i = 0; i < input.length; i++) {
      hash = (hash * 31 + input.charCodeAt(i)) | 0;
    }
    return Math.abs(hash) % 100;
  }
 
  private evaluateRule(rule: FlagRule, context: FlagContext): boolean {
    const value = context[rule.attribute];
    switch (rule.operator) {
      case "eq": return value === rule.value;
      case "in": return (rule.value as unknown[]).includes(value);
      case "gt": return (value as number) > (rule.value as number);
      case "lt": return (value as number) < (rule.value as number);
      default: return false;
    }
  }
}

The hash-based percentage rollout is crucial: it ensures a user consistently sees the same variant without storing assignments. Increase the percentage to gradually expose more users.

Gradual Rollouts

The safest way to release a feature is incrementally: internal users first, then a small percentage, then wider.

tstypescript
// Rollout strategy for a major feature
const rolloutStages = [
  { percentage: 0, allowList: ["internal-team-ids"], duration: "2 days" },
  { percentage: 5, allowList: [], duration: "3 days" },
  { percentage: 25, allowList: [], duration: "2 days" },
  { percentage: 50, allowList: [], duration: "2 days" },
  { percentage: 100, allowList: [], duration: "permanent" },
];
tstypescript
// Monitor error rates at each stage
function shouldProceedToNextStage(metrics: StageMetrics): boolean {
  const errorRateThreshold = 0.01; // 1%
  const latencyP99Threshold = 500; // ms
 
  return (
    metrics.errorRate < errorRateThreshold &&
    metrics.latencyP99 < latencyP99Threshold &&
    metrics.userComplaints === 0
  );
}

If error rates spike at 5%, you roll back to 0% instantly — no deployment, no downtime.

The Cleanup Problem

The biggest risk with feature flags isn't the feature — it's the flags themselves. Stale flags accumulate and create an impossible-to-test combinatorial explosion.

tstypescript
// ❌ Stale flags — nobody knows if these are still needed
if (flags.isEnabled("new-checkout")) {
  if (flags.isEnabled("checkout-v2-variant-b")) {
    if (flags.isEnabled("express-checkout")) {
      // How many code paths is this? 2^3 = 8 combinations to test
    }
  }
}
 
// ✅ Clean up flags after full rollout
// 1. Roll out to 100%
// 2. Monitor for 1 week
// 3. Remove the flag check, keep only the new code path
// 4. Delete the flag from the configuration

Add expiration dates to release flags. Track flag age in your dashboard. A release flag older than 30 days is tech debt.

Testing With Flags

Feature flags complicate testing. You need to test both paths — and ideally the transition between them.

tstypescript
describe("checkout", () => {
  it("renders new checkout when flag is enabled", () => {
    const flags = new FeatureFlagService([
      { key: "new-checkout", enabled: true, rolloutPercentage: 100, allowedUserIds: [], rules: [] },
    ]);
 
    const result = renderCheckout(flags, testContext);
    expect(result).toContain("Express Checkout");
  });
 
  it("renders legacy checkout when flag is disabled", () => {
    const flags = new FeatureFlagService([
      { key: "new-checkout", enabled: false, rolloutPercentage: 0, allowedUserIds: [], rules: [] },
    ]);
 
    const result = renderCheckout(flags, testContext);
    expect(result).toContain("Standard Checkout");
  });
});

Key Takeaways

  1. Feature flags decouple deployment from release — deploy daily, release when ready
  2. Categorize flags by type — release flags are temporary, ops flags are permanent
  3. Gradual rollouts reduce risk — start at 0%, increase based on metrics
  4. Hash-based percentage rollout ensures users consistently see the same variant
  5. Clean up flags aggressively — stale flags create untestable combinatorial explosions
  6. Every flag needs an owner and an expiration date for release-type flags
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX