Schedule and remove stale feature flags before they become production debt
For developers who already use feature flags and want a reliable way to retire them instead of carrying dead branches forever. This guide shows where flag removal fits in delivery flow, how to wire expiry and ownership into CI, and what concrete checks, scripts, and failure modes to use so old flags get deleted on schedule.
TL;DR — A feature flag is not done when rollout reaches 100%; it is done when the flag, fallback path, config, metrics hooks, and tests are removed. The single most effective fix is to treat every temporary flag like a ticket with an owner and expiry date, then fail CI once that date passes unless the flag is deleted or explicitly renewed. Reading time: ~7 min
What it is and where it sits
This is not about how to evaluate flags at runtime. It is about the part teams usually skip: scheduling the removal of temporary flags so they do not outlive the feature they guarded.
In a typical system, the flag touches more than application code:
- application request path chooses old vs new behavior
- config service or env vars provide the flag value
- analytics/metrics label requests by variant
- tests cover both branches
- dashboards and alerts may split by flag state
- migrations may depend on the old path still existing
When the rollout is complete, the flag becomes a liability. It keeps dead code alive, doubles test surface, and blocks cleanup of schema, APIs, and operational assumptions. The removal process replaces "we'll clean it up later" with a scheduled lifecycle:
- create flag with metadata
- roll out
- hit target state
- delete old path before or at expiry
- remove metadata, tests, and config
A practical architecture looks like this:
Client request
|
v
API handler ------------------------------+
| |
| reads flag value | CI scans repo for expired flags
v | and opens/fails cleanup work
Flag source (env/config/SDK) |
| v
+--> choose old code path / new path Repo metadata (owner, expiry, type)
|
v
DB / downstream services
Where it lives in the request flow: usually at the application boundary or service layer, before calling the old or new implementation. Where removal lives: in source control and CI. That is the key point. Runtime evaluation decides behavior; scheduled removal decides whether the branch is allowed to keep existing.
How it actually works
Use one realistic rule: every temporary release flag must have owner, created, expires, and ticket metadata in the repo, and CI fails after expires if the flag still exists.
End-to-end example
Assume a Node service has a temporary flag checkout_v2 guarding a new payment flow.
Step 1: add the flag in code and metadata.
{
"checkout_v2": {
"type": "release",
"owner": "payments-team",
"ticket": "PAY-1842",
"created": "2026-09-01",
"expires": "2026-10-15",
"remove_when": "100% rollout stable for 7 days"
}
}
if (flags.isEnabled("checkout_v2", user)) {
return runCheckoutV2(req);
}
return runCheckoutV1(req);
Step 2: roll out to 5%, 25%, 100%. During rollout, both paths are legitimate. Tests may still cover both branches.
Step 3: once checkout_v2 is at 100% and stable, cleanup should happen immediately, not "next sprint". The change is:
return runCheckoutV2(req);
Then delete:
runCheckoutV1- the metadata entry in
flags.json - any dashboard split by
checkout_v2 - tests asserting V1 behavior behind the flag
- any kill-switch docs for this flag
Step 4: if nobody does it, CI catches it after 2026-10-15.
Example CI script behavior:
$ python3 scripts/check_flag_expiry.py flags.json
ERROR expired temporary flags found:
- checkout_v2 owner=payments-team expires=2026-10-15 ticket=PAY-1842 age=16d
Delete the flag and old code path, or extend expiry with a commit that updates flags.json.
exit status 1
That exit code is the mechanism that turns cleanup from a suggestion into a delivery constraint.
Why repo metadata beats only using a flag vendor UI
If metadata lives only in a remote dashboard, your code review cannot see expiry, ownership, or intent next to the branch it controls. Repo-local metadata lets CI scan without network calls, works in forks, and survives vendor migration. If you also use a hosted flag system, keep runtime targeting there and keep lifecycle metadata in git.
What the checker should validate
At minimum:
typeis one ofrelease,experiment,ops,permission- only
releaseandexperimentrequire expiry by default expiresparses as ISO date- expired temporary flags fail the build
- flags referenced in code but absent from metadata fail the build
- metadata entries not referenced in code warn or fail after a grace period
That last check matters. Otherwise you delete code but leave stale config forever.
When to use it (and when not to)
You want scheduled removal for temporary flags, not every toggle in existence.
| Scenario | Recommendation |
|---|---|
| Release flag guarding a new code path during rollout | Use expiry + CI enforcement. This is the primary case. |
| A/B experiment flag with a decision date | Use expiry + owner + result ticket. Delete losing branch quickly. |
| Operational kill switch for a flaky dependency | Usually do not auto-expire. Keep it documented, reviewed quarterly, and tested. |
| Permission flag representing a long-lived entitlement | Do not treat as temporary cleanup work. Model it as authorization/config, not a release flag. |
| One-service internal change deployable in minutes with easy rollback | You probably do not need a flag at all; prefer a small batch deploy and revert if needed. |
| Database migration requiring old and new readers temporarily | Use a time-bounded flag, but tie removal to migration phase completion, not just calendar date. |
You probably do not need this if your team rarely uses temporary flags and already removes them in the same PR that completes rollout. But once you have more than a handful of active flags, manual memory stops working.
Trade-offs
Every benefit costs something.
- Faster codebase cleanup
- Cost: more process at flag creation time. Developers must add metadata and think about expiry before merging.
- Lower test matrix over time
- Cost: CI checks and repo scanners need maintenance, especially if your codebase spans multiple languages.
- Clear ownership
- Cost: stale ownership data becomes its own problem after team changes or reorganizations.
- Less production ambiguity
- Cost: hard CI failures can block urgent releases if expiry dates are unrealistic.
- Reduced vendor lock-in when metadata is in git
- Cost: duplication if runtime targeting still lives in a hosted flag platform.
- Safer migrations when cleanup is explicit
- Cost: more coordination with DB and SRE work; the flag cannot be removed until observability and rollback plans are updated.
Latency and money: the scheduling/removal side adds almost no request latency if checks run in CI only. If you build runtime checks that fetch metadata remotely on every request, you are solving the wrong problem and paying for it in latency and failure modes.
Operational burden: someone must own the policy. In practice that means one repo script, one CI job, and one convention for flag types. Keep it boring.
In practice
Example 1: repo metadata plus a CI checker
{
"search_index_v2": {
"type": "release",
"owner": "search-team",
"ticket": "SRCH-902",
"created": "2026-09-10",
"expires": "2026-10-01",
"remove_when": "reindex complete and 100% traffic on v2"
},
"payments_provider_fail_open": {
"type": "ops",
"owner": "payments-team",
"ticket": "OPS-77",
"created": "2026-08-21",
"review_every_days": 90,
"note": "kill switch for provider outage"
}
}
This file is the contract CI reads. The gotcha: do not force expiry on long-lived ops or permission toggles, or teams will game the system by mislabeling flags.
#!/usr/bin/env python3
import json, sys
from datetime import date
with open("flags.json") as f:
flags = json.load(f)
today = date.today()
errors = []
for name, meta in flags.items():
ftype = meta.get("type")
if ftype in ("release", "experiment"):
if "expires" not in meta:
errors.append(f"{name}: missing expires")
continue
y, m, d = map(int, meta["expires"].split("-"))
exp = date(y, m, d)
if exp < today:
owner = meta.get("owner", "unknown")
ticket = meta.get("ticket", "none")
errors.append(f"{name} owner={owner} expires={exp} ticket={ticket}")
if errors:
print("ERROR expired temporary flags found:")
for e in errors:
print(f"- {e}")
sys.exit(1)
print("OK no expired temporary flags")
This exits 1 on expired release/experiment flags so any CI system can fail the job. The gotcha: this only validates metadata dates; pair it with a grep/AST check so code cannot reference undeclared flags.
Example 2: GitHub Actions job to block merges after expiry
name: flag-expiry
on:
pull_request:
push:
branches: [main]
jobs:
check-flags:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Check flag expiry
run: python3 scripts/check_flag_expiry.py
This runs on PRs and on main, so expired flags block both merges and direct pushes. The gotcha: if you only run on a nightly schedule, stale flags linger all day and developers stop trusting the signal.
Typical failure output in CI looks like:
Run python3 scripts/check_flag_expiry.py
ERROR expired temporary flags found:
- search_index_v2 owner=search-team expires=2026-10-01 ticket=SRCH-902
Error: Process completed with exit code 1.
Example 3: code search to find stale references before deletion
$ rg -n 'search_index_v2|checkout_v2' .
./flags.json:2: "search_index_v2": {
./src/search/handler.ts:18: if (flags.isEnabled("search_index_v2", req.user)) {
./src/search/handler.test.ts:44: it("uses v2 when search_index_v2 is enabled", async () => {
./docs/runbooks/search-rollout.md:12:- Toggle search_index_v2 to 100%
Use this before the cleanup PR to remove code, tests, and docs in one pass. The gotcha: plain text search misses dynamically constructed flag names; if your code does that, stop doing that for temporary flags or your cleanup automation will be weak.
⚠️ If the old code path is tied to a database migration, do not delete it just because the expiry date passed. First verify migration state in production, backups, and rollback posture. A premature cleanup can turn a safe deploy into data loss or a forced outage.
For migration-coupled flags, add an explicit checklist item in the PR description:
- [ ] write path switched to new schema
- [ ] backfill complete
- [ ] read path on new schema for 7 days
- [ ] rollback plan updated
- [ ] old columns/tables scheduled for drop in separate migration
- [ ] flag and old code path removed
That keeps the date-based policy from overriding operational reality.
Further reading
- Martin Fowler, "Feature Toggles"
- LaunchDarkly guide, "Technical debt from feature flags"
- Thoughtworks Technology Radar, notes on feature toggles
- Google SRE Book, chapters on change management and gradual rollouts
- OpenFeature specification and concepts documentation
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI