Skip to content

Feature Flags at Scale: Strategies for Progressive Delivery

Build a feature flag system with percentage rollouts, user segmentation, A/B testing and kill switches, decoupling deployment from release at any scale.

4 min read
A deployment pipeline with feature flags gating traffic percentages to new and old code paths

Deployment Is Not Release

Deploying code and releasing a feature are two different actions. Deployment is a technical operation—pushing artifacts to production. Release is a business decision—exposing functionality to users. Feature flags separate the two, letting you deploy dark code anytime and release features when you are ready, to whomever you choose.

The Feature Flag Contract

A feature flag is a runtime decision point. At its simplest, it is a boolean. At scale, it is a rule engine that evaluates user context against targeting rules to return a value.

tstypescript
interface FeatureFlag {
  key: string;
  type: "boolean" | "string" | "number" | "json";
  defaultValue: unknown;
  enabled: boolean;
  rules: TargetingRule[];
  killSwitch: boolean;
}
 
interface TargetingRule {
  priority: number;
  conditions: Condition[];
  percentage: number;
  value: unknown;
}
 
interface Condition {
  attribute: string;
  operator: "eq" | "neq" | "in" | "contains" | "gt" | "lt" | "semver_gt";
  value: unknown;
}
 
interface EvaluationContext {
  userId: string;
  email?: string;
  plan?: string;
  country?: string;
  appVersion?: string;
  customAttributes?: Record<string, unknown>;
}
tstypescript
class FeatureFlagClient {
  private flags: Map<string, FeatureFlag> = new Map();
 
  evaluate<T>(flagKey: string, context: EvaluationContext, fallback: T): T {
    const flag = this.flags.get(flagKey);
 
    // Flag doesn't exist — return fallback
    if (!flag) return fallback;
 
    // Kill switch activated — return default immediately
    if (flag.killSwitch) return flag.defaultValue as T;
 
    // Flag disabled globally — return default
    if (!flag.enabled) return flag.defaultValue as T;
 
    // Evaluate targeting rules in priority order
    const sortedRules = [...flag.rules].sort(
      (a, b) => a.priority - b.priority
    );
 
    for (const rule of sortedRules) {
      if (this.matchesConditions(rule.conditions, context)) {
        if (this.isInPercentage(context.userId, flagKey, rule.percentage)) {
          return rule.value as T;
        }
      }
    }
 
    return flag.defaultValue as T;
  }
 
  private matchesConditions(
    conditions: Condition[],
    context: EvaluationContext
  ): boolean {
    return conditions.every((condition) => {
      const contextValue = this.getAttribute(context, condition.attribute);
      switch (condition.operator) {
        case "eq":
          return contextValue === condition.value;
        case "neq":
          return contextValue !== condition.value;
        case "in":
          return Array.isArray(condition.value) &&
            condition.value.includes(contextValue);
        case "contains":
          return typeof contextValue === "string" &&
            contextValue.includes(condition.value as string);
        default:
          return false;
      }
    });
  }
 
  // Deterministic percentage based on userId + flagKey hash
  private isInPercentage(
    userId: string,
    flagKey: string,
    percentage: number
  ): boolean {
    if (percentage >= 100) return true;
    if (percentage <= 0) return false;
    const hash = this.consistentHash(`${userId}:${flagKey}`);
    return (hash % 100) < percentage;
  }
}

Progressive Rollout Patterns

Start with internal users, expand to beta testers, then ramp to 1%, 5%, 25%, 50%, 100%. If metrics degrade at any stage, halt or roll back instantly—no deployment needed.

tstypescript
// ❌ Big-bang release — all users get the feature at once
function NewCheckout() {
  return <RadicallyDifferentCheckout />;
}
 
// ✅ Progressive rollout with monitoring at each stage
function Checkout() {
  const flags = useFeatureFlags();
  const isNewCheckout = flags.evaluate("new-checkout-flow", {
    userId: user.id,
    plan: user.plan,
    country: user.country,
  }, false);
 
  return isNewCheckout ? <NewCheckoutFlow /> : <CurrentCheckoutFlow />;
}
 
// Rollout configuration evolution:
const rolloutStages: TargetingRule[] = [
  // Stage 1: Internal team only
  {
    priority: 1,
    conditions: [{ attribute: "email", operator: "contains", value: "@ourcompany.com" }],
    percentage: 100,
    value: true,
  },
  // Stage 2: Beta users
  {
    priority: 2,
    conditions: [{ attribute: "plan", operator: "eq", value: "beta" }],
    percentage: 100,
    value: true,
  },
  // Stage 3: 5% of all users
  {
    priority: 3,
    conditions: [],
    percentage: 5,
    value: true,
  },
  // Stage 4: 50% of all users
  // Stage 5: 100% — flag cleaned up
];

Server-Side vs Client-Side Evaluation

Where you evaluate flags affects latency, security, and reliability. Server-side evaluation keeps targeting rules private and ensures consistency. Client-side evaluation reduces latency for UI changes but exposes flag configurations.

tstypescript
// Server-side: evaluate on the API, send only the result
// app/api/flags/route.ts
export async function GET(request: Request) {
  const context = await buildContextFromRequest(request);
 
  // Only send evaluated values, never rules or conditions
  const flags = {
    "new-checkout": flagClient.evaluate("new-checkout", context, false),
    "dark-mode": flagClient.evaluate("dark-mode", context, false),
    "pricing-tier": flagClient.evaluate("pricing-tier", context, "standard"),
  };
 
  return Response.json(flags);
}
 
// Client-side: bootstrap with server-evaluated values
function FlagProvider({ children }: { children: React.ReactNode }) {
  const [flags, setFlags] = useState<Record<string, unknown>>({});
 
  useEffect(() => {
    fetch("/api/flags")
      .then((r) => r.json())
      .then(setFlags);
  }, []);
 
  return (
    <FlagContext.Provider value={flags}>
      {children}
    </FlagContext.Provider>
  );
}
 
function useFlag<T>(key: string, fallback: T): T {
  const flags = useContext(FlagContext);
  return (flags[key] as T) ?? fallback;
}

Flag Lifecycle and Tech Debt

Feature flags that live forever become tech debt. Every flag is a branch in your code. Establish a lifecycle: create, roll out, reach 100%, remove the flag and the old code path.

tstypescript
interface FlagMetadata {
  key: string;
  owner: string;
  createdAt: string;
  expectedRemovalDate: string;
  purpose: "release" | "experiment" | "ops" | "permission";
  jiraTicket: string;
}
 
// Automated flag staleness detection
function findStaleFlags(
  flags: FlagMetadata[],
  today: Date = new Date()
): FlagMetadata[] {
  return flags.filter((flag) => {
    const removalDate = new Date(flag.expectedRemovalDate);
    return removalDate < today;
  });
}
 
// ESLint rule concept: warn on flags past their removal date
// Flag inventory tracked alongside code
const FLAG_REGISTRY: FlagMetadata[] = [
  {
    key: "new-checkout-flow",
    owner: "payments-team",
    createdAt: "2025-06-01",
    expectedRemovalDate: "2025-08-01",
    purpose: "release",
    jiraTicket: "PAY-1234",
  },
  {
    key: "maintenance-mode",
    owner: "platform-team",
    createdAt: "2025-01-15",
    expectedRemovalDate: "9999-12-31", // Permanent ops flag
    purpose: "ops",
    jiraTicket: "PLAT-500",
  },
];

Monitoring and Observability

Every flag evaluation should emit telemetry. Without it, you cannot correlate flag states with error rates, latency changes, or business metrics.

tstypescript
class ObservableFeatureFlagClient extends FeatureFlagClient {
  constructor(private readonly metrics: MetricsClient) {
    super();
  }
 
  evaluate<T>(flagKey: string, context: EvaluationContext, fallback: T): T {
    const start = performance.now();
    const result = super.evaluate(flagKey, context, fallback);
    const duration = performance.now() - start;
 
    this.metrics.increment("feature_flag.evaluation", {
      flag: flagKey,
      result: String(result),
      userId: context.userId,
    });
 
    this.metrics.histogram("feature_flag.evaluation_ms", duration, {
      flag: flagKey,
    });
 
    return result;
  }
}

Key Takeaways

Feature flags decouple deployment from release. Deploy code anytime; release features to specific users, in specific percentages, with a toggle—not a deploy. Use progressive rollouts to limit blast radius: internal first, beta next, then ramp percentages with monitoring at each stage.

Evaluate flags server-side when targeting rules are sensitive or consistency matters. Track every flag with metadata—owner, purpose, expected removal date—and enforce cleanup to prevent tech debt accumulation. Emit telemetry on every evaluation so you can correlate flag states with production metrics.

The goal is not to have more flags. The goal is to ship with confidence, knowing that any new feature can be dialed back in seconds without touching the deployment pipeline.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX