Module 2: Feature Flags

Flag debt: when (and how) to remove them

Description

Lesson 6 split Mercado's flags into two groups: those with a natural retirement date (release, experiment) and those without (operational, permanent). This lesson closes the module's topical arc with what happens when a flag from the first group doesn't get retired on time. An if (isEnabled('recommendations', ...)) that's still in the code months after the rollout reached 100% isn't harmless — it's flag debt: code that keeps existing, keeps getting read, keeps having to be understood, while no longer serving any purpose.

Connection to the module. This is the last topic lesson before the project, and it brings together everything built so far: lesson 3's registry, lesson 4's rolloutPercent, and lesson 6's type classification are, together, exactly the data needed to detect the debt with an executed model, not a gut feeling.

An analogy: the loose wires behind the electrical closet

Go back to lesson 3's breaker panel. Over the years, a house accumulates remodels: a room that became two, a temporary setup for a party that was never disconnected, a washing machine circuit that got moved three years ago. If nobody removes what's no longer used, the panel ends up full of switches labeled by hand in faded handwriting, some with no label at all, and nobody — not the original electrician, much less a new one — can say for certain which are safe to remove and which still feed something real.

That's exactly flag debt: it's not that an old flag actively breaks anything — just like a loose wire behind the closet doesn't necessarily cause a short circuit — it's that its mere presence forces everyone who later needs to understand or modify the system to decide, with no clear information, whether it's safe to touch. The more old flags pile up, the slower and riskier any future change becomes, even one that has nothing to do with those flags.

Worked example: flagDebtReport() on Mercado's registry

Let's build a model that reviews four real Mercado flags — including their type and how many days they've been in their current state — and decides which are debt candidates:

function flagDebtReport(flags) {
  return flags.map((f) => {
    let isDebt = false;
    let reason = 'actively in use, no signs of debt';
    if (f.type === 'release' && f.rolloutPercent === 100 && f.daysSinceCreated > 30) {
      isDebt = true;
      reason = 'release at 100% for ' + f.daysSinceCreated + ' days -- the rollout is over, the flag should be removed from the code';
    } else if (f.type === 'experiment' && f.experimentClosed) {
      isDebt = true;
      reason = 'the experiment already closed (metrics guide) but the flag is still in the code';
    } else if (f.type === 'operational' || f.type === 'permanent') {
      reason = f.type + ' -- expected to live indefinitely, not debt';
    }
    return { ...f, isDebt, reason };
  });
}

const flagsWithAge = [
  { name: 'newCheckoutLayout', type: 'release', rolloutPercent: 100, daysSinceCreated: 96 },
  { name: 'recommendations', type: 'release', rolloutPercent: 10, daysSinceCreated: 4 },
  { name: 'checkoutVariantB', type: 'experiment', rolloutPercent: 50, daysSinceCreated: 61, experimentClosed: true },
  { name: 'disableSellerPayouts', type: 'operational', rolloutPercent: 100, daysSinceCreated: 210 },
];

console.log('=== flagDebtReport on Mercado\'s flags ===\n');
const report = flagDebtReport(flagsWithAge);
report.forEach((f) => {
  console.log(f.name.padEnd(22) + 'isDebt=' + f.isDebt);
  console.log('  -> ' + f.reason + '\n');
});

const debtCount = report.filter((f) => f.isDebt).length;
console.log('Total: ' + debtCount + ' of ' + report.length + ' flags are debt candidates.');

What to expect. Running the file with Node, the output is exactly this:

=== flagDebtReport on Mercado's flags ===

newCheckoutLayout     isDebt=true
  -> release at 100% for 96 days -- the rollout is over, the flag should be removed from the code

recommendations       isDebt=false
  -> actively in use, no signs of debt

checkoutVariantB      isDebt=true
  -> the experiment already closed (metrics guide) but the flag is still in the code

disableSellerPayouts  isDebt=false
  -> operational -- expected to live indefinitely, not debt

Total: 2 of 4 flags are debt candidates.

Notice the four verdicts, one by one. newCheckoutLayout is clear debt: it's a release flag, it reached rolloutPercent: 100 96 days ago, and nobody removed it — the rollout ended more than three months ago, and the code still carries an if that always evaluates to the same thing. recommendations, by contrast, isn't debt: it's only 4 days into a rollout still in progress at 10% — it's doing exactly the job it was created for. checkoutVariantB is debt for a different reason: it doesn't matter how many days it's been — 61 in this case — what marks it is that experimentClosed: true — the experiment already ended, the metrics guide already has its result, and the flag is still in the code with nobody having cleaned it up. disableSellerPayouts isn't debt, and the reason is exactly lesson 6's: it's operational, expected to live indefinitely, so it doesn't even get evaluated against a day threshold.

Why the criterion depends on type, not just age

Notice something important in the model: flagDebtReport() never asks "how many days has this flag existed?" in isolation — it always asks it together with the type. A 96-day-old release flag is suspicious because release flags are designed to live weeks, not months. A 210-day-old operational flag — older than any of the other three — raises no alert at all, because that's exactly its expected lifespan. If the model only looked at age, without type, it would flag disableSellerPayouts as the worst debt case of the four — the oldest — when in reality it's the only one designed, on purpose, to last that long. That's the exact reason lesson 6 had to come first: without the type classification, any attempt to measure flag debt ends up penalizing the flags that are working exactly as they should.

It's also worth noting the 30 that appears as the threshold for release flags — it's a teaching number for this example, not a universal rule. Each team, and each organization, defines its own reasonable threshold based on how fast it expects a rollout to reach 100% and be confirmed stable; what doesn't change between organizations is the underlying idea: a temporary flag needs some age threshold to be compared against, and a permanent one doesn't.

Common mistakes

Never setting a threshold, and trusting "someone will remember to remove it." What happens: the team creates release flags with no process that periodically reviews which ones reached 100% a while ago — relying on the individual memory of whoever created it, who's usually working on something else by the time the rollout ends. Why it happens: removing a flag doesn't have the urgency of creating one — nothing visibly breaks if the old flag stays — so it competes against, and loses to, any task with a real deadline. How to spot it: if nobody can say how many release flags in Mercado's registry have been at 100% for more than a month without being reviewed, no process exists — only good intentions. How to fix it: a report like flagDebtReport(), run regularly (not once), turns "someone will remember" into a concrete, verifiable list of candidates, exactly like this lesson's report.

Deleting a debt flag without verifying it's really no longer used anywhere. What happens: someone sees newCheckoutLayout marked as debt and deletes the flag from the registry right away, without checking whether production code still calls isEnabled('newCheckoutLayout', ...) — and that code, finding no flag, can fail in unexpected ways depending on how its error handling is written. Why it happens: the debt report identifies candidates with good confidence, but "candidate to review" and "safe to delete with no further steps" aren't the same — actually removing a flag has two parts: removing it from the registry, and removing the if in the code that queried it, in that order. How to spot it: if deleting a flag from the registry causes production errors, the second step was skipped. How to fix it: treat an isDebt: true flag as the start of a cleanup job — review and remove the code that queries it first, and only then retire it from the registry — not as a single-step action.

Measuring flag debt only by total count, without distinguishing types. What happens: a team sets a goal of "fewer than 20 flags in the registry" and, to meet it, pushes equally to reduce old release flags and operational flags that have been working correctly for years — treating the total count as if all of it were debt equally. Why it happens: a total number is easier to communicate in a team goal than a four-category distinction, and the pressure to simplify ends up flattening a difference that actually matters. How to spot it: if the "reduce flags" goal doesn't distinguish between types, someone is eventually going to propose deleting an operational kill switch just to lower the total number. How to fix it: as in this lesson's model, the right metric isn't "how many flags exist," it's "how many temporary flags (release, experiment) exceed their expected age threshold" — a much smaller, and much more actionable, number than the total.

Exercises

Exercise 1 — Add a fifth flag. Mercado has a release flag named newSearchRanking, with rolloutPercent: 100 and daysSinceCreated: 12. Using flagDebtReport()'s criterion (30-day threshold for release), is it debt? Justify it with the same reason format the model uses.

See solution

It's not debt: even though rolloutPercent: 100 meets the first condition, daysSinceCreated: 12 doesn't exceed the 30-day threshold — the full condition requires f.rolloutPercent === 100 && f.daysSinceCreated > 30, and 12 > 30 is false. The reason would be 'actively in use, no signs of debt', the same one recommendations got in the example. This illustrates an important point: reaching 100% doesn't immediately flag debt — the team needs a reasonable margin (here, up to 30 days) to confirm the full rollout is stable before anyone is expected to remove the flag from the code.

Exercise 2 — Find the ambiguous case. An experiment flag has daysSinceCreated: 200 but experimentClosed: false (the experiment is still actively running, with results not yet significant). By this lesson's model, is it debt? Do you think it should be, even though the model says no?

See solution

By this lesson's exact model, it's not debt — the condition for experiment only checks experimentClosed, not age — so the reason would be 'actively in use, no signs of debt'. But 200 days is an unusually long time for an experiment to still lack significance (this ecosystem's metrics guide treats an experiment's duration as something sized in advance, not open-ended), so there's a reasonable argument that this lesson's model, as written, is incomplete: it's missing an age threshold for experiment flags that have gone too long without closing, too. This is a good example of how a teaching model like flagDebtReport() captures the central case, but a real production system would probably need more complete rules — including, perhaps, alerting when an experiment has run far longer than planned with no result, not only once it has already closed.

Exercise 3 — Explain the debt without using the word "flag." In two or three sentences, explain to a business person why Mercado spends engineering time reviewing and removing old flags, instead of simply leaving them there since they cause no visible error. You can use the breaker panel analogy.

See solution

An example answer: "It's like loose wires behind an electrical panel that nobody uses anymore: they don't cause a short circuit on their own, but every new person who needs to work on that installation has to waste time figuring out which ones are safe to touch and which ones still feed something real. Over time, the more loose wires pile up, the slower and riskier any new work becomes, even work that has nothing to do with those particular wires." The central idea: flag debt's cost isn't an immediate, visible error — it's the accumulated friction every old flag adds to any future work, regardless of what that work is about.

Summary and next step

In this lesson you built flagDebtReport(), a model that combines a flag's type (from lesson 6) with its age and state to decide if it's a debt candidate: out of four Mercado flags, two turned out to be debt — newCheckoutLayout, a release at 100% for 96 days, and checkoutVariantB, an already-closed experiment — and two aren't — recommendations, still in an active rollout, and disableSellerPayouts, an operational expected to live indefinitely. You saw that the correct criterion always combines type and state, never just age in isolation.

Before moving on you should be able to: explain what makes a flag a debt candidate, based on its type; distinguish "deleting from the registry" from "removing the code that queries it," as two separate cleanup steps; and explain why measuring debt by total flag count, without distinguishing type, leads to wrong decisions.

With this lesson the module's topical arc closes: you have the complete feature flag mechanism — what distinguishes it from a plain if (L2), its anatomy and where it lives (L3), how to expose a percentage stably (L4), how to turn everything off instantly (L5), its four types (L6), and when to retire it (L7). Lesson 8, this module's project, asks you to put the three central technical pieces — the flag, the 10% exposure, and the kill switch — into practice on recommendations' real case.

Resources

  • Pete Hodgson (with Martin Fowler), "Feature Toggles (aka Feature Flags)" — martinfowler.com/articles/feature-toggles.html. The "Managing Technical Debt" section explicitly describes feature flags as "inventory that comes with a maintenance cost" and recommends treating them as active technical debt, the direct foundation for this lesson.
  • LaunchDarkly, "Reducing technical debt from feature flags" — launchdarkly.com/docs/guides/flags/technical-debt. Practical documentation on a flag's lifecycle and at which stages (like "Launched," equivalent to this lesson's 100% release) it's worth retiring it from the code.