Feature Flags: Every Flag Is a Branch You Promised to Delete

The event bus article ended on a warning about a certain kind of switch teams sprinkle everywhere with a casual "it's just a toggle" attitude — and then can't get rid of. Feature flags are that switch. They're one of the most genuinely useful tools in a mature frontend, and also one of the fastest ways to accumulate debt that never shows up on a dashboard until it hurts.
Let me be fair to them first, because they earn their place. A flag lets you deploy code without releasing it, roll a feature out to 5% of users and watch, keep a kill switch for something risky, or run an A/B test. Decoupling "the code is in production" from "users can see it" is a real superpower — it turns scary big-bang launches into calm, reversible dials. I'm not here to talk you out of flags. I'm here to talk about the bill that arrives later.
The hidden cost: every flag forks your code
Here's what a flag actually is, mechanically:
if (flags.newCheckout) { return <NewCheckout />; } return <LegacyCheckout />;
That's a fork. Your code now has two paths, and both of them ship to production. Fine for one flag. But flags don't come alone. Add newCheckout, expressPay, and giftingV2, and you don't have three toggles — you have up to eight combinations of code paths, and your test suite almost certainly covers two of them. The combination where newCheckout is on, expressPay is off, and giftingV2 is half-rolled-out is a real state a real user can hit, and nobody has ever seen it.
Every flag doubles the number of code paths you technically ship. Ten flags is a thousand possible combinations of your app, and you have tested maybe five of them.
This is the cost that never makes it onto the ticket. The flag went in to reduce risk, and each one individually does. But their product quietly grows a combinatorial space of untested app states, and that space is where the weird, unreproducible bugs live.
Not all flags are the same thing
The biggest conceptual mistake I see is treating "feature flag" as one category. It's at least four, and they have completely different lifespans:
- Release flags — "show the new checkout." Short-lived. The whole point is to remove them once the feature is fully rolled out. If it's still there three months after 100% rollout, it's dead weight.
- Ops / kill switches — "disable the recommendation engine if it's melting." Long-lived on purpose. These are operational controls, not temporary forks.
- Experiment flags — "variant A vs B." Live exactly as long as the experiment, then die with a decision.
- Permission / entitlement flags — "this customer's plan includes analytics." These aren't really flags at all. They're configuration or authorization, permanent by nature, and modeling them as feature flags muddies both systems.
The failure is conflating these. A release flag that's secretly treated as permanent config never gets deleted. An entitlement check that's built as a "flag" ends up in the same dusty list as abandoned experiments. Name the kind of flag when you create it, because the kind determines whether — and when — it's supposed to disappear.
Dead flags are the real debt
A release flag that's been at 100% for six months isn't neutral. It's a permanent if with a dormant else branch full of code that no longer runs but still compiles, still ships, still shows up when someone greps the codebase, and still makes every reader pause to figure out whether the old path matters. It's a comment that lies, except the compiler enforces the lie.
So flags need a removal discipline baked in from creation, not bolted on never. The cheapest version that works: give every flag an owner and an expiry, in the definition itself, so a stale flag becomes visible instead of invisible.
type FlagDefinition = { key: string; kind: 'release' | 'ops' | 'experiment'; owner: string; /** Release/experiment flags carry a date they're expected to be gone by. */ expires?: string; // ISO date description: string; }; const flags: FlagDefinition[] = [ { key: 'newCheckout', kind: 'release', owner: 'payments', expires: '2026-09-01', description: 'Rollout of the redesigned checkout flow.', }, ];
A tiny CI check that fails when a release flag is past its expires date turns "we'll clean it up eventually" into "the build reminds us." This is exactly the contract step from the changing-shared-components article: a deprecation is a promise to remove the old path, and a flag is a promise to remove one of two branches. Flags that never get contracted are the same debt, wearing a different hat.
Centralize evaluation behind a typed door
Architecturally, the thing that makes flags survivable is refusing to scatter raw checks through the codebase. If if (config.flags['new_checkout']) appears inline in forty components with forty slightly different string keys, you can neither find all usages nor safely delete the flag. Put evaluation behind one typed interface:
// keys are a closed set — typos are compile errors, usages are greppable type FlagKey = 'newCheckout' | 'expressPay' | 'giftingV2'; export function useFlag(key: FlagKey): boolean { const flags = useFlagContext(); return flags[key] ?? false; // safe default: off } // usage — one obvious, discoverable pattern everywhere function Checkout() { const newCheckout = useFlag('newCheckout'); return newCheckout ? <NewCheckout /> : <LegacyCheckout />; }
One door means you can list every flag from the FlagKey type, find every use of a flag by searching for one function, and delete a flag by deleting its key and chasing the compile errors — the same "smaller public API is a smaller promise" idea from the module boundaries work. One more thing specific to this series' static-export stack: decide deliberately whether a flag is evaluated at build time (baked into the static output, changing it needs a rebuild) or at runtime (fetched client-side, changeable live). Build-time flags are simpler and free; runtime flags cost you an evaluation path but let you flip without deploying. Neither is wrong — but conflating them leads to "why didn't my flag change do anything" confusion.
The reframe
Feature flags are a borrowing tool. Each one lets you borrow flexibility now — ship dark, roll out slow, bail out fast — against a promise to pay it back by deleting the branch later. Teams get in trouble not because they use flags, but because they only ever do the borrowing half. The flag goes in, the feature ships, everyone moves on, and the else branch sits there forever accruing interest in the form of code paths nobody understands.
So the discipline is simple to say and hard to keep: know what kind of flag you're adding, give the temporary ones an owner and a death date, put them all behind one typed door, and treat deleting a flag as part of shipping the feature, not a someday-chore. A flag system with a removal habit is a superpower. A flag system without one is a slowly growing maze.
There's one more cross-cutting concern that gets sprinkled through components with the same casual carelessness as flags and events — scattered, stringly-typed, and impossible to audit — and it happens to be a perfect customer for the typed-event thinking from the last two articles: analytics. That's where this cluster ends.
If you've got flags in your app, go count how many have been at 100% for more than a quarter. That number is your dead-branch debt, and I'd bet it's higher than anyone on the team would guess. Tell me what you find.
More than a blog post
I share frontend news and the reasoning behind it throughout the day. Pick the language that feels natural to you.