Feature flags are a debt instrument
4 min read
A payout service came up in a recovery region at 2am during a failover drill. It had been green in staging for weeks. Forty minutes after it took traffic, finance noticed payouts going out with the wrong fee, a bug that had been fixed a year earlier.
The fix was real and it was deployed. It just wasn't running. It lived behind a flag called new_payout_flow, which had been at 100% in production since April and had never been deleted. The recovery region had an empty flag store, an empty store means every flag defaults to off, and off meant the old code path. The one nobody had run, tested or even read in twelve months.
Nobody made a mistake that night. The flag had done its job and then stayed.
A flag is a branch you've promised to delete
Every flag is an if with two sides, and both sides have to keep working. While the rollout is happening that's a fair price, because it buys you a gradual release and a way back. Once the rollout is done, the price keeps being charged and the benefit is gone.
The arithmetic is unkind. Ten flags is 1,024 combinations of behaviour. You test perhaps three of them: everything off, everything as it is in production, and whatever the current rollout needs. The other thousand run in production only by accident, usually when something resets.
The old side is the dangerous one. It still compiles, so nothing flags it as dead. Teams edit the new path, because that's where the work is, and the old path slowly stops being a fallback and becomes an untested fork.
Give every flag an owner and an expiry at creation
The rule is short. A flag cannot be created without a name, an owner and a date.
defineFlag({
key: "new_payout_flow",
owner: "payments-platform",
expires: "2026-05-15",
kind: "release",
});
The date is the part people resist, because they don't know it yet. Write it anyway. A guess is better than nothing, and moving the date forward is a visible decision that someone has to make on purpose. Letting it slide is not.
Then make the date do something. A CI check that fails the build once a flag is past expiry is enough. A reminder in a wiki is not. Wikis are where good intentions go to be archived, and the whole point is that removal stops depending on anyone remembering.
Not every flag is a release flag
Most of the argument above applies to one kind of flag, so name the kinds.
- Release flags gate unfinished or risky code. They are temporary by definition. These get an expiry.
- Kill switches turn off a dependency or a feature when something is on fire. They are permanent on purpose. Give them an owner and a runbook entry, and test the off state on a schedule.
- Entitlement flags decide who gets what: plan tiers, beta access, per-tenant behaviour. That's configuration wearing a flag's clothes. Model it as data with a proper home, not as a toggle somebody will clean up one day.
Most flag systems have one boolean and one table for all three. That's how a kill switch ends up deleted in a tidy-up and a release flag ends up treated as permanent.
Default to the new path
The recovery region failed because of what an empty store meant. Decide that deliberately. For a release flag past 100%, the safe default is the new behaviour, and the better answer is to delete the flag so there is no default to get wrong. If a flag needs the old path on a missing value, that's a claim that the old path is still supported, and it should be tested like one.
What this costs
It's more friction up front. Product wants a flag in five minutes and you're asking who owns it and when it dies. Expiry dates will also fire at awkward moments, the build breaks on a Friday, and somebody extends it by a week without thinking. You will get some of that.
What you get back is a codebase where the number of states you have to reason about goes down over time instead of up. And when a flag does outlive its question, it's because someone chose to keep it, with their name on it.
Check your own flag list tonight. Sort by the date each one was last changed. Anything at 100% for more than a quarter is either a kill switch you forgot to label or a branch you owe a deletion.