Config & TestsGENERALSCALE-SPECIFICFRAMEWORK-SPECIFIC

Feature Flags: Rollout, Kill Switches and Debt

Separating deploy from release, buying an instant off-switch — and accumulating a combinatorial mess if nobody removes them.

What actually happensHow to build it

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.

The question

When is a runtime toggle the right tool, and what does having one cost after the launch is over?

The requirement

We want to ship a new pricing engine to 1% of tenants, watch it, expand gradually, and turn it off in seconds if it is wrong — without a deploy for any of those steps.

The obvious build

Add if (process.env.NEW_PRICING === 'true') around the new code path. It is a boolean, it is configuration, and flipping it is a restart.

Why it breaks

A restart is not "in seconds". During an incident the difference between a config flip and a rolling restart is the difference between a blip and an outage.

How it breaks in production
  • A restart is not "in seconds". During an incident the difference between a config flip and a rolling restart is the difference between a blip and an outage.
  • It is all-or-nothing per instance. There is no way to expose the new path to 1% of tenants, or to one internal tenant, which is the entire point of a gradual rollout.
  • Instances flip at different times during a rolling restart, so for several minutes the same tenant can get old pricing on one request and new pricing on the next (Stateless Services).
  • There is no record of who changed it or when, so a behaviour change has no audit trail and no deploy marker to correlate against ("What Changed?" — Deploy Markers and the Invisible Deploys).
  • Nothing removes it. Two years later the environment variable is still set, nobody knows whether the old branch still works, and the code has both paths.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

What is actually happening

  • A feature flag decouples deploy (code is on the servers) from release (behaviour is visible). Those become two separate, independently reversible events, which is the source of nearly all the value.
  • Flags serve at least four distinct purposes, and conflating them is what produces the mess: release toggles (short-lived, removed after launch), kill switches (long-lived, operational), experiments (A/B, with an analysis end date), and permission or plan gates (permanent — and these are authorization, not flags) (Authorization in Backends).
  • Evaluation takes a context — user, tenant, region, plan — and returns a variant. Consistent bucketing is what makes a percentage rollout usable: hashing a stable key means the same tenant always lands in the same bucket, so users do not flicker between behaviours (Consistent Hashing).
  • Flag state has to reach the process. The two shapes are polling with a local cache, and streaming updates. Both mean the value can change *between* two evaluations in the same request, which is the central correctness hazard.
  • A flag decision must be made once per request and reused. Evaluating the same flag at three points in a request can take three different answers if a refresh lands in between, producing a half-old, half-new execution that no code path was written to handle.
  • Every flag doubles the number of code paths in principle. Ten independent flags is 1024 combinations, of which you have tested perhaps three (A Test Strategy Chosen by What Each Layer Can Prove).
  • Flags in the data layer are the sharp edge: a flag that changes which table is written to leaves data in two shapes, and turning it off does not turn the data back.

Four kinds of flag with four different lifespans

The mess comes from treating all of these as one thing. A release toggle that lives forever is debt; a kill switch that gets deleted after launch removes your off-switch; a plan gate implemented as a flag is an authorization control living in a marketing tool.

Classify at creation and the removal policy follows automatically.

KindLifespanWho flips itRemoval policyMain risk
Release toggleDays to weeksThe team shipping itDeleted when the rollout hits 100%Never deleted; becomes permanent branching
Kill switchYears — deliberatelyOn-call, during an incidentKept, reviewed, and exercised periodicallyBit-rot: never tested, fails when finally used
ExperimentOne analysis periodThe analyst or product ownerDeleted when the result is decidedLeft running past the decision; contaminates data
Permission / plan gatePermanentSales, billing or an adminNever — but it should not be a flagIt is authorization; enforce it server-side (Authorization in Backends)
Operational limitLong-livedOn-callKept; documented with safe rangesA dangerous value set under pressure with no bounds (Configuration: Separating Code From Environment)

Evaluate once, store in request context

GENERALThe pattern is provider-independent. Where an SDK evaluates locally against synced rules, this is what it already does internally per call — the request-scoped snapshot is what extends the guarantee across the whole request rather than one call.

The single most important implementation rule: a flag is evaluated at the start of the request, the result goes into request context, and every subsequent decision reads that result. This makes a mid-request refresh harmless.

The reason is not tidiness. A pricing request that reads the flag as true when calculating and false when writing produces a record that neither branch would ever create, and it commits.

One evaluation per request, consistent bucketing, safe default
1// Bucket on a STABLE key hashed with the flag name, so the same tenant
2// always gets the same variant, and different flags bucket independently.
3function bucket(flagKey: string, subjectId: string): number {
4 return hash32(`${flagKey}:${subjectId}`) % 100 // 0..99, stable
5}
6
7function evaluate(flagKey: string, ctx: EvalContext): boolean {
8 const rules = flagCache.get(flagKey) // local, refreshed in background
9 if (!rules) {
10 fallbackEvaluations.inc({ flag: flagKey }) // graph this — silent defaults hide outages
11 return SAFE_DEFAULTS[flagKey] // provider down != our outage
12 }
13 if (rules.tenantAllowlist?.includes(ctx.tenantId)) return true
14 return bucket(flagKey, ctx.tenantId) < rules.percentage
15}
16
17// ONE evaluation per request, at the boundary.
18app.use((req, _res, next) => {
19 req.flags = Object.freeze({
20 newPricing: evaluate('new-pricing', { tenantId: req.auth.tenantId }),
21 })
22 // Now on every log line and every metric for this request:
23 req.log = req.log.child({ variant_new_pricing: req.flags.newPricing })
24 next()
25})
26
27// Downstream code reads the decision; it never re-evaluates.
28const price = req.flags.newPricing ? newEngine.price(cart) : legacyEngine.price(cart)

The frozen req.flags object is the mechanism. A refresh landing halfway through this request changes flagCache and cannot change req.flags, so the request executes exactly one coherent path.

The debt, and what actually pays it down

Stale flags are not a tidiness problem. They are untested code paths that everyone assumes work, branching that makes every subsequent change harder to reason about, and a combinatorial space nobody can test. The old branch decays because nothing exercises it, which is discovered on the day someone finally flips the switch back.

The practices that work are unglamorous and structural: an owner and an expiry on every flag, the removal ticket created with the flag, and a visible count that a team is uncomfortable with.

How flag debt actually hurts
TriggerSymptomCauseResponse
A release toggle at 100% for a yearA refactor breaks the disabled branch; nobody noticesNo test exercises the off path, and no one owns itDelete on reaching 100%; open the removal ticket in the introducing PR
Two flags enabled together for the first timeA failure in a combination that passed every testn flags is 2^n paths; tests cover a handfulKeep concurrent flags few and independent; test the combinations you actually intend to ship
A kill switch flipped during an incidentThe legacy path errors immediately — it stopped working months agoThe switch was never exercised after the code around it changedExercise kill switches on a schedule in a real environment
A flag changing which table is writtenTurning it off leaves records written in the new shape unreadableData changes are not reversible by a toggleUse expand-contract so both shapes are readable throughout (Expand and Contract Migrations)
The flag provider has an outageEvery request falls back to defaults; behaviour changes fleet-wide with no deployNo local cache, or a default chosen for the new path rather than the safe oneCache last-known rules locally; choose defaults by safety and alert on fallback rate

How to build it

Most important first.

  • Classify every flag at creation: release, kill switch, experiment or permission. Release and experiment flags get a removal date; kill switches get an owner and a review cadence; permission gates should not be flags at all.
  • Evaluate once at the start of a request, put the result in request context, and pass it down. Never re-evaluate mid-request (Request Context Propagation).
  • Bucket on a stable identifier — tenant id or user id — hashed with the flag key so different flags bucket independently and the same user does not always land in every experiment.
  • Default to the safe value when the flag service is unreachable, and cache the last known state locally. A flag provider outage must never become your outage (Calling Something You Do Not Control).
  • Keep kill switches independent of the flag service where it matters most: the fastest kill switch is one that fails safe by default.
  • Log the evaluated variant with the request, and add it as a low-cardinality metric label so you can compare error rates and latency between variants directly (The Metrics a Backend Must Emit).
  • Make flag changes auditable: who, when, what value, and why. Treat them as deploys on your dashboards, because they change behaviour exactly like one does.
  • Remove flags aggressively. Put the removal ticket in the same pull request that introduces the flag, and track flag age as a metric someone actually looks at.
  • For anything touching data shape, plan the reverse path before switching on. A flag that is not truly reversible is a deploy wearing a toggle's clothing (Expand and Contract Migrations).

What can go wrong

Failure modes
  • Mid-request inconsistency: a flag flips between two evaluations and the request executes half the old path and half the new one — often committing data that neither path expects.
  • Instances disagreeing during propagation, so concurrent requests from one user get different behaviour for a window. Usually harmless for UI, dangerous for pricing and permissions.
  • The flag service as a hard dependency on the request path, with no local cache: its outage becomes yours, and it is a dependency nobody put on the architecture diagram.
  • Stale flags accumulating until nobody dares delete one, because nobody knows whether the disabled branch still compiles, let alone works.
  • Flag interaction bugs: two flags each correct alone and broken together, in a combination no test covers and no environment exercised.
  • The mitigation failing: a "safe default" that is safe for the new code and unsafe for the old, so the fallback during a provider outage is the untested path.
  • A kill switch that has never been exercised, discovered during an incident to have bit-rotted along with the code path it was meant to disable.
What can race
  • A flag refresh lands mid-request. If the flag is evaluated more than once, the request executes a mixture of both paths — the reason to evaluate once and store the result in request context.
  • Concurrent config or flag reload racing with evaluation: if the rules object is mutated in place, an evaluation can read a half-updated ruleset. Swap an immutable snapshot atomically (Atomic Operations).
  • Two instances propagating at different times means concurrent requests from one user can take different branches — for pricing or permissions that is a correctness bug, not a cosmetic one (Eventual Consistency in Practice).
Security
  • Flags are not authorization. A flag hiding an admin feature is a rollout mechanism; the endpoint must still enforce permission independently (Object-Level Authorization).
  • Flag evaluation context often carries user attributes to the flag provider — plan, region, email domain. That is personal data leaving your system to a third party (Sensitive Data Classification).
  • Who can flip a flag is a privilege question. A production kill switch that anyone with a dashboard login can toggle is a production change with no review (Least Privilege).
  • Flag state exposed to clients reveals unreleased features and internal naming. Server-side flags should stay server-side.
  • Audit flag changes with the same seriousness as deploys. "Nothing changed, we did not deploy" is false in a system with flags, and that gap has hidden the cause of real incidents (Audit Logs for Privileged Actions).
Misreads
  • "Feature flags replace testing." They limit blast radius. Shipping untested code behind a flag ships untested code, and the 1% who receive it are your test (A Test Strategy Chosen by What Each Layer Can Prove).
  • "A flag is free to leave in." Every flag is permanent branching, an untested combination and a code path that decays quietly.
  • "Flags are configuration." Configuration changes at deploy; flags change under live traffic with requests in flight. That difference is the whole hazard.
  • "We can turn it off instantly." Only if the change is reversible. Data written under the new path does not un-write itself.
  • "Percentage rollout means random." It means consistently bucketed. Random per request would give the same user different behaviour on every call, which is almost never what anyone wants.

Operating it

How you see it in production
  • Add the variant as a metric label on request rate, error rate and latency. Comparing variants directly is the entire operational point of a gradual rollout (RED: Rate, Errors, Duration).
  • Log the evaluated variant on the request-completion line so a customer report can be traced to the behaviour they actually received (What a Backend Should Actually Log).
  • Mark flag changes on dashboards alongside deploys. A latency step with no deploy is very often a flag.
  • Graph flag age and the count of active flags. Both should be visible to the team that creates them, or removal never gets prioritised.
  • Alert on flag-service unavailability and on the rate of fallback-to-default evaluations. Silently serving defaults is the failure that looks like nothing.
What changes at 10x and 100x
  • At 10x instances, propagation delay becomes visible: the window in which some instances have the new value and some do not grows, and anything requiring fleet consistency suffers.
  • At 100x, evaluation must be local. A network call per flag per request is unacceptable, so the model becomes "sync rules locally, evaluate in-process".
  • Flag count scales worse than traffic. Combinatorial explosion is a code-comprehension problem that arrives long before any performance problem does.
  • Per-tenant flags are cardinality: a targeting rule listing thousands of tenant ids is a large ruleset to distribute and a large object to evaluate against (Cardinality: The Label That Took Down Monitoring).
What this costs
  • Every flag is branching complexity in the code and a combination that is not tested. The cost is paid by everyone who reads the code afterwards.
  • A flag service is another runtime dependency, and one that sits on the request path unless you cache deliberately.
  • Gradual rollout genuinely reduces blast radius and genuinely makes debugging harder: "it works for me" now depends on which bucket you are in.
  • Kill switches must stay live to be trustworthy, which means keeping the old code path alive and maintained long after it would otherwise be deleted.

Where this applies

Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.

  • GENERALDeploy-versus-release separation, consistent bucketing and stale-flag debt are independent of any provider.
  • SCALE-SPECIFICFor a single service deploying several times a day, a deploy is already a fast rollback and flags may add more complexity than they remove. Flags earn their cost when deploys are slow or risky, when rollout must be gradual across tenants, or when an off-switch must act faster than a deploy.
  • FRAMEWORK-SPECIFICManaged flag platforms differ in the property that matters most: whether rules are streamed and evaluated locally in the SDK, or every evaluation is a network call. The first degrades gracefully when the provider is down; the second puts a third party on your request path. Check which one you have before treating flags as cheap.

Where the depth lives

This domain teaches the application-side mechanism and hands the rest off.