Feature flags
Switches in your code that turn features on or off for chosen users without a deploy: for gradual releases, experiments, emergency kill switches, and plan-based access, if you avoid flicker, leaked code, and a pile of dead flags.
The problem: deploying is not the same as releasing
It is Thursday afternoon. Your team has rebuilt the checkout page over six weeks, and the reviewed, tested code ships to production at 14:00. By 14:20 the error tracker shows a spike: card payments from one bank fail on the new page. Revenue is dropping by the minute.
Without feature flags, your only lever is a rollback. That means a new build, a new deploy, cache purges on the CDN, and maybe 20 to 40 minutes before every user is back on the old page. Worse, the rollback also removes the three unrelated fixes that shipped in the same deploy.
With a feature flag, the new checkout sits behind if (flags.newCheckout). The code is deployed to everyone, but the flag decides who sees it. At 14:20 someone opens the flag dashboard, sets newCheckout to off, and within seconds users get the old checkout again. No build, no deploy, and the three fixes stay live.
Two different events
A deploy puts new code on servers and in the bundle. A release turns that code on for real users. Without flags, the two happen at the same moment. With flags, you deploy whenever the code is ready, and release later, gradually, to whoever you choose.
Separating them changes how a team works. Engineers merge small pieces of an unfinished feature into the main branch every day, hidden behind a flag that is off. This is trunk-based development: no long-lived feature branch, no painful merge at the end. The product manager decides when to turn it on, not the release train.
What flags buy you
- Gradual rollout: turn the feature on for 1% of users, watch errors and Core Web Vitals, then 10%, 50%, 100%. A bug hits 1% of users, not all of them.
- Instant rollback: turn the flag off in seconds, without a deploy. This is the kill switch.
- Targeting: show the feature only to employees, beta testers, one country, or one customer.
- Experiments: show variant A to half the users and variant B to the other half, then measure.
- Entitlements: show premium features only to users on the paid plan.
Each of these sounds like the same if statement, and in the code it is. But they behave very differently: how long the flag lives, who changes it, how fast it must update, and how dangerous it is to get wrong. Mixing them up is the first mistake teams make, and the next chapter sorts them out.
What flags cost
Flags are not free. Every boolean flag doubles the number of code paths through that part of the app: two flags make four combinations, ten flags make 1,024.
On the frontend there are extra costs. The flag value has to reach the browser, and if it arrives late the page shows the old UI, then jumps to the new one. That jump is flicker, and it hurts both users and your Cumulative Layout Shift score.
Flags can also leak. If the code for an unreleased feature is in the JavaScript bundle, anyone who opens DevTools can read it, and sometimes turn it on. And flags that are never removed pile up as flag debt: dead branches nobody dares to delete. This lesson covers each of these in turn.
Check your understanding · Exercise 1
Your team ships the new search page behind a flag that is off. Which statements are true? Select all that apply.
Four kinds of flags, and why the kind matters
Pete Hodgson's article on martinfowler.com sorts feature toggles into four kinds using two questions. How long does the flag live? And how dynamic is its decision: one value for everyone, or decided per user or per request? The answers decide almost everything else, from where you evaluate the flag to who may change it.
Release flags
A release flag hides unfinished or unproven code. It starts off, rolls out from 1% to 100% over days or weeks, and is then deleted. Its value changes rarely and it should live a few weeks at most. The engineering team owns it.
Example: newCheckout. The common mistake is forgetting the last step. Once a release flag reaches 100% and stays there for two weeks, it is no longer a flag: it is dead code with a config lookup attached.
Experiment flags
An experiment flag assigns each user to a variant (control, a, b) and keeps them there so you can compare metrics between groups. It lives for the length of the experiment, often two to four weeks, and the product or growth team owns it.
What makes it different: the assignment must be sticky (the same user gets the same variant on every visit and device) and every exposure must be logged, or the analysis is worthless. The statistics side, sample sizes and significance, belongs to the A/B testing lesson. Here we only care about delivering the right variant without flicker.
Ops flags and kill switches
An ops flag controls how the system behaves under load or failure. A kill switch is the classic one: recommendationsEnabled turns off a costly widget when its backend is struggling. Others lower the polling rate, switch image quality, or disable autocomplete during a traffic spike.
Ops flags are often long-lived, sometimes permanent, and they must change fast: when on-call flips one at 03:00, every client should feel it within seconds, not on the next page load. The on-call or SRE team owns them.
Permission flags
A permission flag turns features on for a group of users: the paid plan, internal staff, a beta program, one enterprise customer. It can live for years, and its value is decided per user from their account. Sales, support, or product owns it.
These are the dangerous ones on the frontend. Hiding a button for free users is fine. Trusting the client to enforce the plan is not: the server must check the entitlement on every request, because anyone can change a value in their own browser. Chapter 8 returns to this.
| Question | Release | Experiment | Ops / kill switch | Permission |
|---|---|---|---|---|
| Purpose | Hide unfinished code, roll out gradually | Compare variants | Degrade or turn off under trouble | Gate features by plan or group |
| Lifetime | Days to weeks | Weeks | Months to forever | Months to years |
| Value changes | Rarely, step by step | Fixed per user once assigned | Suddenly, in an incident | When the account changes |
| Owner | Engineering | Product or growth | On-call or SRE | Product, sales, support |
| Needs fast updates | No | No (must be sticky) | Yes, seconds | On next request is fine |
| Safe to evaluate only on the client | Yes, if the code is not secret | Yes, with logging | Yes | Only for display, never for access |
Why naming the kind matters
Most flag tools store all four kinds the same way, so the difference lives in your process. Tag every flag with its kind and an owner when you create it. Release and experiment flags get an expiry date; ops and permission flags do not, but they get a review date.
The kind also tells you how to test and evaluate. A permission flag must be checked on the server. A kill switch needs a live update channel, and an experiment flag needs sticky bucketing and an exposure event. A release flag needs a cleanup ticket.
If you can't say which kind a flag is, it will probably be misused.
Check your understanding · Exercise 2
Match the flag to its kind. Which pairings are right? Select all that apply.
How a flag is evaluated, step by step
Every flag system, from a JSON file to a paid service, does the same job. It takes a flag definition (the rules someone set in a dashboard) and an evaluation context (facts about the current user and request), and returns a variant: true or false, or a string like "annual-first". This step is called evaluation.
The definition and the context
Here is a realistic definition for the new checkout, written as plain data. It has an on/off switch, a list of rules checked in order, and a default for everyone the rules don't match.
{
"key": "newCheckout",
"kind": "release",
"owner": "payments-team",
"expires": "2026-12-01",
"enabled": true,
"offVariant": false,
"rules": [
{ "if": { "attr": "email", "endsWith": "@acme.com" }, "serve": true },
{ "if": { "attr": "country", "in": ["DE", "AT"] }, "serve": false },
{ "rollout": { "percent": 10, "by": "userId" }, "serve": true }
],
"default": false
}
The context is whatever you know about the request: { userId: "u_42", email: "sam@gmail.com", country: "IN", plan: "free", appVersion: "5.3.0" }. On the server it comes from the session. In the browser it comes from whatever the app knows, which is one reason client evaluation is weaker: an anonymous visitor has no userId yet.
Walking through one evaluation
- Is the flag
enabled? If not, serve theoffVariant(false) and stop. This is the kill switch path, and it is checked first on purpose. - Rule 1: does
emailend with@acme.com? Sam's is@gmail.com, so no. - Rule 2: is
countryDE or AT? Sam is in IN, so no. - Rule 3: a 10% rollout by
userId. Hash the flag key and the user id together, map the result to a bucket from 0 to 99, and servetrueif the bucket is below 10. Sayu_42lands in bucket 37: no. - No rule matched, so serve the
default:false. Sam sees the old checkout.
Sticky bucketing: hashing, not random numbers
The rollout step must give the same answer every time for the same user. If it used Math.random(), Sam would see the new checkout on one page load and the old one on the next, on a different device, or even in two tabs. That is confusing for users and it ruins any measurement.
So systems use a deterministic hash. They feed a string such as newCheckout:u_42 into a fast hash like MurmurHash3 or SHA-1, take part of the result as a number, and reduce it to a bucket, often 0 to 99, or a larger range such as 0 to 99,999 for finer steps. The same input always gives the same bucket, on any server, in any browser, with no database lookup. This is sticky bucketing.
function bucket(flagKey: string, userId: string): number {
let h = 0x811c9dc5;
for (const ch of `${flagKey}:${userId}`) {
h ^= ch.charCodeAt(0);
h = Math.imul(h, 0x01000193);
}
h ^= h >>> 16; // final mix (MurmurHash3's finalizer)
h = Math.imul(h, 0x85ebca6b);
h ^= h >>> 13;
h = Math.imul(h, 0xc2b2ae35);
h ^= h >>> 16;
return (h >>> 0) % 10_000; // 0..9999, so steps of 0.01%
}
const inRollout = (key: string, id: string, percent: number) =>
bucket(key, id) < percent * 100;
Two details matter. First, include the flag key in the hash. If you hashed only the user id, the same 10% of users would get every new feature first and become a permanent test group.
Second, compare the bucket with a threshold: on a 0 to 99 scale, serve the feature if bucket < percent. Going from 10% to 25% keeps everyone who was already in (buckets 0 to 9) and adds buckets 10 to 24, so nobody flips back to the old UI during a ramp.
What do you hash for a visitor who is not logged in? Usually a random id stored in a first-party cookie on the first visit. That keeps them sticky on one browser, but not across devices. When they log in, the bucket may change, so many teams evaluate signed-out pages by the cookie id and accept the switch at login.
2 of 20 users get newCheckout at 10%. Change the rollout or the flag key to compare.
Check your understanding · Exercise 3
You ramp newCheckout from 10% to 20% using hash(flagKey:userId) % 100 < percent. What happens? Select all that apply.
Where to evaluate: server, client, or edge
Evaluation can run in three places: on your server (or backend for frontend) while it builds the page, in the browser after the JavaScript loads, or at a CDN edge function in between. Each one changes three things: how fast the user gets the right UI, whether the page flickers, and how much of your unreleased work ends up in public JavaScript.
Client-side evaluation
The browser SDK starts, fetches either the full rule set or the already-evaluated values for this user, and then the app renders. Many vendors offer both modes. Downloading all rules is risky: the rules reveal every flag name, every targeting list, sometimes customer emails. So many client SDKs send the context to the vendor (or to a proxy you run) and get back only the values for that user.
The cost is a network round trip before the app knows what to show. On a decent connection that is roughly 50 to 150 ms; on a slow phone network or a cold vendor endpoint it can be 500 ms to over a second. The app has to render something while waiting, and that something is usually the default. Then the real value arrives and the UI changes: flicker.
Server-side evaluation
The server SDK keeps the full rule set in memory and refreshes it in the background, usually over a streaming connection or by polling, often every 15 to 60 seconds depending on the SDK. Evaluation is then a local function call that takes microseconds, with no network trip per request. The server renders the right UI into the HTML and also writes the values into the page for the client to reuse.
This gives no flicker, no extra round trip, and secret rules stay on the server. The cost is that the page must be rendered per user, which hurts caching. A product page that was one cached HTML file for everyone now has a variant per flag combination, or must skip the CDN cache entirely.
Edge evaluation
An edge function (Cloudflare Workers, Vercel middleware, Fastly Compute) runs in the CDN location near the user. It reads a cookie for the user id, evaluates the flags from rules cached at the edge, and then either rewrites the request to a pre-built variant (/checkout becomes /checkout-v2) or adds the values to a header the origin uses. The decision adds only a few milliseconds, and static pages can stay cached, one copy per variant.
The limits: edge functions have small CPU and memory budgets, the rule set must be synced to hundreds of locations, and the edge knows little about the user beyond cookies, headers, and rough geography. It suits marketing pages and experiments on static sites, less so deep app logic.
Decision Where should the checkout page evaluate newCheckout?
What we pick: For a logged-in app with SSR, evaluate on the server and bootstrap the values: it removes flicker and keeps rules private. For a static marketing site, use the edge. Use client-only evaluation for flags that change things below the fold or after an interaction, where a late value doesn't move anything the user is looking at.
The usual answer is hybrid
Large apps mix all three. The server evaluates flags that change the first screen and passes them down. The client SDK starts from those values, then opens a stream for live changes so a kill switch reaches open tabs.
Flags that only matter after a click can be evaluated lazily on the client. The rule of thumb: the earlier a flag affects pixels, the earlier in the request it should be decided.
Check your understanding · Exercise 4
A static, CDN-cached landing page runs a headline experiment with two variants. Which approaches avoid flicker? Select all that apply.
Avoiding flicker and leaked code
Flicker is the most visible flag bug on the frontend. The page paints with one value, then the real value arrives and the page changes: the user sees the old banner for 400 ms, then the new one.
If the new banner is taller, everything below it jumps, which counts toward CLS (Cumulative Layout Shift, the Core Web Vital for unexpected movement). If the flag picks a different hero image, the LCP (Largest Contentful Paint) is recorded later, when that late image finally renders.
Why it happens
A client-only setup has a gap. The HTML arrives, the JavaScript loads and renders, and only then does the SDK ask for flag values. Whatever the app renders during that gap uses a default. The fix is always the same idea: have the right value before the first render, or don't render the flagged part until you do.
Fix 1: bootstrap values into the HTML
When the server renders the page, it has already evaluated the flags. Write those values into the HTML as a small JSON script, and start the client SDK from them. The first client render uses the same values the server used, so hydration matches and nothing moves.
// server (inside the page render)
const flags = await flagClient.getAll(ctx); // local, from in-memory rules
const html = renderToString(<App flags={flags} />);
res.send(`${html}<script id="flags" type="application/json">${
safeJson(flags) // escapes "<" so the JSON can't close the tag
}</script>`);
// client (before hydrate)
const boot = JSON.parse(document.getElementById("flags")!.textContent!);
const client = createFlagClient({ bootstrap: boot, stream: true });
hydrateRoot(root, <App flags={boot} client={client} />);
Only send the values this user needs, not the rules. A bootstrapped payload of 40 flags is usually a couple of KB at most. Escape it properly: user-controlled strings inside an inline script are an XSS risk.
Fix 2: cache last-known values, then revalidate
For a pure SPA without SSR, store the last values in localStorage. On the next visit, render immediately from the stored values, fetch fresh values in the background, and apply them only at a safe moment, such as the next route change. This is stale-while-revalidate applied to flags. The very first visit still needs a default, but repeat visits don't flicker.
The trap: a stale value can be wrong for a while. If you just turned off a kill switch, the cached true survives until the refresh. So for ops flags, apply fresh values right away even if it causes a change; for release and experiment flags, wait for a safe moment.
Fix 3: good defaults and a timeout
Every flag read needs a default in code: client.getBooleanValue("newCheckout", false). Choose the default that is safe if the flag service is down: the old, proven UI. Then put a time limit on waiting: if the SDK has not answered within, say, 300 ms, render with defaults and don't switch later on this page. A late switch is worse than staying on the default.
When none of this is possible, reserve space. Render a skeleton with the same size as both variants, so a late value swaps content without moving the layout. That turns a CLS problem into a short loading state.
Leaked code: the flag hides the UI, not the source
With client evaluation, both branches of if (flags.newPricing) are in the bundle. Anyone can search the JavaScript for strings like "Pricing 2027" and read your unreleased copy, prices, or partner names. Journalists and fans regularly find unreleased features in the code of popular apps exactly this way. Some can even flip the flag locally with DevTools and try the feature.
Two defences. For secret features, load the new code only when the flag is on: const NewPricing = lazy(() => import("./NewPricing")) puts it in a separate chunk the browser fetches only if needed. For truly sensitive launches, render the new part on the server (or with React Server Components) so its code never reaches users outside the rollout.
First paint at 600 ms. Right UI from 1400 ms. Layout shift at 1400 ms: everything below the banner jumps 40 units.
Check your understanding · Exercise 5
A React SPA (no SSR) shows a promo banner behind a flag. Users report the banner "popping in" and pushing the page down. Which fixes help? Select all that apply.
The parts: SDKs, OpenFeature, updates, and rollouts
A flag setup has four moving parts: the dashboard where people edit rules, the flag service that stores and serves them, the SDKs in your servers and clients that evaluate, and the events the SDKs send back so you can see who got what. You can buy all four (LaunchDarkly, Statsig, Optimizely, ConfigCat), self-host them (Unleash, Flagsmith, GrowthBook, flagd), or build a small version yourself.
OpenFeature: one API, any vendor
Each vendor used to have its own SDK, so switching vendors meant touching every flag call in the codebase. OpenFeature is an open standard, a CNCF incubating project, that defines one evaluation API. Your code talks to the OpenFeature client; a provider plugged in at startup talks to the actual vendor or to your own backend.
import { OpenFeature } from "@openfeature/web-sdk";
await OpenFeature.setContext({ targetingKey: user.id, plan: user.plan });
await OpenFeature.setProviderAndWait(new MyVendorProvider({ clientKey }));
const client = OpenFeature.getClient();
const showNew = client.getBooleanValue("newCheckout", false);
const layout = client.getStringValue("pricingLayout", "monthly-first");
Three OpenFeature ideas are worth knowing. The evaluation context carries a targetingKey, the id used for bucketing. Hooks run before and after each evaluation, which is where you add logging or send exposure events.
And the web SDK uses a static context model: the context is set once (and again when the user changes), and values are read synchronously, which suits UI rendering. Server SDKs use a dynamic context model and accept a context on every call.
How changes reach running apps
When someone flips a flag, how fast does it reach a tab that has been open for an hour? There are three ways, each a familiar realtime pattern:
- On next load: the app reads flags once at start. Simple and stable, but an open tab never sees the change. Fine for release flags, wrong for kill switches.
- Polling: the SDK refetches on an interval, often somewhere between 15 seconds and a few minutes. Cheap and works through every proxy, but changes take up to one interval to land.
- Streaming: the SDK keeps a Server-Sent Events or WebSocket connection open and usually gets a message within a second or two of the change. Best for ops flags, at the cost of one long-lived connection per tab.
Live updates bring their own UX question. If a release flag turns on while a user is halfway through the old checkout, swapping the page under them is worse than waiting. A good pattern: apply ops flags and any switch to off immediately, and apply other release and experiment changes only on the next navigation.
Exposure events
Each time the app actually shows a variant, the SDK records an exposure: flag, variant, user, time. For experiments this is the data the analysis runs on.
For release flags, exposures tell you how many users are really on the new path. Attach the active flag values to error reports and logs too, as the error tracking and frontend logging lessons suggest. A crash that only happens when newCheckout=true is then one filter away.
A safe rollout, step by step
Turning a release flag from 0% to 100% in one click throws away most of what flags give you. A typical progressive rollout looks like this:
- Internal: on for employees only. Dogfood for a few days.
- 1%: watch error rate, Core Web Vitals, and the business metric (checkout conversion) for the flagged group against everyone else. Give it at least a day to cover different traffic.
- 10%, then 25%, then 50%: each step a day or more, each with the same checks.
- 100%: leave it a week or two as a safety net.
- Remove: delete the flag and the old code path.
Some tools automate the checks as guardrails: if the error rate for the flagged group rises above a threshold, the rollout pauses or reverts on its own. This is where the RUM and error tracking lessons connect: both need the flag values attached to every event, or you can't compare the groups.
Check your understanding · Exercise 6
Your team uses OpenFeature with vendor A and plans to move to a self-hosted flagd. What changes? Select all that apply.
Kill switches and incident response
A kill switch is an ops flag that turns a feature off when it is hurting the product. It is the fastest fix in your toolbox: no code, no build, no deploy. But it only works if you plan it before the incident, because at 03:00 nobody has time to add a flag.
What deserves a kill switch
- New code paths in their first weeks: every release flag doubles as a kill switch until it is removed.
- Expensive extras that the core task doesn't need: recommendations, live presence, rich previews, chat widgets. Turning them off sheds load from struggling backends.
- Third-party scripts: a slow or broken vendor script can block the main thread or throw on every page. A flag that stops loading it is a lifesaver.
- Aggressive clients: the polling interval, retry count, or prefetch setting. Raising a poll from 5 to 60 seconds can cut traffic to an overloaded service by over 90%.
How a kill switch plays out
Back to Thursday. The error tracker alerts at 14:20: payment errors for the newCheckout=true group are 8 times higher than usual. On-call checks the dashboard, sees that the flagged group is the only one affected, and sets the flag's enabled to false.
The flag service pushes the change over its stream. Server SDKs typically pick it up within a second, open browser tabs within a couple of seconds, and new page loads get it right away.
Within a minute, error rates are back to normal. The team then fixes the bank-specific bug at a normal pace, ships it behind the same flag, and turns it back on at 1%. Compare that with a 30-minute rollback that also removes unrelated fixes.
Designing for off
A kill switch is only safe if the off state is safe. Before you rely on one, check:
- Does off work mid-session? A user in step 3 of the new checkout should finish or be moved cleanly, not hit an error because the old flow can't read the new cart format.
- Is the data compatible? If the new code writes data the old code can't read, turning the flag off breaks things. Keep writes backward compatible until the flag is gone.
- What happens when the flag service is down? The SDK must fall back to its last known values, then to the defaults in code. The flag system should never be a single point of failure for your app.
- Has it been tested? Flip each kill switch in staging, ideally on a schedule, the same way you test backups.
Who changed what, when
A flag change is a production change, so treat it like one. Good flag systems keep an audit log (who changed which rule, from what, to what), require approval for flags in sensitive areas, and post every change to the incident channel. Put flag changes on the same timeline as deploys in your monitoring dashboards. When a graph spikes at 14:07, the first question is "what changed at 14:07?", and a flag flip is often the answer.
Check your understanding · Exercise 7
The recommendations service is overloaded and slowing every product page. You have a recommendationsEnabled flag. What makes flipping it safe and effective? Select all that apply.
Testing combinations and paying off flag debt
Every live flag is a fork in the code. Ten boolean flags in one area give 2 to the power of 10, or 1,024, possible combinations, and you will never test them all. Two habits keep this under control: test the combinations that matter, and keep the number of live flags small.
Which combinations to test
- Production today: the exact values most users have right now. This is your main CI run.
- Production next: today's values with the flags you are about to ramp turned on. This catches the release you are about to make.
- All off (defaults): what users get if the flag service is down. It must work.
- Each flag alone, both ways in unit and component tests for the code it touches. Most flags touch one area and don't interact.
- Known pairs: when two flags touch the same component or data, test the four combinations explicitly. Pairwise testing tools can pick a small set that covers every pair of values.
Make flags easy to override in tests. With OpenFeature, an in-memory provider lets a test set { newCheckout: true } without any network. In end-to-end tests, a query parameter or cookie that sets flags (only accepted in test and staging environments) lets you load the page in any state.
import { InMemoryProvider, OpenFeature } from "@openfeature/web-sdk";
for (const on of [false, true]) {
test(`checkout submits with newCheckout=${on}`, async () => {
await OpenFeature.setProviderAndWait(new InMemoryProvider({
newCheckout: { variants: { on: true, off: false },
defaultVariant: on ? "on" : "off", disabled: false },
}));
renderCheckout();
await user.click(screen.getByRole("button", { name: "Pay" }));
expect(await screen.findByText("Order confirmed")).toBeVisible();
});
}
Flag debt
A release flag at 100% for three months is pure cost. The old branch is dead but still compiled, shipped, and read by every engineer who opens the file. Worse, someone may flip it by mistake, sending everyone back to code that hasn't run in months. This is flag debt, and every team that adopts flags runs into it.
There is a well-known warning story. In August 2012, Knight Capital deployed new trading code that reused an old flag, but one of its eight servers missed the deploy and still had long-dead code behind that flag. Turning the flag on woke that code up, and the firm lost more than 460 million dollars in about 45 minutes, according to the SEC. The lesson for frontend teams is the same at smaller scale: delete dead flags, and never reuse a flag name.
Habits that keep flags few
- Expiry date at creation: release flags get one (often 30 to 60 days). Past it, the dashboard warns, CI fails, or a ticket is created for the owner.
- Owner on every flag: a team, not a person who might leave.
- Removal ticket in the same sprint as the flag: the cleanup is part of the feature, not a later chore.
- Typed keys and lint rules: flag keys must come from a typed list (
flags.newCheckout, not the string"new_checkout"scattered around), so a search finds every use and a removed key fails the type check. - Stale-flag reports: list flags that have served one value to 100% of traffic for 14 days, or that no code references any more.
- A flag budget: some teams cap live release flags per team, which forces cleanup before new flags.
Removing a flag means deleting the losing branch, the flag read, its tests, and finally the flag in the dashboard, in that order. Delete it from the dashboard last: if code still reads a flag that no longer exists, it silently gets the default, which might be the old UI.
Check your understanding · Exercise 8
Which practices reduce flag debt? Select all that apply.
Security, performance, and privacy
Never gate access on a client flag
Everything that runs in the browser is under the user's control. They can edit localStorage, change a variable in DevTools, block the flag request, or replay an API call with a different body. So a flag evaluated in the browser can decide what to show, but never what a user is allowed to do.
Example: exportToCsv is a Business-plan feature. The frontend hides the Export button for free users, which is good UX. But the POST /reports/export endpoint must check the plan on the server for every request.
If the endpoint trusts that "the button was hidden", any free user with curl gets the feature. The rule: the server enforces, the client reflects.
isPremium sent by the browser.What leaks through flags
- Code and copy of unreleased features, if they are in the main bundle. Use code splitting or server rendering for anything secret.
- Flag names themselves:
enableMergerAnnouncementtells a story. Use neutral names for sensitive launches. - Targeting rules, if the client SDK downloads them: lists of customer emails or account ids. Prefer SDKs that return evaluated values only.
- The client-side SDK key: it is public by design, unlike the server key. Make sure the client key can only read evaluated values, never edit rules or list all flags.
Performance costs
A client SDK is often roughly 10 to 40 KB of compressed JavaScript, depending on the vendor, plus a blocking request if you wait for it before rendering. Load it once, start it from bootstrapped values, and never let it hold back the first paint for more than a short timeout. Dead branches add bytes too: a release flag left at 100% keeps both versions of the component in the bundle.
Flags can also break caching. If the server renders per flag combination, the CDN can't share one cached page. Keep flags that change cached HTML to a minimum, use the edge to pick between a small number of cached variants, and evaluate the rest on the client below the fold.
Never add a flag value to a cache key casually: five boolean flags multiply one cached page into 32. And don't reach for Vary: Cookie, which gives every visitor their own cache entry; let the edge map the bucket cookie to a short variant name and key on that.
Privacy
The evaluation context often holds personal data: email, country, plan. When the client SDK sends it to a vendor, that vendor is now processing personal data. Send the minimum (a hashed or opaque id and the attributes rules actually use), and check whether your privacy policy and consent banner cover it. Exposure events for experiments can count as analytics under consent rules in some regions.
Check your understanding · Exercise 9
Which uses of client-evaluated flags are safe? Select all that apply.
In real products and in the interview
How teams use flags
Flags are standard practice at any company that ships many times a day. Facebook (now Meta) has described Gatekeeper, its internal system for gating features by user groups and percentages, in engineering talks and posts, along with rolling changes out to employees first, then small slices of real users. Many large web teams follow the same shape: employees, then 1%, then wider, with automatic checks between steps.
On the tooling side, the market splits into hosted services (LaunchDarkly, Statsig, Optimizely, ConfigCat, PostHog), open-source servers you can self-host (Unleash, Flagsmith, GrowthBook, flagd), and framework helpers such as the Flags SDK from Vercel for Next.js and SvelteKit, which evaluates flags on the server and can precompute flag combinations so each variant of a page is rendered statically. Many vendors now publish OpenFeature providers.
Mobile apps lean on flags even harder, because a new app build can take days or weeks to reach most users through store review and updates. Remote config there plays the role of the kill switch: the code is already on millions of phones, and the flag is the only fast lever.
When to bring flags up in an interview
Flags fit naturally in three places in a frontend system design answer. In the rollout plan at the end ("I'd ship it behind a release flag, ramp 1% to 100%, and watch errors and LCP for the flagged group"). In reliability ("recommendations and live presence get kill switches so we can shed load"). And in rendering decisions ("flags that change the first screen are evaluated on the server and bootstrapped, so there is no flicker").
| Level | What a strong answer shows |
|---|---|
| Junior | Explains deploy vs release; puts risky features behind a flag; knows a flag can be turned off without a deploy |
| Mid | Names flag kinds; knows sticky bucketing; avoids flicker with SSR bootstrap or cached values; knows client flags are not security |
| Senior | Chooses server, edge, or client evaluation per flag; plans streaming for kill switches; ties rollout steps to RUM and error metrics; plans cleanup |
| Staff | Sets org rules: flag ownership, expiry, audit and approvals, OpenFeature to avoid lock-in, flag changes on the incident timeline |
What to draw: a small box for the flag service with a stream to both the server and the browser, an arrow showing values bootstrapped into the HTML, and a note that the API enforces permissions on its own. That one picture answers the three follow-ups interviewers ask most: flicker, kill switches, and security.
Think first
You are designing a news site's new article page. The page is SSR and heavily cached at the CDN. Product wants to roll out the redesign gradually and also test two headline layouts. How do you deliver the flags?
The redesign changes the whole first screen, so it must be decided before HTML reaches the browser. Evaluate it at the edge: hash a first-party cookie id into a bucket and route to one of two cached versions of the page. That keeps CDN caching with just two copies.
The headline experiment also affects the LCP element, so it goes through the same edge step. With both flags, that is four cached variants, which is still fine; I'd avoid adding more page-level flags to the edge.
Everything below the fold, like the new comments widget, is evaluated on the client after load, inside a space-reserved container so it can't shift the layout.
Kill switches for comments and recommendations come over a stream so open tabs react in seconds. Exposure events and errors carry flag values, and the redesign flag gets an owner and an expiry date.
Summary
- A feature flag separates deploying code from releasing it, so you can release gradually, to chosen users, and turn things off in seconds.
- Name the kind: release, experiment, ops (kill switch), or permission. Kind decides lifetime, owner, update speed, and where it may be evaluated.
- Percentage rollouts hash the flag key plus a stable user id into a bucket, so assignment is sticky and ramps only ever add users.
- Flags that change the first screen should be evaluated on the server or edge and bootstrapped into the HTML; client-only evaluation flickers and shifts layout.
- A flag hides UI, not code: unreleased features in the main bundle are public. Split them into lazy chunks or render them on the server.
- Kill switches need a designed off state, a safe default, and live delivery (streaming or short polling) to reach open tabs.
- Client flags are never security: the server enforces permissions, the client reflects them.
- Every release flag is debt from day one: give it an owner, an expiry date, and a removal ticket.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
What is the difference between deploying and releasing, and why do feature flags matter for it?
Deploying puts new code on servers and in the bundle; releasing turns it on for users. Without flags they happen together, so every deploy is a risky all-or-nothing release.
A flag lets you deploy the code switched off, then release gradually to employees, 1%, 10%, and so on, watching errors and metrics at each step, and turn it off in seconds without a new deploy.
It also enables trunk-based development: unfinished work merges daily behind an off flag instead of living on a long branch.
Question 2
Our SPA shows the old banner for half a second, then switches to the new one. Why, and how would you fix it?
The client SDK fetches flag values after the app renders, so the first render uses the default and the real value arrives later. That causes flicker and layout shift.
Best fix: evaluate on the server during SSR and embed the values in the HTML, so the client's first render matches. Without SSR: cache last values in localStorage and render from them on repeat visits, revalidating in the background.
Also: a safe default in code, a short timeout after which you stay on the default for this page, and reserved space so a late change can't move the layout.
Question 3
How does a percentage rollout keep the same user in the same group?
It hashes a stable id together with the flag key, for example newCheckout:u_42, into a bucket from 0 to 99 (or finer), and serves the feature if the bucket is below the rollout percent.
The hash is deterministic, so every server and browser computes the same bucket with no stored state. Raising the percent only adds users; nobody already in drops out.
Including the flag key means each flag picks a different slice of users. For signed-out visitors, a first-party cookie id is the usual key.
Question 4
Can we use feature flags to give premium features to paying users?
For display, yes: a permission flag can hide or show premium UI. For access, no: anything evaluated in the browser can be changed by the user.
The server must check the entitlement on every request to a premium API. The client flag only reflects what the server enforces.
Long-lived permission flags should usually move into the real plan or entitlement system, which the backend owns.
Question 5
How would you keep flags from piling up?
Tag every flag with its kind, an owning team, and for release and experiment flags an expiry date. Create the removal ticket with the flag.
Use typed flag keys so every use is searchable and a deleted key fails the build; report flags that have served one value to all traffic for two weeks.
Remove in order: delete the losing code path and tests, then the flag read, then the flag in the dashboard. Never reuse an old flag name.
Practice it
These design problems lean on feature flags.
References
- Feature Toggles (aka Feature Flags), Pete Hodgson The classic taxonomy of release, experiment, ops, and permission toggles.
- OpenFeature documentation The vendor-neutral flag evaluation API, providers, hooks, and context.
- OpenFeature specification The formal spec, including the static context model for client SDKs.
- Cumulative Layout Shift (CLS), web.dev Why late UI changes hurt users and how CLS is measured.
- Unleash: activation strategies How an open-source flag server does gradual rollouts and stickiness.
- SEC order: Knight Capital Americas LLC (2013) The official account of the 2012 incident caused by a reused flag and an incomplete deploy.
- Flags SDK by Vercel: precompute Evaluating flags on the server while keeping pages static, one prerendered copy per combination.
- Trunk Based Development: feature flags How flags let unfinished work merge to main safely.
Read next
- A/B testingShowing different versions of a page to randomly split groups of users and measuring which works better: stable bucketing, no flicker, honest statistics, and guardrails that catch harm.
- Real-user monitoring (RUM)Measuring speed on your real users' devices and networks: collecting Web Vitals and Performance API timings, sending them safely, and reading them as percentiles by device, network, country, and page to find what an average hides.
- Error trackingCatching the errors that happen in users' browsers, decoding minified stacks, grouping thousands of reports into a few issues, and alerting when a release makes things worse: how you learn the app is broken before support does.
- Logging in the browserSending small, structured records of what happened in each user's browser to your own servers, in batches that survive the tab closing, sampled and scrubbed so they stay cheap and safe: the only way to see bugs that never happen on your machine.
- Server-side renderingThe server builds the full HTML for each request, so users see content before any JavaScript runs, then the browser hydrates it: faster first paint and better SEO, paid for with server work, a later first byte, and a second render in the browser.
- A frontend testing strategyHow to split a frontend's tests between static checks, unit, integration, and end-to-end tests, so they catch the bugs that matter without making every change slow.