← Blog

Detect unused feature flags before they rot

Detect unused feature flags by checking three signals together: no call site left in the code, a weight stuck at 100% past its expected life, and exposure events that have gone quiet. No single signal proves a flag is dead. Together, they separate a flag nobody uses from one that’s just quiet this week.

Every codebase collects a flag like new-checkout-v2. It sits at 100% for three years with a dead branch behind it, because deleting it means someone has to be sure nothing still depends on it, and nobody wants to be the one who’s wrong about that in production. That’s the flag debt trunk-based development warns about: the flag stops being a rollout decision and quietly becomes architecture.

What follows is each signal in detail, a script that checks it today, and a way to put that script on a schedule so nobody has to remember to run it.

Key Takeaways

  • An unused feature flag shows three signals together: no call site in the app source, a weight stuck at 100% past its expected life, and exposure events gone quiet.
  • dif validate scans your source for orphan dif("...") calls in both directions: a call with no flag file, and a flag file with no call site.
  • A flag’s created date plus a weight of 100 is enough to compute its age at full rollout. Past 90 days, it’s a candidate for a conversation, not an automatic delete.
  • Exposure counts dropping to zero on a flag still marked active is the clearest sign the code path isn’t being hit, whether by traffic, routing, or a flag nested behind another flag.
  • A check that runs on a schedule catches a stale flag in weeks. A check that lives in someone’s memory catches it in years, if at all.

Three signals a feature flag has gone unused

A flag can look suspicious on one signal and still be doing real work. An ops flag held at 100% on purpose is a kill switch, not dead code. A flag with a call site that nobody has hit in 30 days might just be gated behind a holiday promotion. Checking one signal alone produces false positives that train a team to ignore the warning.

Checking three together is what makes the signal trustworthy:

  1. No call site. The flag file exists, but nothing in the app references it anymore.
  2. Stuck at 100%. One variant has carried the full weight for longer than the flag’s type would explain.
  3. Quiet exposures. The call site exists, but nobody’s hitting it, which usually means dead routing, not a dead flag.

A flag tripping two of the three is worth a look. A flag tripping all three is almost certainly safe to delete.

Signal one: no call site in the code (orphaned flags)

dif validate scans your app source for dif("...") call sites and checks the match in both directions. It warns when a call site references a flag file that doesn’t exist, and it warns when a flag file has no call site anywhere in the code, which is the orphan that matters for cleanup:

$ dif validate
Checking dif/experiments/active/old-hero-test.md ... ok

Scanning src/ for dif() call sites ... 8 calls, 1 orphan
  orphan: dif/experiments/active/old-hero-test.md
    referenced by: none

1 flag has no call site. See https://dif.sh/docs/troubleshooting/ for diagnostic codes.

The flag file passed every other check (valid owner, valid surface, weights sum to 100), so it’s easy to miss in a status board. Only the source scan catches it. If you’re not on dif, the same check is a one-line grep for the flag’s exact id across the app source, run against every flag id in your list:

# flags with zero references in src/
for id in $(grep -rhoE '^id: .*' dif/*/active/*.md | cut -d' ' -f2); do
  count=$(grep -rl "\"$id\"" src/ | wc -l)
  [ "$count" -eq 0 ] && echo "orphan: $id"
done

A missing call site is the strongest signal on this list. There’s no code path left to flip, so the flag file is pure overhead: something dif validate still checks, something a reviewer still has to understand, with nothing behind it.

Signal two: stuck at 100% past its expected life

A release flag is supposed to move from 0 to 100 once and then get deleted. The weight tells you where it is; the created date tells you how long it’s been there:

---
id: new-checkout-v2
status: active
owner: payments@acme.com
surface: checkout
created: 2023-11-02
variants:
  - id: "off"
    weight: 0
  - id: "on"
    weight: 100
---

That flag has been at full rollout for about three years. For a release flag, that’s not a rollout in progress, it’s a decision that already happened and was never recorded. Checking age against type takes one pass over the active directory:

# release flags at 100% for 90+ days
today=$(date +%s)
for f in dif/*/active/*.md; do
  created=$(grep '^created:' "$f" | cut -d' ' -f2)
  weight=$(grep -A1 'id: "on"' "$f" | grep weight | grep -o '[0-9]*')
  age_days=$(( (today - $(date -d "$created" +%s)) / 86400 ))
  [ "$weight" = "100" ] && [ "$age_days" -gt 90 ] && echo "$f: $age_days days at 100%"
done

This signal alone isn’t proof. An ops flag or a permission flag, in Martin Fowler’s feature toggle taxonomy, can sit at 100% indefinitely on purpose, which is exactly what the naming convention’s type suffix is for: a -kill or -beta suffix tells you to leave it alone. A bare release flag with no type suffix, stuck at 100% for 90 days, is the pattern worth a second look.

Signal three: exposure events have gone quiet

A flag with a call site and a non-trivial weight can still be unused in practice, because dif.track()-style exposure fires once per flag per user at the render site, and a dead code path produces no exposures even though the flag file looks healthy. If traffic got rerouted, a feature got hidden behind a newer flag, or a redirect sends users around the page entirely, the call site is still there and nobody’s reaching it.

$ dif qa --surface checkout --summary
Checking dif/experiments/active/checkout-upsell-banner.md
  weight: on=20, off=80
  exposures (last 30d): 0
  last exposure recorded: 2026-06-14

Zero exposures on a flag marked active, with a call site that resolves in dif validate, means something between the flag and the user isn’t connecting. That’s worth investigating even before you decide to delete anything, because it usually means a routing bug, not just a stale flag. If you’re on a different tool, the same check is a query against whatever sink receives your exposure events (Segment, Amplitude, a custom webhook): count by flag id over the trailing 30 days, and flag anything at zero.

Combine the three signals into one check

Running the three checks separately means remembering to run three things. Combine them into one script, and a candidate list looks like this:

$ ./check-unused-flags.sh
checkout-upsell-banner    0/3 call sites found, 0 exposures (30d)   -> DELETE
new-checkout-v2           100% for 1,097 days, no type suffix        -> REVIEW
old-hero-test             orphan, no call site                       -> DELETE
pricing-annual-upsell-test  active, 12% weight, 340 exposures (30d)  -> KEEP

new-checkout-v2 trips the age signal but still has a call site and exposures, so it goes to REVIEW, not DELETE. old-hero-test and checkout-upsell-banner trip the strongest signals, so they’re safe to take to a PR that deletes the flag file and the code behind it.

For a growth PM, the same list doubles as a status check on what’s actually running. pricing-annual-upsell-test shows real weight and real exposures, so it’s still an active experiment worth watching in the results panel, not a flag someone forgot to clean up. The REVIEW and DELETE rows are the ones to bring to the engineer who owns checkout, before the next planning cycle assumes that surface is still contested.

Run the check on a schedule, not in memory

A check that only runs when someone remembers to run it catches nothing. The fix is the same one that works for flag validation in CI: wire it into a scheduled GitHub Actions workflow, not a trigger a human has to think of.

name: dif unused-flag check

on:
  schedule:
    - cron: '0 9 * * 1'  # every Monday, 9am

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm install -g @dif.sh/cli
      - run: dif validate
      - run: ./check-unused-flags.sh >> "$GITHUB_STEP_SUMMARY"

This runs alongside the PR-time dif validate check, which catches orphans and collisions on every pull request, but age and exposure drift over weeks, not in the span of one PR, so they need their own cadence. dif build compiles the same active-flag state into context.json, so a coding agent reviewing the repo at session start has the created date and weight for every flag it touches, and can flag an old one the same way this script does, before it adds a line of code behind something already dead.

FAQ

How do you find unused feature flags in your codebase? Check three things: whether the flag still has a call site in the app source, whether its weight has been stuck at 100% past its expected life, and whether exposure events for it have gone to zero. A flag tripping two or more is a cleanup candidate.

What is a zombie feature flag? A flag that’s still technically active (a valid file, a call site that resolves) but isn’t doing anything: no traffic reaches it, or it’s been fully rolled out for so long that the condition it gates might as well not exist.

Does dif validate detect unused feature flags automatically? It detects one signal: orphan dif("...") call sites, in both directions. It doesn’t check age or exposure volume on its own, which is why the age and exposure checks in this post run as a separate, scheduled job.

Can a feature flag be unused even if it’s still called in the code? Yes. A call site proves the code path exists, not that anything is reaching it. A redirect, a newer flag nested on top, or routing that changed since the flag shipped can all leave a call site with zero real traffic.

Is it safe to delete a feature flag stuck at 100%? Only after checking the type. An ops or permission flag can sit at 100% on purpose, encoding a kill switch or an access rule. A bare release flag with no type suffix, stuck at 100% for 90-plus days with a call site nobody’s hit, is the one to take to a deletion PR.

Getting started

Detecting unused feature flags isn’t one check, it’s three run together: no call site, a weight stuck at 100% past its type’s expected life, and exposure events gone quiet. One signal alone is a false positive waiting to embarrass someone; two or three agreeing is a flag you can actually delete.

dif validate gives you the call-site check for free, on every pull request. The age and exposure checks in this post are a script you run on a schedule, so a flag like new-checkout-v2 surfaces in weeks instead of sitting there for three years. Install the CLI and scaffold a project to get the validator running:

npm install -g @dif.sh/cli
dif init

The CLI reference documents every check dif validate runs, and the .md format reference covers the frontmatter fields (created, weight, status) this post’s scripts read.