The feature flag you forgot to delete is still deciding what your users see
Every flag is born temporary and most of them die permanent. This is why stale flags, orphaned evaluations, and flags "cleaned up" by hardcoding them to a constant are real debt - and flagrot, a static gate that fails the build when it finds them.
Every feature flag is created with the same unspoken promise: this is temporary. You wrap a risky change in if flag("new-checkout"), ship both branches, roll it out to 1% and then 100%, watch the dashboards, and - once you are sure - you go back and delete the flag, the dead branch, and the config entry.
That last step is the one that almost never happens.
What happens instead is that the flag ships, the rollout succeeds, everyone moves on, and the flag stays. A year later the config still contains old-checkout-redesign, created in 2019, on: true, and the evaluation is still in the code branching on it. Nobody remembers whether it is safe to remove. So it stays another year. Multiply that by every flag your team has ever shipped, and you have a specific, expensive kind of technical debt: feature-flag debt.
This post is about the three shapes that debt actually takes, why each one is a bug and not just untidiness, and a small tool I wrote - flagrot - that fails the build the moment it finds one.
Why a leftover flag is a bug, not clutter
A stale flag is not harmless dead weight. It is active dead weight.
Every flag in your config is a live branch in production. The code still evaluates it on every request, which means both paths still ship - the one you kept and the one you abandoned. That losing branch is code nobody tests, nobody reviews with any care, and nobody can confidently delete, because deleting it means first proving the flag is truly gone everywhere. The flag has quietly turned a finished decision back into an open question.
It gets worse in the aggregate. A config with four hundred flags, most of them permanent, is a config nobody can reason about. Which flags are load-bearing? Which are inert? Which one, if you flip it by accident during an incident, changes behavior for real users? The signal-to-noise ratio collapses, and the flag system - which existed to make change safer - becomes a source of risk in its own right. The classic failure mode is an on-call engineer toggling a flag they believe is dead and taking down a path they did not know was still wired up.
And leftover flags rot in more than one direction at once. The config rots. The code that reads the config rots. And the "cleanup" people do under time pressure rots too.
The three shapes of flag debt
If you look at real codebases, feature-flag debt shows up in three recognizable forms.
1. The stale permanent flag. A flag sits in the config with a creation date well in the past - months, often years - and no removal date, no expiry, no owner. It was meant to live for a two-week rollout and it has been there since the last reorg. This is the core of the problem: a flag that outlived its purpose and was never cleaned up.
2. The orphaned evaluation. The opposite direction. The code calls client.boolVariation("legacy-banner", user, false), but legacy-banner is defined in no flag config anywhere in the repo. Either the flag was deleted from the management side and the evaluation was left behind - so it now silently always returns the default - or the key was mistyped and never worked at all. Both are dead references pretending to be live logic.
3. The dead flag hardcoded to a constant. This is the "cleanup" that made things worse. Someone decided the flag had won, and instead of removing the flag and the losing branch, they took the fast path: const NEW_CHECKOUT_ENABLED = true;. The flag call is gone, which looks like progress, but the branch it guards now never varies, and the losing branch is still sitting there as unreachable dead code. The flag was not removed; it was fossilized.
The thing these three share is that none of them fails a test. A stale flag config is valid config. An orphaned evaluation returns its default and the app keeps running. A hardcoded flag compiles and behaves - it just behaves the same way forever. There is nothing at runtime to catch. The debt is textual, sitting in plain sight in the config and the source, which is exactly what makes it a job for a static gate.
Why cross-referencing is the whole trick
Here is the part that makes flag debt hard to catch with the tools you already have.
Your flag provider's dashboard knows which flags exist, but it does not live in your repo and it does not fail your CI. Your linter reads your source, but it has no idea what a flag config is. Neither side, alone, can see the debt - because the debt lives in the gap between them. A stale flag is a config entry with no cleanup. An orphan is a code reference with no config entry. A dead flag is a config concept collapsed into a source constant.
So the tool has to hold both sides at once: parse the flag config to learn what flags exist and how old they are, scan the source to learn what the code actually evaluates, and then cross-reference the two. That cross-reference is the entire idea behind flagrot.
How flagrot works
flagrot is a single static Go binary - standard library only, no dependencies, no code execution, no network, nothing to configure. It runs in two passes.
Pass one reads the config. flagrot parses every JSON and YAML file that looks like a flag config - recognized by content, a top-level flags / features / toggles container, so it works with LaunchDarkly exports, Unleash definitions, OpenFeature flagd files, or a plain flags.yaml with no naming convention required. It handles both the map form (flags: { "key": {...} }) and the array form (features: [ { name: ... } ]). From each flag it reads a creation or last-modified date (epoch milliseconds, epoch seconds, or an ISO date) and checks for an expiry/removal field. It records the full set of defined flag keys, and it flags any flag that is older than the threshold and carries no removal date.
Pass two reads the source. flagrot scans your code - Python, JavaScript/TypeScript, Go, Java/Kotlin - for the evaluation calls of the major SDKs: LaunchDarkly's variation / boolVariation, OpenFeature's getBooleanValue, Unleash's isEnabled, Flagsmith's is_feature_enabled, Split's getTreatment, and their siblings. For each call with a string-literal key, it asks two questions: is this key defined in any config we parsed? And separately, is there a flag-named identifier here that has been pinned to a boolean literal?
Out of those two passes come three rules:
FR001 (blocker) - a stale permanent flag: dated older than
--max-age(default 90 days), with no removal/expiry date.FR002 (warning) - an orphaned evaluation: a flag key evaluated in source that no config defines.
FR003 (warning) - a dead flag: a flag-named identifier hardcoded to a boolean literal.
Every finding points at the exact file, line, and column, names the flag, says what makes it debt, and gives the fix:
● 1 stale permanent flag(s):
config/flags.json:4:5 Stale permanent flag "old-checkout-redesign" - created 2788 days ago with no removal/expiry date.
↳ Remove the flag and its dead branch, or add an expiry/removal date (e.g. "expiresAt") if it must stay.
[FR001 · blocker]
● 2 warning(s):
services/checkout.js:13:32 Orphaned flag evaluation "legacy-banner" (boolVariation) - defined in no flag config in this repo.
↳ Delete the dead evaluation, or add the flag to your flag config if the reference is intentional.
[FR002 · warning]
services/checkout.js:18:7 Dead flag "NEW_CHECKOUT_ENABLED" hardcoded to true - the branch never varies.
↳ Delete the flag variable and inline the winning branch; remove the code the flag used to guard.
[FR003 · warning]
1 blocker · 2 warnings
Staying quiet enough to leave on
A debt linter is only worth anything if you can run it in CI without a revolt. The failure mode for this kind of tool is obvious - a flag system has thousands of legitimate flags, and a naive scanner would flag all of them - so flagrot leans hard on being conservative:
Recently-created flags are fine. Anything inside the
--max-agewindow is exactly what a flag is supposed to be: temporary and current.Flags annotated for removal are fine. If a flag carries an
expiresAt,removeBy,sunset, orttlfield, the team is already tracking it. That is the whole hygiene goal, so flagrot does not count it as debt even when it is old.Dynamic keys are left alone. An evaluation whose key is a variable -
client.variation(flagKey, …)- is not something flagrot can resolve, so it never guesses. No literal, no finding.Orphans are suppressed when there is no config at all. If a repo has no flag config anywhere, its flags probably live entirely in a SaaS dashboard, and reporting every evaluation as an orphan would be noise. FR002 only fires when there is a config to cross-reference against.
Ordinary booleans are not dead flags.
done = trueis not debt; the identifier has to actually look like a flag before FR003 will fire.Comments don't count. A flag mentioned in a comment is stripped before scanning.
And for the genuine exceptions there are two escape hatches: a flagrot:ignore comment on the line, or a .flagrotignore file listing path substrings to skip.
Where it fits
flagrot is a static gate, so it goes where your other gates go - a pre-commit hook, or a CI step:
- run: go run github.com/jay-tank/flagrot@latest ./
It exits 1 on a stale permanent flag, which fails the build; orphans and dead flags are warnings by default and become failures under --strict, so you can adopt it gradually: start by blocking only on the worst debt, then tighten.
The honest limitation
flagrot parses config precisely, but on the source side it is a line/literal heuristic, not full data-flow analysis. It reads evaluation calls whose keys are string literals; it will miss a key assembled by concatenation, and it does not prove a branch is unreachable beyond the hardcoded-constant case. It answers one question well - which flags are debt: old and unowned, referenced but undefined, or hardcoded and dead? - and it answers it fast, across languages and providers, with a false-positive rate low enough to leave switched on. The deep cases are what flagrot:ignore is for.
The point is not to replace the discipline of deleting flags. It is to make the absence of that discipline visible, in the one place a team actually looks: a failing build.
flagrot is open source (MIT) and a single Go binary. Grab it at https://github.com/jay-tank/flagrot - go install github.com/jay-tank/flagrot@latest - point it at a repo, and see how many flags you forgot to delete.
