Feature Flags Without Building a Second Product
Flags decouple deploy from release and quietly multiply the number of systems you ship. The four kinds, and why only one should be long-lived.
Feature flags separate deploying code from releasing behaviour. That is genuinely valuable: you can ship continuously, roll out gradually, and turn something off without a deploy.
For a separate people-operations perspective, the detailed guide covers coordinating distributed teams.
The cost is that each independent flag doubles the number of code paths that exist, and nobody tests all of them. Ten independent boolean flags describe 1024 possible configurations. Your test suite covers one or two.
The discipline is entirely about removing them.
Four kinds, and they have different lifetimes
Conflating these is the root of most feature flag problems.
Release flags. Hide incomplete work so it can be merged and deployed before it is finished. Lifetime: until the feature ships. Days or weeks. These should be removed aggressively and they are the ones that most often are not.
Operational flags. Kill switches. Turn off an expensive feature under load, disable a failing integration. Lifetime: as long as the feature exists. Legitimately long-lived, and there should be few.
Experiment flags. A/B tests. Lifetime: the experiment. Weeks. Removing them is part of concluding the experiment, and an experiment that never concludes is a flag that never leaves.
Permission flags. Which customers have which functionality. This is not a feature flag — it is a product entitlement, and it belongs in your permissions model rather than in a flag system. Flag systems are not built for it and conflating them means your entitlements have no audit trail.
Why they accumulate
Removing one is unrewarded work. It ships no value and carries a small risk.
Nobody knows if it is still needed. The person who added it has moved on and the flag has no owner.
It becomes load-bearing. Someone built something that depends on the flag being in a particular state, so it can no longer be removed without care.
The default drifted. The flag is on for everyone and the off path has not been exercised in a year — so nobody knows whether it still works, and nobody wants to find out.
And then a flag becomes a permanent branch in the code, which is the thing flags were supposed to avoid.
The practices that keep it under control
An expiry date on every flag, set at creation. Not aspirational — enforced. A build warning after the date, a failing build a month later. Several flag platforms support this and where they do not, a scheduled check over the flag definitions is a short script.
An owner on every flag. A person or a team, recorded with the flag.
A default in code, so the system behaves sensibly if the flag service is unavailable. A flag service that goes down should not take your application with it, and the default should be the safe path — usually the old behaviour.
Cache flag values locally with a short refresh. Evaluating a flag should never be a network call on the request path.
A ticket to remove it, created when the flag is created. Not "later" — a real item, scheduled.
A limit on concurrent flags. A number your team agrees to and holds. When you hit the ceiling, one comes out before another goes in. Arbitrary and effective.
Keeping it testable
You cannot test every combination. You can constrain the problem.
Test the two states of each flag independently, not the combinatorial product.
Run the full suite with all flags at their production values, and again with all new flags flipped. That covers the two configurations that actually matter.
Avoid nesting flags. A flag whose behaviour depends on another flag is where the untested combinations live, and it is a strong signal the first flag should have been removed before the second was added.
Assert the off path still works for long-lived operational flags. A kill switch nobody has exercised is a kill switch that fails when needed — this is the same failure as an untested backup. Exercise them on a schedule, in production, deliberately.
Rolling out with them
Percentage rollout should be sticky per user, not random per request. A user who sees the new behaviour on one page and the old one on the next has a broken experience and will report a bug that nobody can reproduce.
Hash a stable identifier to decide, so the same user always lands the same way.
Watch metrics per cohort, not in aggregate. A feature broken for 5% of users is invisible in an overall error rate and obvious in a per-cohort one. This is the entire point of gradual rollout and it is frequently not set up, which makes the rollout a slow deploy rather than a controlled experiment.
Know the rollback. Turning the flag off should restore the previous behaviour completely. If the new path wrote data the old path cannot read, it does not — and that is a migration problem wearing a flag. See schema migrations on a running system.
Flags and data
The case people get wrong.
If the new code path writes a different data shape, turning the flag off does not undo it. The old code now encounters data it does not understand.
The rule: the data change must be compatible with both paths before either path is enabled. Expand the schema, deploy both readers, then flag the writer. The flag controls behaviour, not data format.
The cleanup
Removing a flag means removing the branch, not setting the default. A flag hard-coded to true is still two paths in the code and still untested in one direction.
Delete the losing path entirely. If you are keeping it "just in case," the flag is not ready to be removed.
Search for the flag name across the codebase, including configuration, tests, and any dashboards or alerts referencing it.
Then delete the flag from the service, or it lingers as a definition nobody can trace.
The summary
Flags decouple deploy from release, and each one doubles the paths.
Four kinds with four lifetimes — and only operational kill switches should be long-lived.
Expiry dates and owners at creation, enforced by the build rather than by intention.
Sticky rollout, per-cohort metrics, and a default in code for when the flag service is unavailable.
And exercise your kill switches, because an untested one is not a kill switch.