Back to Blog

Feature Flags: One Lever Is Not Enough

Feature Flags: One Lever Is Not Enough cover image

The worst deploy I have been part of went out on a Friday afternoon to every customer at once. A pricing change with an edge case nobody had hit in testing. Within twenty minutes support had eleven tickets, and the rollback took forty minutes because a database migration had gone out in the same release.

The code was fine, mostly. The problem was that we had exactly one lever — ship to everyone or ship to nobody — and we had pulled it.

Feature flags exist to give you more levers than that. They also, if you are careless, give you a codebase that nobody can reason about. Both of those things are true and the difference between them is discipline about the boring parts.

Separating Deploy From Release

The idea underneath everything else: shipping code to production and turning a feature on for users become two independent events.

Once they are separate, a lot of painful things stop being painful. Long-lived branches disappear because unfinished work can merge behind an off flag. Releases stop being events, because the risky moment is a config change rather than a deploy. And rollback becomes a toggle rather than a redeploy — seconds instead of forty minutes.

That last one is the argument that wins over sceptical teams. Every incident where the fix was "revert and wait for CI" is an incident that could have been a flag flip.

Four Kinds of Flag, and They Are Not the Same

Conflating these is the root of most feature-flag mess.

Release flags hide work in progress. Short-lived, days to weeks, deleted the moment the feature is fully on. These should be the majority of your flags and almost none of your permanent ones.

Experiment flags split traffic for an A/B test. They live as long as the experiment and are removed when it concludes.

Operational flags — kill switches. Turn off the recommendation engine when it is struggling; disable the expensive AI feature when the provider is degraded. These are legitimately permanent, and they are the ones I would add first on any system with third-party dependencies.

Permission flags gate features by plan or entitlement. These are not really feature flags at all — they are business logic, and they belong in your authorisation layer, not in a flag service. Putting them in a flag tool is how flag counts get out of control.

Keep the Evaluation Boring

Flags rot when the check is scattered and untyped. Two rules keep it manageable: declare them in one place, and always pass full context so targeting works.

// flags.ts — one registry, typed, with owners and expiry.
export const FLAGS = {
  newCheckoutPricing: {
    key: "new-checkout-pricing",
    type: "release",
    owner: "payments",
    expires: "2026-10-15",   // a release flag past this date is a bug
    default: false,
  },
  aiSummaries: {
    key: "ai-summaries",
    type: "operational",     // kill switch, no expiry
    owner: "platform",
    default: true,
  },
} as const;

export function isOn(flag: keyof typeof FLAGS, ctx: RequestContext) {
  const f = FLAGS[flag];
  // Always pass tenant + user, or percentage rollouts flip per request
  // and a single user sees the feature appear and disappear.
  return client.boolVariation(f.key, {
    key: ctx.user.id,
    tenantId: ctx.tenant.id,
    plan: ctx.tenant.plan,
    country: ctx.user.country,
  }, f.default);
}

That expiry date is the single most useful field. A weekly job that lists release flags past their expiry, with the owning team, is what stops the count creeping into the hundreds. Without it, flags are added forever and removed never.

The default must be the safe value. If the flag service is unreachable, what happens? For a release flag, off. For a kill switch, on. Getting this backwards means a flag provider outage becomes your outage — which I have seen, and it is an embarrassing incident to write up.

How to Actually Roll Out

The sequence I use, and each step exists because skipping it has hurt someone:

Internal only. Your own accounts. Catches the obvious.

One friendly customer, told in advance. Real data, real usage patterns, and a person who will tell you rather than churn.

5%, sticky by user. Sticky matters — a user who sees the new checkout on one page and the old one on the next will file a bug, and they will be right.

25%, then 50%, then 100%, watching error rate, latency and the business metric between each step.

Delete the flag. The step everyone skips. A flag that is 100% on and still in the code is dead weight and a future accident.

The important part is what you watch. Not just errors — the business metric. A change can be technically flawless and reduce conversion, and error rates will tell you nothing about it.

Flags and Migrations Do Not Mix Well

The failure that made our Friday deploy unrecoverable. A flag can be turned off in a second; a database migration cannot.

So they have to be decoupled. Ship the schema change first, on its own, backwards-compatible — add nullable columns, never rename or drop. Deploy the code behind a flag that can use either shape. Roll out. Only once the flag is fully on and stable does the cleanup migration go out, in a separate release.

Expand, migrate, contract. It is more steps and it means a flag flip is always sufficient to get back to safety.

The Cost, Stated Honestly

Every flag is a branch, and n flags mean up to 2ⁿ combinations. In practice combinations do not multiply cleanly, but the point stands: your test suite covers a fraction of the states production can be in.

Mitigations that work: keep flag lifetimes short so few are live at once, test the two states that matter for each flag rather than every combination, and use a consistent flag state per environment so staging is not a random configuration nobody has seen before.

And be strict about one thing — no flags inside flags. Nested conditionals across two flag checks produce code that nobody can trace, and it is where the genuinely confusing production bugs come from.

Where to Start

If you have none of this, do not adopt a platform first. Add one kill switch for your most fragile third-party dependency — the payment provider, the AI feature, the recommendation service. Get comfortable with the pattern on something where the value is obvious.

Then add release flags to your next risky feature and do the percentage rollout properly. Then add the expiry job before you have twenty flags, not after.

Our Friday pricing change would have been a five-minute incident affecting 5% of users, ended by a toggle. Instead it was forty minutes affecting everyone, because we had one lever and it was attached to a migration.

Related Posts