Feature Flags for Frontend: Rollout Safety Without Redeploys
Shipping code and releasing features stopped being the same thing for us a long time ago.
We ran a fleet of micro-frontends with a shared deploy pipeline. Deploys were boring and safe — canary, health checks, automatic rollback. But every deploy still carried the same anxiety, because deploying the code and exposing the feature were one atomic, irreversible step. A bad launch meant a rollback, and a rollback meant taking everyone’s unrelated changes down with it.
Feature flags fixed the actual problem: they decoupled the deploy from the release. The pipeline ships code; flags decide who sees it. Once we internalized that, launches got boring in the best way.
This is the setup that worked for us, and the mistakes that taught us each piece.
The #1 mistake: flags as permanent if-statements
Every flag system starts innocently:
if (flags.newCheckout) {
return <NewCheckout />;
}
return <OldCheckout />;
Six months later, nobody knows whether newCheckout is on for everyone, safe to remove, or load-bearing in some forgotten edge case. The flag becomes permanent. Then there are forty of them, and every new feature has to reason about the combinatorial swamp of everyone else’s abandoned toggles.
A flag without a removal plan is tech debt with a toggle. Every flag needs an owner, a purpose, and an expiry date — decided at creation time, not “later”.
Principle: deploy is not release
The mental model that made everything click:
“Deploy ships code to production. Release exposes it to users. Flags are the gap between the two.”
Once you see it this way, several decisions become obvious:
- Code behind a flag should be production-quality on day one — it is in production, just dark.
- Rollback becomes “flip the flag”, which is instant, targeted, and doesn’t touch anyone else’s work.
- The scariest launches (payments, auth, pricing) get the most flag coverage, not the least.
Architecture: a typed evaluation layer
Don’t sprinkle flags.someString across components. Centralize evaluation behind a typed API so flags are greppable, autocompletable, and removable with confidence:
// flags/registry.ts
export const FLAGS = {
"checkout.express-apple-pay": {
owner: "@payments-team",
type: "release",
expires: "2026-12-01",
description: "New express checkout flow with Apple Pay",
},
"search.semantic-ranking": {
owner: "@search-team",
type: "release",
expires: "2026-11-15",
description: "Semantic ranking, 10% rollout",
},
"ops.checkout.kill-express-flow": {
owner: "@payments-team",
type: "ops",
description: "Kill switch: disable express checkout without a deploy",
},
} as const;
export type FlagKey = keyof typeof FLAGS;
// flags/evaluate.ts
interface FlagContext {
userId: string;
cohort: string;
region: string;
}
export function evaluateFlag(key: FlagKey, ctx: FlagContext): boolean {
const rule = getRule(key); // from remote config, cached locally
return rule.evaluate(ctx);
}
// flags/useFlag.ts
export function useFlag(key: FlagKey): boolean {
const ctx = useFlagContext();
return useMemo(() => evaluateFlag(key, ctx), [key, ctx]);
}
(useFlagContext should return a memoized context, otherwise the useMemo recomputes on every render.)
Typed keys buy you three things: your editor tells you when a flag name is wrong, grep tells you exactly where a flag is used when it’s time to delete it, and TypeScript tells you when the registry entry is gone but a call site remains.
Targeting: who sees what
Every flag evaluates to on or off, but not every flag is on or off for everyone. We used four rollout shapes, in increasing order of sophistication:
- Kill switch — boolean, default off (or on). The simplest and most valuable flag shape.
- Percentage rollout — 1% → 10% → 50% → 100%. Boring, effective.
- Cohort targeting — internal users, beta group, specific region.
- Attribute rules — “users on plan=pro in region=eu”.
Percentage rollouts have one non-negotiable requirement: sticky bucketing. Hash the user ID into a bucket; never randomize per evaluation:
function hashToBucket(id: string): number {
let h = 2166136261;
for (const c of id) {
h ^= c.charCodeAt(0);
h = Math.imul(h, 16777619);
}
return (h >>> 0) % 100;
}
function inRollout(key: FlagKey, userId: string, percent: number): boolean {
return hashToBucket(`${key}:${userId}`) < percent;
}
Note the flag key in the hash input — otherwise a user in bucket 7 for one flag is in bucket 7 for every flag, and your “independent” rollouts correlate. And because the check is bucket < percent, raising a rollout from 10% to 50% only adds users; nobody who already had the feature loses it. Random per-request bucketing is worse: users flip-flop between variants on every page load, which is both a terrible experience and useless experiment data.
Kill switches: the real reason flags exist
Every risky launch got a kill switch, even when we were confident. The rule was simple: if a feature could plausibly need to be turned off at 2 AM by someone who didn’t write it, it got a flag with a big red name.
Kill switches differ from release flags in one important way: they’re long-lived and operational, not temporary. Don’t mix them into the same cleanup process as release flags, or someone will diligently delete the switch that saves you during the next incident. We tagged them type: 'ops' in the registry and exempted them from expiry.
The incident runbook entry wrote itself: “If checkout is broken, flip ops.checkout.kill-express-flow. No deploy needed, no other teams affected.”
One caveat: “instant” only holds if the client actually hears about the flip. Rules cached in the browser (or a snapshot baked into server-rendered HTML) keep the old value in every open tab until the next refresh. Ops flags need a short cache TTL plus polling or a streaming update — otherwise your kill switch only works for new page loads.
Flag hygiene: the part everyone skips
This is where flag systems go to die. Our discipline had four parts:
Naming convention. area.description, all lowercase, dot-separated: checkout.express-apple-pay, search.semantic-ranking. Kill switches get an ops. prefix (ops.checkout.kill-express-flow) so they stand out. When you’re staring at forty flags in a dashboard, the namespace tells you which area it touches, and the registry’s owner tells you who to ping.
Flag lifecycle types. We distinguished three kinds and treated them differently:
release— short-lived, has an expiry date, must be removed after full rollout.ops— long-lived kill switches and circuit breakers, reviewed but never “expired”.entitlement— permanent-ish, tied to plans/permissions (e.g.billing.pro-features). These are product configuration, not really flags — but they lived in the same system so there was one place to look.
Expiry dates with teeth. Every release flag had an expiry date in the registry. A weekly CI job listed flags past expiry and opened a cleanup issue assigned to the owner. Not a Slack reminder that gets ignored — an issue, assigned, with the grep results attached.
Delete the code with the flag. The PR that removes a flag removes the branching code too, in the same diff. “Remove flag, keep both branches just in case” is how you get dead code that nobody dares touch.
Testing: the matrix problem
N flags means 2^N combinations, and you can’t test them all. We tested three configurations per flag:
- Flag off (the control — this is what most users still see).
- Flag on (the treatment).
- The specific combinations we were actually rolling out together.
For E2E, we added a flag override via query param, honored only in non-production environments:
// e2e only: ?flags=checkout.express-apple-pay:true,search.semantic-ranking:false
function getE2EOverrides(): Partial<Record<FlagKey, boolean>> {
if (APP_ENV === "production") return {};
const params = new URLSearchParams(window.location.search);
return Object.fromEntries(
(params.get("flags") ?? "")
.split(",")
.filter(Boolean)
.map((pair) => pair.split(":"))
.filter(([k]) => k in FLAGS)
.map(([k, v]) => [k, v === "true"]),
);
}
This let QA and Playwright tests pin any combination without redeploying or touching the flag service. The production guard matters — you do not want users self-enabling unreleased features from the URL bar. Note the guard checks the deploy environment (APP_ENV, injected at build time), not NODE_ENV: staging and QA run production builds too, so a NODE_ENV check would switch overrides off exactly where you need them.
The SSR hydration gotcha
If you server-render, flags are a hydration footgun. Evaluate on the server, serialize the evaluated values into the page, and hydrate from those exact values:
// _document.tsx (or your SSR entry)
<script
dangerouslySetInnerHTML={{
// escape "<" so a value can never close the script tag
__html: `window.__FLAGS__=${JSON.stringify(evaluatedFlags).replace(/</g, "\\u003c")}`,
}}
/>
// client bootstrap reads the server-evaluated snapshot first
declare global {
interface Window {
__FLAGS__?: Record<FlagKey, boolean>;
}
}
const initialFlags: Record<FlagKey, boolean> = window.__FLAGS__ ?? evaluateAllFlags(getContext());
If the client re-evaluates with a slightly different context (different user agent parsing, a clock skew, a missing cookie), React sees mismatched HTML and you get hydration errors on exactly the pages you most wanted to be fast. Evaluate once, ship the answers with the HTML.
The trade-off: per-user flag values make the HTML per-user, which means it can’t be shared from a CDN cache. If a page needs to stay edge-cacheable, keep its above-the-fold markup flag-free and resolve flags on the client after hydration.
What I’d do differently next time
- Start the cleanup process on day one. We added expiry enforcement a year in, and the archaeology project to clear old flags took a full sprint. The second flag ever created should have had an expiry date.
- Separate release flags from entitlements earlier. They look similar but have opposite lifecycles. Mixing them meant our cleanup tooling kept flagging (ha) permanent configuration as overdue.
- Put flag state in the bug report template. “Which flags were on?” should be as routine as “which browser?“. We added evaluated flags to our error reporting context six months too late, and debugged at least three “impossible” bugs that were just flag combinations.
A summary you can apply
- Decouple deploy from release: the pipeline ships code, flags decide who sees it.
- Give every flag an owner, a purpose, and an expiry date at creation.
- Centralize evaluation behind typed keys — greppable, autocompletable, deletable.
- Bucket percentage rollouts by hashing a stable ID (namespaced per flag), never by random.
- Keep kill switches (
type: 'ops') separate from release flags; exempt them from expiry. - Enforce cleanup with automation: expired flags become assigned issues, not reminders.
- Delete the branching code in the same PR that removes the flag.
- Test off, on, and the combinations you actually ship; allow E2E overrides outside production.
- SSR: evaluate once on the server, serialize the answers, hydrate from them.
The goal was never “use feature flags”. The goal was launches so boring nobody notices them — and flags, with discipline, got us there.
Enjoyed this post?