Every team that ships behind flags eventually has the same conversation. Someone opens the flag dashboard, sorts by age, and finds a release flag that was fully rolled out fourteen months ago. It is still evaluated in three services. Nobody wants to touch it, because the last person who deleted a flag config before removing the code watched a shipped feature silently revert for a cohort of users.
That failure is not a mystery. It is an ordering bug. The flag’s compiled-in default is usually the old behavior, so deleting the config entry while evaluations remain is a quiet rollback. The fix is not more caution. It is a cadence: record expiry intent when the flag is created, then run a monthly audit that ends every row in either a merged code-removal PR or a re-dated expiry with a written reason.
Why flags accumulate
Pete Hodgson’s widely cited article on feature toggles is blunt about the underlying dynamic: toggles introduce complexity, and teams should aim to constrain the number of toggles in their system. He also notes that release toggles are transitionary by nature, generally not sticking around much longer than a week or two, though product-centric toggles may need to remain longer. Ops toggles are mostly short-lived, with a small number of long-lived kill switches being common.
So the categories already have different natural lifespans. The problem is that most teams never write the lifespan down. A flag is created in a hurry, rolled out, and then lives in a kind of administrative fog. It is not on anyone’s roadmap, it is not in anyone’s on-call rotation, and it is not obviously broken. It just sits there, adding a branch to every code path that touches it.
Vendor documentation has started to name this. FeatureSignals’ lifecycle guide calls cleanup “often the forgotten step” and recommends flag expiration plus category-aware staleness thresholds to prevent stale flags. That is a useful confirmation of the problem, not a solution you can copy wholesale, because the enforcement still has to live in your process.
Expiry metadata: write it at creation, not at cleanup
The single highest-leverage change is to stop treating expiry as a cleanup activity. A practitioner guide on flag lifecycle management puts it directly: encode ownership and expiry intent in the flag’s metadata at creation time, not as an afterthought during cleanup sprints. The same guide argues that expiry intent does not have to be a hard date; a category such as experiment, release, ops, or permanent is enough for tooling to know which flags to nag about.
That distinction matters. A hard date that passes while the flag is still load-bearing trains the team to ignore expiry warnings. A category plus a target removal window records intent, and intent can be renegotiated in the audit with a written reason. A date that silently lapses teaches people that the system cries wolf.
The minimum metadata set
Every flag should carry these fields from the moment it is created:
- Owning team, not an individual. People change teams and leave companies; a flag owned by a departed engineer is an orphan the day the account is deprovisioned.
- Category: release, experiment, ops, or permanent. This drives the cleanup clock.
- Expiry intent: a target removal window (for example, “within two sprints of full rollout”) or an explicit permanent designation.
- Evaluation locations: the files or modules that read the flag. This is what makes the removal PR mechanical instead of archaeological.
- Removal PR link, once one exists. The audit’s job is to make sure every row has one.
The evaluation-locations field only works if flag names are static. Unleash’s guide to managing flags in code recommends centralizing flag name definitions in constants or enums as a single source of truth, and explicitly warns against dynamically constructed names: building a name at runtime prevents static analysis and makes automated cleanup much harder. If your codebase builds flag keys from strings, the audit cannot find all the call sites, and the whole cadence collapses into manual grep archaeology.
Category drives the clock
A single blanket TTL for every flag is the wrong abstraction. It will nag about kill switches that are supposed to live forever, and it will under-nag about release flags that should have been removed weeks ago. The published category-to-timeline guidance is a reasonable starting point:
- Release: days to weeks after full rollout.
- Experiment: after the experiment concludes and a winner is declared.
- Ops: rarely; ops flags are often long-lived.
- Permission: rarely; permission flags may be permanent.
Those timelines come from one vendor’s documentation, so treat them as a starting point rather than a universal rule. The important structural point is that the category, not a single date, determines which flags the audit chases. The same guide notes that a category is enough for tooling to know which flags to nag about, which is exactly the property you want: the audit can exclude permanent flags by design, so its signal is not diluted by flags that are supposed to live forever.
The ordering rule that prevents silent rollbacks
This is the part teams get wrong under deadline pressure. The lifecycle guide is explicit about the failure: delete the config first and any straggler evaluation immediately starts returning its compiled-in default, which is usually the old behavior, silently reverting a shipped feature for whichever cohort the rule used to target.
The correct order is not negotiable:
- Remove the flag evaluations from the code. Keep the new behavior unconditional.
- Deploy that change and confirm it is live everywhere.
- Confirm no service is still calling the flag key. Check logs, metrics, or the provider’s evaluation data.
- Only then delete or archive the configuration entry.
The same guide recommends marking a flag deprecated, notifying the owning team, giving a deadline (typically two sprints), then hard-deleting. That two-sprint figure is one guide’s recommendation, not a law of nature, but the sequence is the part that matters. Deprecation is a state that separates “this flag is going away” from “this flag is gone,” and it exists precisely so the code-removal step can happen first.
There is a second reason to respect the ordering. OpenFeature’s specification requires that flag evaluation calls return the default value rather than throwing on abnormal execution. That is good design for availability, but it means a missing flag does not announce itself. The evaluation returns the default, the request succeeds, and the only symptom is a feature that quietly stopped working for some users. The spec also defines a DISABLED resolution reason for values resolved because the flag was disabled in the management system, which is useful for observability, but you should not assume every provider emits it or that it maps to expiry specifically. The safe assumption is that a deleted flag looks like a normal evaluation returning a default.
The monthly audit
Per-flag reminders do not work at scale. Each notification looks ignorable in isolation, and there is no moment when someone sees the whole list. A monthly audit produces one reviewable artifact with a named owner per row. The cadence is a design choice, not an empirical finding, but the structure is what makes it stick.
The audit is a removal pipeline, not a status report. Every row must end in one of two states: a merged code-removal PR, or a re-dated expiry with a written reason. Relabeling a flag as “still needed” without a reason is not a valid outcome, because that is how the list gets closed without anything changing.
The checklist
Run these four queries against the registry each month:
- Past expiry intent. Flags whose target removal window has passed and whose category is not permanent.
- Fully rolled out beyond a window. Flags at 100% rollout for longer than the category’s expected window. These are candidates for code removal even if nobody marked them stale.
- No recent evaluations. Flags with no evaluation traffic in the last period. A flag nobody evaluates is either dead code or a missing call site; both need a decision.
- Orphaned owners. Flags whose owning team no longer exists or has been renamed. Reassign before the next audit, not during an incident.
For each row, assign an owner and open a removal PR or record a re-dated expiry with a reason. The audit meeting is short because the output is a list of PRs, not a debate about whether a flag is still needed.
A small registry schema
The registry can be a YAML file in the repo, a table in the flag provider, or a spreadsheet that gets exported. What matters is that it is the source of truth for flag names and that it is machine-readable enough to run the audit queries. Here is a minimal schema, shown as a hypothetical example rather than a real deployment:
flags:
- key: checkout.new-summary-panel
owner_team: payments
category: release
expiry_intent: "within 2 sprints of full rollout"
created: 2026-03-14
evaluation_locations:
- services/checkout/summary.ts
- services/checkout/controller.ts
removal_pr: null
- key: search.ranking-v2
owner_team: search
category: experiment
expiry_intent: "after experiment concludes"
created: 2026-05-02
evaluation_locations:
- services/search/ranking.ts
removal_pr: null
- key: platform.disable-recommendations
owner_team: platform
category: permanent
expiry_intent: null
created: 2025-11-20
evaluation_locations:
- services/web/home.ts
removal_pr: null
The permanent category is the escape hatch that keeps the audit honest. Kill switches and permission gates are supposed to live forever, and the audit should exclude them by design. If they show up as noise every month, people stop reading the list.
An audit script sketch
The script does not need to be clever. It reads the registry, compares each flag against its category’s window, and emits the three lists. This is a sketch, not a tested tool:
import yaml, datetime
WINDOWS = {
"release": datetime.timedelta(days=30),
"experiment": datetime.timedelta(days=60),
"ops": None,
"permanent": None,
}
with open("flags.yaml") as f:
flags = yaml.safe_load(f)["flags"]
today = datetime.date.today()
past_expiry = []
for flag in flags:
window = WINDOWS.get(flag["category"])
if window is None:
continue
created = datetime.date.fromisoformat(str(flag["created"]))
if today - created > window:
past_expiry.append(flag)
print("Past expiry intent:")
for flag in past_expiry:
print(f" {flag['key']} (owner: {flag['owner_team']})")
The real version would also query the flag provider for rollout percentage and evaluation counts, and would check that every evaluation_locations entry still contains a reference to the key. That last check is what turns the audit from a status report into a removal pipeline: if the code no longer references the flag, the config entry is safe to delete, and the script can say so.
What the audit does not solve
The audit will not remove a flag for you, and it will not decide whether a long-lived kill switch is still worth its complexity. Permanent flags need an explicit category so they stop appearing as noise, but that category is a declaration, not a proof. A kill switch that has not been exercised in two years may be dead weight, and the audit should surface it for a human decision rather than silently excluding it forever.
It also will not fix dynamically constructed flag names. If your code builds keys at runtime, the static search cannot find all the call sites, and the removal PR becomes guesswork. The fix there is at review time: reject dynamically constructed names, centralize the constants, and make the registry the source of truth. The audit is only as good as the search it depends on.
The artifact
Copy this into your team’s docs and adapt the windows to your release cadence. The registry schema and the audit checklist are the two pieces that make the cadence real.
# Flag Registry Entry
key: <namespace.service.feature>
owner_team: <team name>
category: release | experiment | ops | permanent
expiry_intent: <target removal window or null for permanent>
created: <YYYY-MM-DD>
evaluation_locations:
- <path/to/file>
removal_pr: <link or null>
# Monthly Audit Checklist
[ ] List flags past expiry intent (exclude permanent)
[ ] List flags at 100% rollout beyond category window
[ ] List flags with no recent evaluations
[ ] List flags with orphaned owners
[ ] For each row: assign owner and open removal PR, or re-date expiry with written reason
[ ] Confirm removal order: code removed, deployed, no callers, then config deleted
The point is not the YAML. The point is that every flag has an owner, a category, and an expiry intent before it ships, and that once a month someone looks at the whole list and turns stale entries into merged PRs. Flags do not expire on their own. They multiply until someone runs the audit.