How to remove a stale feature flag before it rots
Removing a feature flag is two pull requests, not one: collapse the dead code path first, then run dif conclude to archive the flag file and write down why it ended. Skip either step and you get a flag like new-checkout-v2, stuck at 100% for three years with a dead branch behind it.
Most flag guidance covers adding a flag: naming it, setting the split, gating the render. Almost none of it covers taking one out. So flags ramp to 100%, the team moves on to the next release, and the flag becomes permanent infrastructure nobody chose to keep.
This guide covers how to tell a flag is actually done, how to find every place it’s called before you touch anything, and the sequence that removes it from both the code and the repo’s history of why.
Key Takeaways
- Removing a feature flag is two pull requests: collapse the dead code path, then conclude the flag. Doing only one leaves a gap the other doesn’t cover.
dif conclude checkout-one-click-buyarchives the flag file and writes a one-line decision back to its surface, permanently.dif validatewarns (W001) on adif()call site that names no active flag. It does not report the reverse, an active file nobody calls, so grep for that.- A flag with no removal condition at ship time is how a flag like
new-checkout-v2stays at 100% for three years with a dead branch behind it.- Concluding archives the decision; it does not silently delete the record of why the flag existed.
When a feature flag is safe to remove
A flag is safe to remove when it has held its final state, on or off, for a stated window with no regression, and someone has confirmed the losing code path is genuinely dead. “Ramped to 100% last sprint” is not the same claim as “held at 100% for two weeks with nothing broken.”
Feature flag best practices covers stating that removal condition in the flag’s file before it ships: a ramp flag gets a target, an ops flag (a kill switch) says explicitly that it’s permanent. If the file never said what “done” looked like, removal becomes a judgment call made under deadline pressure, which is exactly how it gets skipped. Martin Fowler’s writeup on feature toggles makes the same point about toggle lifecycle: the removal step is the one most teams skip, not the addition.
One exception: a kill switch for a payment-provider outage or similar operational flag is not debt. It’s meant to sit there unused until the day it isn’t. Don’t remove those on a schedule, and say so in the file so a future reviewer doesn’t flag it as stale by mistake.
Find every call site before you remove a feature flag
Before deleting anything, find where the flag is actually called. Every call site passes the flag’s id to dif() as a string literal, which makes the search exact:
grep -rn 'dif("checkout-one-click-buy"' src/
src/components/Checkout.tsx:42: const checkout = dif("checkout-one-click-buy", {
One call site, one file. If the grep returns nothing, the code was already collapsed and the flag file is the only thing left to conclude. If it returns several, each one needs to come out in the same pass, or the flag isn’t actually dead yet.
Collapse the dead code path first
With the call site in hand, delete the losing branch and keep the winner. This is a normal code change, reviewed like any other:
- const checkout = dif("checkout-one-click-buy", {
- off: () => <StandardCheckout />,
- on: () => <OneClickBuy />,
- });
- return checkout();
+ return <OneClickBuy />;
This PR ships before the flag file goes anywhere else. That leaves the flag file briefly without a call site, still listed in dif/experiments/active/ and still compiled into the client until the next step.
Conclude the flag, don’t just delete the file
Deleting dif/experiments/active/checkout-one-click-buy.md throws away the reason the flag existed. dif conclude archives it instead, and writes the decision back to the flag’s surface as a one-line learning:
$ dif conclude checkout-one-click-buy --decision "Shipped on. Held at 100% for 2 weeks, no regression, no support ticket increase."
→ moved dif/experiments/concluded/2026-09-checkout-one-click-buy.md
→ drafted ## Decision block
→ appended to dif/surfaces/checkout.md
"Shipped on. Held at 100% for 2 weeks, no regression, no support ticket increase."
The file moves from active/ to concluded/, dated. The learning lands in dif/surfaces/checkout.md, so the next dif new on that surface reads it into the draft automatically. Nobody re-runs the same idea two years later because nobody remembers trying it.
Run dif build after concluding, so the compiled client.ts and context.json drop the flag. A coding agent reading context.json on its next session sees one fewer flag and the new learning, without anyone telling it by hand.
Validate that the removal didn’t leave orphans
dif validate scans every .ts, .tsx, .js, and .jsx file in the repo, not just the diff in front of a reviewer, and catches a call site the first pull request missed:
$ dif validate
warning W001 src/components/CartSummary.tsx:18:1: orphan ref: `checkout-one-click-buy` is not an active experiment
help: Either create the experiment or remove the dif() call.
⚠ 1 warning(s)
That’s the signal that the flag was concluded while code still calls it. At runtime that call returns its first branch and records nothing. It is a warning, so dif validate still exits 0. Make dif validate a required status check in branch protection, and have the job fail on any W001 in dif validate --json if you want the orphan to block the merge. The opposite mistake, a code PR merged with the conclude step never run, produces no warning: the file stays in dif/experiments/active/, so check that directory when the code PR lands.
FAQ
How do I remove a feature flag? Collapse the dead code path in one pull request, then run dif conclude in a second to archive the flag file and record the decision. Doing the code change alone leaves an orphaned file; concluding alone leaves dead code behind it.
When is it safe to remove a feature flag? When it has held its final state (fully on or fully off) for a stated window with no regression, and the losing code path has no remaining traffic. An ops flag meant as a permanent kill switch is the exception: it stays.
What happens if I delete a feature flag file without removing the code? The remaining dif("...") call site has nothing to resolve. dif validate reports it as a W001 orphan warning before it ships; if it reaches production anyway, dif’s fail-safe default resolves to the first variant and records nothing, so the worst case is silently showing the old path rather than crashing.
Should I delete or archive a feature flag? Archive it. dif conclude moves the file to concluded/ and writes the decision and a one-line learning back to the surface. A quiet delete throws away the record of why the flag existed, which is the same problem that let new-checkout-v2 become unexplainable in the first place.
How do you find unused feature flags in a codebase? Run ls dif/experiments/active/ for the full list of what’s live, then grep src/ for each id. A file with no match is a flag nobody is calling anymore. dif validate covers the other direction: a call site whose flag is gone.
Getting started
Removing a feature flag is not a delete button. It’s two pull requests: collapse the code path once the flag has held its final state, then dif conclude to archive the file and write down why. dif validate warns if a call site outlives its flag, so the decision lands in the surface’s history instead of disappearing with the file.
None of this requires a dashboard or a second system to keep in sync with the code:
npm install -g @dif.sh/cli
dif init
See the CLI reference for the full conclude and validate flags, the feature flags landing page for the full model, or feature flags in git for how the rest of the lifecycle stays in pull requests instead of a dashboard.