Feature flags are debt, not configuration

Every flag doubles the reachable code paths and no flag ever gets deleted by itself. Giving each one a removal date matters far more than building a clever flag system.

The problem feature flags solve is clear: decouple deploy from release so you can canary and roll back. The problem they create is just as clear: every flag doubles the possible state combinations, and nobody owns deleting it.

Three kinds, three lifetimes

Kind Lifetime Example
Release flag days to weeks canary the new homepage
Ops flag months, maybe forever kill switch for a dependency
Experiment flag one experiment A/B test

Managing all three in one system is where the mess starts. Release flags must be deleted. They are scaffolding for the rollout, nothing more.

Use a date, not a boolean

An expiry field on the flag beats any written convention:

const FLAGS = {
  newCheckout: { enabled: false, expiresAt: '2026-10-31' },
  legacySearch: { enabled: true, expiresAt: '2026-12-01' },
} as const;

Pair it with one test: an expired flag fails the build.

for (const [name, f] of Object.entries(FLAGS)) {
  if (Date.parse(f.expiresAt) < Date.now()) {
    throw new Error('flag ' + name + ' is past its removal date');
  }
}

That assertion turns “clean up later” into “deal with it now”. Without it, flags live in the code forever.

Reads need a default

function isEnabled(name: keyof typeof FLAGS): boolean {
  const override = process.env['FLAG_' + name];
  return override ? override === '1' : FLAGS[name].enabled;
}

Keep the default in code and let the environment variable be a temporary override. The reverse (default off, config turns it on) means a new environment silently ships with the feature disabled.

Keep the branch in one place

A flag should appear in exactly one spot. Scattered if (flag) checks make removal nearly impossible.

// good: one place chooses the implementation
export const checkout = isEnabled('newCheckout') ? newCheckout : legacyCheckout;

// bad: branches all over the implementation
export function checkout(cart) {
  if (isEnabled('newCheckout')) { /* ... */ }
  if (isEnabled('newCheckout')) { /* ... */ }
}

The first is deleted by removing a line and a file. The second needs a merge undone in dozens of places.

A flag is not a business rule

If a flag has been on for three months with no plan to turn it off, it is not a flag any more. It is configuration and belongs in the real code path. Calling it a flag just makes people think it can be dropped at will.

A flag earns its keep during the rollout. Every day it survives afterward, it makes the code harder to read and test.

← Back to all posts

Comments

…