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.
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.
When is a runtime toggle the right tool, and what does having one cost after the launch is over?
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.
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.
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.
- 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.
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.
| Kind | Lifespan | Who flips it | Removal policy | Main risk |
|---|---|---|---|---|
| Release toggle | Days to weeks | The team shipping it | Deleted when the rollout hits 100% | Never deleted; becomes permanent branching |
| Kill switch | Years — deliberately | On-call, during an incident | Kept, reviewed, and exercised periodically | Bit-rot: never tested, fails when finally used |
| Experiment | One analysis period | The analyst or product owner | Deleted when the result is decided | Left running past the decision; contaminates data |
| Permission / plan gate | Permanent | Sales, billing or an admin | Never — but it should not be a flag | It is authorization; enforce it server-side (Authorization in Backends) |
| Operational limit | Long-lived | On-call | Kept; documented with safe ranges | A dangerous value set under pressure with no bounds (Configuration: Separating Code From Environment) |
Evaluate once, store in request context
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.
1// Bucket on a STABLE key hashed with the flag name, so the same tenant2// 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, stable5}6 7function evaluate(flagKey: string, ctx: EvalContext): boolean {8 const rules = flagCache.get(flagKey) // local, refreshed in background9 if (!rules) {10 fallbackEvaluations.inc({ flag: flagKey }) // graph this — silent defaults hide outages11 return SAFE_DEFAULTS[flagKey] // provider down != our outage12 }13 if (rules.tenantAllowlist?.includes(ctx.tenantId)) return true14 return bucket(flagKey, ctx.tenantId) < rules.percentage15}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.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| A release toggle at 100% for a year | A refactor breaks the disabled branch; nobody notices | No test exercises the off path, and no one owns it | Delete on reaching 100%; open the removal ticket in the introducing PR |
| Two flags enabled together for the first time | A failure in a combination that passed every test | n flags is 2^n paths; tests cover a handful | Keep concurrent flags few and independent; test the combinations you actually intend to ship |
| A kill switch flipped during an incident | The legacy path errors immediately — it stopped working months ago | The switch was never exercised after the code around it changed | Exercise kill switches on a schedule in a real environment |
| A flag changing which table is written | Turning it off leaves records written in the new shape unreadable | Data changes are not reversible by a toggle | Use expand-contract so both shapes are readable throughout (Expand and Contract Migrations) |
| The flag provider has an outage | Every request falls back to defaults; behaviour changes fleet-wide with no deploy | No local cache, or a default chosen for the new path rather than the safe one | Cache 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
- 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.
- 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).
- 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).
- "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
- 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.
- 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).
- 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.