Building a Feature Flag System That Doesn't Become Technical Debt
The Feature Flag Graveyard
Every codebase we audit has the same problem: hundreds of feature flags, most of them stale. Nobody knows which are still active. Nobody wants to remove them because "what if something breaks?" The flags that were supposed to reduce risk have become risk themselves.
// Real code we found in an audit
if (featureFlags.get("new_checkout_v2")) { // Added 2023
if (featureFlags.get("checkout_v2_fix")) { // Added 2023
if (featureFlags.get("checkout_v3_rollout")) { // Added 2024
renderCheckoutV3();
} else {
renderCheckoutV2Fixed();
}
} else {
renderCheckoutV2();
}
} else {
renderCheckoutV1(); // Nobody knows if this still works
}Three layers of flags, four possible code paths, and the team is afraid to touch any of it. This is what happens when you add flags without a plan to remove them.
The Feature Flag Lifecycle
Every flag should have a lifecycle defined at creation:
interface FeatureFlag {
// Identity
id: string;
name: string;
description: string;
// Lifecycle
type: "release" | "experiment" | "ops" | "permission";
createdAt: Date;
createdBy: string;
expiresAt: Date; // When this flag should be removed
owner: string; // Who's responsible for cleanup
// State
status: "active" | "rolled-out" | "expired" | "archived";
rolloutPercentage: number;
// Targeting
rules: TargetingRule[];
}The Four Flag Types
| Type | Purpose | Typical Lifespan | Example |
|---|---|---|---|
| Release | Gradual rollout of new features | 1-4 weeks | new_search_ui |
| Experiment | A/B test variants | 2-8 weeks | pricing_page_test_q1 |
| Ops | Kill switches, circuit breakers | Permanent | disable_external_api |
| Permission | User-level access control | Permanent | beta_access |
Release and experiment flags are temporary. They must have an expiration date. Ops and permission flags are permanent but should be reviewed quarterly.
The Flag System Architecture
Level 1: Config File (Teams of 1-5)
// flags.ts — checked into version control
export const FLAGS = {
"new-checkout": {
type: "release",
enabled: true,
rollout: 100, // Fully rolled out
expiresAt: "2026-03-01",
owner: "sarah",
description: "New checkout flow with express payment",
},
"search-experiment": {
type: "experiment",
enabled: true,
rollout: 50,
expiresAt: "2026-02-28",
owner: "mike",
description: "Testing AI-powered search suggestions",
},
"disable-recommendations": {
type: "ops",
enabled: false, // Kill switch — flip to true to disable
owner: "platform-team",
description: "Emergency kill switch for recommendation engine",
},
} as const;Pros: Simple, auditable, no external dependencies Cons: Requires deployment to change flags
Level 2: Database-Backed (Teams of 5-15)
// Flag evaluation with database-backed rules
async function evaluateFlag(
flagId: string,
context: FlagContext
): Promise<boolean> {
const flag = await flagStore.get(flagId);
if (!flag || !flag.enabled) return false;
if (flag.expiresAt && new Date() > flag.expiresAt) return false;
// Check targeting rules
for (const rule of flag.rules) {
if (matchesRule(rule, context)) {
return rolloutCheck(flag.rollout, context.userId);
}
}
return false;
}
// Deterministic rollout (same user always gets same result)
function rolloutCheck(percentage: number, userId: string): boolean {
const hash = createHash("md5").update(userId).digest("hex");
const bucket = parseInt(hash.substring(0, 8), 16) % 100;
return bucket < percentage;
}Level 3: Feature Flag Service (Teams of 15+)
Use LaunchDarkly, Unleash, Flagsmith, or similar. But still enforce lifecycle management.
The Cleanup System
This is the part everyone skips. Don't skip it.
Automated Expiration Alerts
// Run daily: find flags that should be cleaned up
async function auditFlags() {
const flags = await flagStore.getAll();
const now = new Date();
for (const flag of flags) {
// Flag is past its expiration date
if (flag.expiresAt && now > flag.expiresAt) {
await notify(flag.owner, {
type: "flag_expired",
message: `Flag "${flag.name}" expired on ${flag.expiresAt}. ` +
`Please remove the flag and clean up the code.`,
flagId: flag.id,
});
}
// Flag has been at 100% rollout for > 2 weeks
if (flag.type === "release" && flag.rollout === 100) {
const rolledOutAt = flag.lastModified;
const twoWeeksAgo = new Date(now.getTime() - 14 * 86400000);
if (rolledOutAt < twoWeeksAgo) {
await notify(flag.owner, {
type: "flag_stale",
message: `Flag "${flag.name}" has been at 100% for 2+ weeks. ` +
`Time to remove the flag and make it permanent.`,
flagId: flag.id,
});
}
}
}
}The Cleanup Checklist
When removing a flag:
## Flag Removal: [flag-name]
- [ ] Verify the flag is at 100% rollout (or 0% if removing the feature)
- [ ] Remove all flag evaluation code
- [ ] Remove the "else" branch (old code path)
- [ ] Remove the flag from the flag store/config
- [ ] Update tests that reference the flag
- [ ] Deploy and verify no errors
- [ ] Delete any related experiment data (if experiment type)The Quarterly Flag Audit
Every quarter, review ALL flags:
For each flag:
1. Is it still needed? (If release flag at 100%, remove it)
2. Is the owner still on the team? (Reassign if not)
3. Is the expiration date still accurate? (Extend or remove)
4. Is the code path for "flag off" still functional? (Test it)
Metrics to track:
- Total active flags (target: < 20 for small teams)
- Flags past expiration (target: 0)
- Average flag age (target: < 30 days for release flags)
- Flags without owners (target: 0)
The Rules
- Every release flag gets an expiration date at creation — no exceptions
- Every flag has an owner — when they leave, flags get reassigned
- Flags at 100% for 2+ weeks get removed — they're not flags anymore, they're dead code
- Maximum 20 active release/experiment flags — if you need more, clean up first
- Ops flags are reviewed quarterly — even permanent flags need attention
- Flag nesting is banned — if you need a flag inside a flag, your architecture has a problem
The Payoff
A well-managed feature flag system gives you:
- Safe deployments — ship code behind flags, roll out gradually
- Fast rollbacks — flip a flag instead of reverting a deployment
- Clean experiments — test variants without deployment risk
- Maintainable code — because stale flags get cleaned up automatically
The system that makes all of this possible isn't the flag evaluation engine — it's the lifecycle management. Build the cleanup into the system from day one, and feature flags will be the best tool in your engineering toolkit instead of the biggest source of technical debt.