Feature Flags Are Technical Debt With a Good Excuse
They are genuinely useful and every one you leave in place multiplies the number of code paths nobody tests. Here is how to keep the benefit.
Feature flags solve a real problem. Deploying is not releasing, you can ship incomplete work safely, and you can turn something off without a rollback.
They also have a cost that is rarely counted, and the cost compounds.
Every flag doubles the number of possible states of your system. Ten flags is 1024 combinations. You test perhaps three of them. The others exist in production and nobody has ever run them.
I am not arguing against flags. I use them. I am arguing that they are debt taken deliberately, and like any debt the problem is not taking it, it is not paying it back.
What goes wrong
Combinations nobody tested. A bug that only appears with flag A on and flag B off, for users in one region. It is real, it is in production, and it is not in any test.
Flags that outlive their purpose. A flag for a migration completed eighteen months ago, still in the code, still evaluated on every request, and now nobody is sure whether the old path still works or whether anything depends on it.
Flags that become configuration. A flag intended as a temporary rollout mechanism becomes the permanent way a customer specific behaviour is expressed. Now it can never be removed and it is not really a flag, it is a badly modelled feature.
Dead code that looks alive. The disabled branch is still compiled, still imported, still reviewed, still updated by refactors. Somebody spends an afternoon fixing a bug in a code path that has been off for a year.
Debugging becomes conditional. "It works for me" now depends on your flag evaluation. A support ticket cannot be reproduced without knowing the exact flag state of that user at that moment, and if you do not log flag state you cannot recover it.
That last one is the practical cost that hits hardest. Debugging is finding which assumption is false, and a flag is an assumption you cannot see from the code.
The categories, which have different lifespans
Treating all flags the same is the root of the problem. They are not the same thing.
Release flags hide incomplete work. Lifespan: until the feature ships. Days to weeks. These should be aggressively removed, and if one is older than a release cycle something has gone wrong with the feature, not with the flag.
Experiment flags run A/B tests. Lifespan: until the experiment concludes. Weeks. These have a natural end date and should be deleted when the result is in, including the losing variant.
Operational flags are kill switches for expensive or risky subsystems. Lifespan: permanent, by design. "Disable recommendations if the service is degraded" is infrastructure, not debt.
Permission flags gate features by plan or customer. Lifespan: permanent. These are not really feature flags, they are entitlements, and modelling them as flags is a mistake that causes trouble later. They belong in your data model.
The first two must have expiry. The second two should not be in the same system as the first two, because mixing them means nobody can tell which flags are safe to remove.
Keeping the debt manageable
Give every temporary flag an expiry date, in the code
export const flags = defineFlags({
newCheckoutFlow: {
type: "release",
owner: "rohit",
created: "2026-05-19",
expires: "2026-07-01",
description: "New checkout. Remove after full rollout.",
},
});
Then fail the build when one is past due:
# CI step
node scripts/check-flag-expiry.js || exit 1
ERROR: flag "newCheckoutFlow" expired 2026-07-01 (owner: rohit)
Remove the flag or extend with a written reason.
A build failure is the only mechanism I have seen actually work. A dashboard listing stale flags gets ignored, a linked ticket gets deprioritised, and a failing build gets dealt with.
Allow extension, and require a reason recorded in the code. Extending twice is a signal to talk about why.
Count them and make the count visible
If your flag count only goes up, you are accumulating debt with no repayment.
Graph it. A team that adds four flags a month and removes zero has a problem that is invisible in any individual pull request and obvious in a trend line.
Never nest flag checks
if (flags.newCheckout) {
if (flags.expressPay) {
if (flags.newAddressForm) {
Eight paths in one function, and the combinations were never tested together. If two flags interact, they should be one flag with three states rather than two booleans.
Log the flag state with every request
Non negotiable if you want to be able to debug.
logger.info({
event: "request.completed",
requestId,
flags: evaluatedFlags, // the actual values used for this request
}, "request completed");
Without this, a bug report is unreproducible because you do not know which code path ran. With it, you filter your logs by flag combination and the pattern appears immediately.
This is part of structured logging generally, and flags are the field people most often forget.
Test both paths, and delete tests with the flag
describe.each([true, false])("checkout with newFlow=%s", (newFlow) => {
beforeEach(() => setFlag("newCheckoutFlow", newFlow));
// tests
});
Doubling the test matrix per flag is a real cost, and it is the honest cost of having the flag. If doubling is too expensive, that is information: you have too many flags in that area.
When you remove the flag, remove the parameterisation in the same commit.
Default to off, and fail closed
const enabled = flags.get("newCheckout") ?? false;
If the flag service is unreachable, you get the old behaviour rather than an unpredictable one. A flag system that fails open turns its own outage into a release of every unfinished feature simultaneously, which is a genuinely bad afternoon.
Also cache flag values locally so a flag service outage does not take down your application. It is a dependency in the request path and it should not be a hard one.
Removing a flag properly
Four steps, in order:
- Confirm the rollout is complete. 100 percent for long enough that you would have heard about problems.
- Delete the flag check and the losing branch, in one commit. Not the check in one and the code in another, because the intermediate state is confusing.
- Delete the flag definition and the tests for the removed path.
- Remove it from the flag service so it cannot be toggled back to a branch that no longer exists.
Step four gets skipped and it is the one that causes the worst incident. Somebody toggles an old flag during an unrelated investigation, the code path it referenced is gone, and behaviour changes in a way nobody can explain because the flag no longer appears anywhere in the codebase.
The position I hold
Flags are worth their cost when they are temporary and counted. They stop being worth it when they become permanent by default.
The practical rule I would give a team: a release flag older than one release cycle is a bug report about your release process. It means the feature is not shipping, or nobody owns it, or the rollout stalled and nobody noticed. The flag is not the problem, it is the symptom, and removing it forces the real conversation.
Everything else here is machinery to make that rule enforceable.