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.

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.
// ❌ 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.
| Type | Purpose | Lifetime | Example |
|---|---|---|---|
| Release flag | Gate incomplete features | Days to weeks | new-checkout |
| Experiment flag | A/B testing | Weeks to months | checkout-redesign-v2 |
| Ops flag | Kill switch for load | Permanent | disable-search-indexing |
| Permission flag | User-tier features | Permanent | premium-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
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.
// 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" },
];// 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.
// ❌ 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 configurationAdd 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.
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
- Feature flags decouple deployment from release — deploy daily, release when ready
- Categorize flags by type — release flags are temporary, ops flags are permanent
- Gradual rollouts reduce risk — start at 0%, increase based on metrics
- Hash-based percentage rollout ensures users consistently see the same variant
- Clean up flags aggressively — stale flags create untestable combinatorial explosions
- Every flag needs an owner and an expiration date for release-type flags


