The Feature Flag Trap: Why Your Kill Switch Can Kill You
For: An engineering manager at a 40-person B2B SaaS company who has been shipping feature flags for two years and now has 200+ flags in production, three of which nobody can confidently say are safe to delete — and one of which they suspect is the reason a bizarre intermittent bug appears only for accounts created before a specific date
A feature flag is not a deployment tool. It's a temporary branch that lives in your database instead of your Git history — and unlike a Git branch, it never shows up in code review, never gets merged, and never gets deleted unless a human remembers to delete it. That's the trap. Once you have more than a few dozen flags, you no longer have a codebase; you have a codebase plus a runtime configuration graph that silently multiplies the number of possible code paths in production. The intermittent bug affecting only accounts created before a specific date? That's almost certainly a flag your team forgot exists.
This post explains why feature flag systems accumulate technical debt faster than any other part of your stack, and what to do about it before the flag layer becomes harder to reason about than the feature itself.
The problem feature flags were supposed to solve
The original pitch is clean. You want to ship code continuously but not release features continuously. You want to test in production with a small user segment. You want a kill switch for the on-call engineer at 3 AM. Feature flags decouple deploy from release, and that decoupling is genuinely valuable.
The trouble is that flags solve a deployment problem by creating a state problem. Every flag you add is a branch in your control flow that now depends on a value fetched at runtime from an external system — LaunchDarkly, Unleash, Flagsmith, your own feature_flags table, whatever. Your code no longer describes what your application does. It describes what your application could do, given some configuration.
The right analogy: flags are Git branches that never merge
Think about how you treat a long-lived Git branch. It goes stale. It drifts from main. Merging it later gets more painful the longer it sits. You have tooling — git branch --merged, PR dashboards, stale branch alerts — because the industry learned the hard way that unmerged branches are debt.
A feature flag is the same thing, with two important differences:
- It's live in production. Both sides of the branch execute for real users, right now.
- It has no merge event. There is no moment where the flag "goes in." Removal is a manual code change you have to remember to do, review, and ship — usually months after anyone remembers why the flag existed.
This is why feature flag technical debt compounds. If you have 10 flags, each request potentially traverses 2^10 = 1024 logical paths. Most combinations are impossible in practice, but you can't easily prove which ones. Add flag #11 on top of flag #7's code path, and now flag #7 can't be removed without understanding flag #11. Flags entangle.
A minimal worked example of how flags entangle
Imagine a billing service. Six months ago you added a flag new_tax_engine to migrate to a rewritten tax module. Rollout went fine. Nobody removed the flag.
if (flags.isEnabled('new_tax_engine', account)) {
tax = newTaxEngine.calculate(cart);
} else {
tax = legacyTaxEngine.calculate(cart);
}
Three months later, a different engineer adds eu_vat_rules for European accounts. They add it inside the new engine because that's the only one they know about:
if (flags.isEnabled('new_tax_engine', account)) {
tax = newTaxEngine.calculate(cart);
if (flags.isEnabled('eu_vat_rules', account)) {
tax = applyEuVat(tax, cart);
}
} else {
tax = legacyTaxEngine.calculate(cart);
}
Now new_tax_engine is load-bearing. If you delete it, EU accounts on the legacy engine silently lose VAT logic. But nobody documented that dependency. The flag config panel shows both flags at 100%. Everything looks safe to delete. It isn't.
Multiply this by 200 flags added over two years by rotating engineers, and you have your current situation.
The gotchas nobody tells you about
1. Flag evaluation order is undocumented logic
When two flags touch the same code path, the order in which they're checked is the business rule. That rule lives in nobody's head after six months.
2. Targeting rules are a second config system
"Enabled for accounts created after 2023-06-01 in the US on the Pro plan" is application logic. It's just application logic that lives in a vendor dashboard, isn't code-reviewed, and doesn't show up in git blame. Your intermittent bug affecting old accounts is almost certainly this.
3. Flags outlive the engineers who added them
The person who added experimental_search_v2 left the company. The flag is at 100%. Is the old code path still reachable? Only one way to find out — and it might be in production.
4. Flag SDKs fail open or fail closed inconsistently
What happens when your flag provider has an outage? Some SDKs default to the last cached value, some to the code default, some throw. Mix defaults across a large flag set and your fallback behavior is unpredictable.
5. Testing coverage collapses
Your CI runs with some default flag state. Production runs with dozens of different states across user segments. The combinations you actually run in production are almost never the combinations you tested.
How to unwind a mature flag system without breaking things
You cannot fix this with a weekend cleanup sprint. Treat it like paying down debt.
- Inventory first, delete second. Export every flag with: current rollout %, last targeting rule change, creator, and the git grep of every reference in code. If a flag has zero code references, it's dead — delete it from the provider. That's the free win.
- Categorize the rest. Every flag is one of four things: (a) release toggle — meant to be temporary, (b) ops toggle — kill switch, keep forever, (c) permission/entitlement — should be in your billing or RBAC system, not a flag, (d) experiment — should have an end date.
- Give every new flag an expiry date. Enforce it in code review. Flags without an owner and a removal ticket don't ship. Some teams put the expiry in the flag name:
release_new_checkout_2024q3. Ugly, effective. - Add a linter. A CI check that fails when a flag is older than 90 days without a renewal justification catches the drift before it compounds.
- Migrate permission-style flags out. If
enterprise_ssois really "which customers paid for SSO," that belongs in your entitlements service, not a flag. This alone often removes 20-30% of the flag count. - Delete in production before deleting in code. Set the flag to a fixed value in the provider for a week. If nothing breaks, then remove the code branch. This gives you a fast rollback that doesn't require a redeploy.
When feature flags are worth the debt, and when they aren't
Use flags when: you need a runtime kill switch for a risky subsystem; you're doing genuine A/B experiments with a defined end date; you're rolling out a schema or infrastructure migration where you need to switch traffic gradually; you have compliance-driven staged rollouts.
Don't use flags when: the "flag" is really a customer entitlement (use your billing system); the change is small enough that a normal deploy with quick rollback is fine; you're using flags to avoid making a decision ("we'll ship it behind a flag and decide later" is how you get 200 flags); the branching logic will clearly outlive the rollout.
The blunt version: every flag is a promise to come back and delete it. If your team doesn't have the discipline to keep that promise, you don't have a flag system — you have a slow-motion configuration bomb. Teams working on legacy modernization or migrating off monoliths tend to hit this wall around the two-year mark, which matches your timeline exactly.
Frequently Asked Questions
How many feature flags is too many?
There's no absolute number, but a useful heuristic: if the ratio of active release flags to engineers exceeds roughly 3:1, you're probably accumulating flags faster than you're retiring them. More importantly, if any engineer on the team can't confidently say what happens when a given flag is deleted, you have too many regardless of the count.
Should we build our own feature flag system or use LaunchDarkly, Unleash, or Flagsmith?
Build your own only if you have unusual requirements — strict data residency, sub-millisecond evaluation, or deep integration with a custom entitlements system. Otherwise the vendors are cheaper than your engineering time, and their SDKs handle edge cases (SDK caching, streaming updates, fail-open behavior) that are annoying to get right yourself.
How do I safely delete a feature flag that's been at 100% for over a year?
Do a full code search for the flag key (including string concatenations and config files). Set the flag to a hardcoded value in the provider for at least a week to catch any lazy-loaded consumers. Then remove the code branch, ship, and delete the flag from the provider last. Never delete provider-side and code-side in the same change.
Are feature flags the same as A/B testing?
No, though they share plumbing. A/B testing requires statistical analysis, defined success metrics, and a fixed end date. Feature flags are a superset — they include kill switches, gradual rollouts, and permission gates that have nothing to do with experimentation. Conflating them is one reason experiment flags never get deleted.
What's the difference between a feature flag and a configuration setting?
A configuration setting describes how the application should behave (timeout values, feature limits, API endpoints). A feature flag describes which of two code paths should run. Config settings are meant to be permanent; feature flags are meant to be temporary. When temporary flags become permanent, they should be converted to config or removed — leaving them as flags is where the debt comes from.
Found this useful? CodeNicely publishes engineering and product playbooks weekly. Browse the archive or tell us what you're building.
_1751731246795-BygAaJJK.png)