ProductionGENERALFRAMEWORK-SPECIFICPLATFORM-SPECIFIC

Feature Flags in the Client

Evaluating a flag in a browser: the three states every flag has, the flicker while it loads, the flagged-off code still sitting in the bundle, and the rule that a flag is never an authorization boundary.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

What changes when a feature flag is evaluated in a browser instead of on a server?

The user intent

A person wants a coherent interface that does not rearrange itself under them. A team wants to merge unfinished work without a long-lived branch, and to turn something off without a deploy.

The obvious build

Wrap the new UI in if (flags.newCheckout), fetch the flags on boot, and read them wherever they are needed. It is a boolean.

Why it breaks

The flags arrive asynchronously, so the first paint renders the off branch and then flips. The user sees the old checkout, then the new one, and the page reflows underneath whatever they were about to click (Visual Stability).

How it breaks in a real browser
  • The flags arrive asynchronously, so the first paint renders the off branch and then flips. The user sees the old checkout, then the new one, and the page reflows underneath whatever they were about to click (Visual Stability).
  • A boolean has two states; a flag read in a browser has three — loading, on, off — and treating undefined as falsy silently makes "off" the behaviour of every network hiccup.
  • The flagged-off branch is still in the bundle. It was downloaded, parsed and compiled; it is simply not executed. It is therefore neither a secret nor a size saving (Bundle Analysis).
  • The flag service is a third-party dependency on your critical path. If its script fails or its response is slow, you need a defined default and you almost certainly have not written one down (Third-Party Scripts and the Supply Chain).
  • A flip lands mid-session and half the screen is now on the other branch: a header from the new design, a form from the old one, and a state shape neither of them fully expected.
  • Six flags is sixty-four possible interfaces. Nobody tested sixty-four interfaces, and the combination that breaks will be one nobody thought could co-occur.
  • Someone gates a privileged feature behind a flag. The code, the endpoint paths and the check are all in a bundle the user can read and edit (The Browser Security Model).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • A flag system has three separable parts: the definition (a rule owned by a service), the evaluation (where that rule is executed against a subject), and the consumption (a component branching on the result). Almost every client-side flag problem is a consequence of where evaluation happens.
  • Client-side evaluation ships the rules and the targeting payload to the browser and runs them there. It is flexible, it works without a server render — and it means the rules, the variant names and often the segment definitions are public.
  • Server-side evaluation decides before the response is produced and sends the client a decision, not a rule. It removes the flicker and the exposure, and it makes the document less cacheable because the response now varies per subject (Server-Side Rendering).
  • Assignment is normally a deterministic hash of a stable subject key plus the flag key, bucketed into variants. Determinism is the whole point: the same user gets the same variant on every request, in every tab, without storing anything.
  • A flag value is server-owned state with a freshness policy and a cache, not a constant. Every question you would ask about a cached API response — how stale may it be, what happens when it cannot be fetched, does it change under a mounted component — applies unchanged (Server State Is Not Your State).
  • "Off" in a client means "not executed". It never means "not shipped". Removing code from the download is code splitting, which is a different mechanism you may combine with a flag but do not get for free from one (Code Splitting).

What this makes the browser do

And which of it is avoidable.

  • An extra request, and often an extra third-party script, before the application can decide what to render — on the critical path unless the decision is inlined into the document.
  • Parsing and compiling both branches of every flag, because both were shipped. Twenty stale flags is twenty pairs of code paths in every user's download (The Real Cost of JavaScript).
  • A re-render of the flagged subtree when the value resolves, plus whatever style, layout and paint that subtree's change actually costs (The Cost of a Change).
  • The SDK's own steady-state work: a polling interval or a streaming connection kept open to receive flips, on the same main thread as everything else (What the Main Thread Owns).
  • Storage writes if the SDK caches the last known payload — usually a good idea, and a place where an old build's cached flags can outlive the deploy that changed them (localStorage and sessionStorage).

Every flag has three states

The single most useful correction to the naive model is that a flag read in a browser is not a boolean. It is an asynchronous, cacheable, server-owned value that is sometimes not there yet and sometimes cannot be fetched at all — and each of those is a distinct rendering decision, not an error case to ignore.

Writing the third state down changes the code you produce. flags.newCheckout && <NewCheckout/> collapses "we do not know yet" and "no" into the same pixels, which is what produces the flip. Once loading is a real state you have to answer a real question: what does a user see while we find out — the old branch, a placeholder, or nothing — and that answer belongs to the product, not to a truthiness rule.

  • `loading` — the value is not known. Render a placeholder, or the branch you would default to, but decide which on purpose.
  • `resolved` from the service — the real assignment. The only state that should produce an exposure event.
  • `resolved` from cache — a previous session's value. Fine, and it means a kill switch has not reached this client yet.
  • `resolved` from default — the service could not be reached. The most important state to get right and the least often tested.
  • Frozen — the value this unit of work will use regardless of what happens next, which is what keeps one screen on one branch.
Three states, an explicit default, and a value frozen for the route
1type FlagState<T> =
2 | { status: 'loading' }
3 | { status: 'resolved'; value: T; source: 'service' | 'cache' | 'default' }
4
5// Defaults live with the flag definition, not at the call site,
6// and they answer one question: what is correct when the flag
7// service is unreachable?
8const FLAGS = {
9 newCheckout: { default: false, critical: false },
10 euPricingRules: { default: true, critical: true }, // legally required: fail on
11 experimentalSort: { default: false, critical: false },
12} as const
13
14// Read once when the route is entered; hold it for the whole flow.
15// A checkout that re-reads the flag between steps is a checkout
16// that can change design halfway through.
17function useRouteFlag<K extends keyof typeof FLAGS>(key: K) {
18 const frozen = useRef<FlagState<boolean>>({ status: 'loading' })
19 const live = useFlagClient(key)
20 if (frozen.current.status === 'loading' && live.status === 'resolved') {
21 frozen.current = live
22 }
23 return frozen.current
24}
25
26// At the call site the third state is unavoidable — which is the point.
27const flag = useRouteFlag('newCheckout')
28if (flag.status === 'loading') return <CheckoutSkeleton/>
29return flag.value ? <NewCheckout/> : <LegacyCheckout/>

source is not decoration. "This user got the default because the SDK timed out" and "this user was deliberately assigned false" are different facts, and only one of them is evidence in an experiment (Analytics Events That Answer a Question).

Flicker is a rendering problem, not a flag problem

The flip that users see is ordinary pipeline work triggered at an unfortunate moment. The flag resolves, a subtree changes, and the browser does whatever that change costs — recomputing style, redoing geometry for everything after it in flow, repainting. The flag did not make it expensive; the shape of the change did, and the fix is the same as for any other late-arriving content (The Cost of a Change).

Which is why the mitigations are the familiar ones. Reserve the space so a branch swap does not move what is below it. Prefer flags that change content within a fixed box to flags that change the box. And for anything that alters the first paint, do not race the network at all — decide before the HTML exists.

What a flag resolving actually costs the browser
ChangestylelayoutpaintcompositeWhy
Subtree swapped for the other branchyesyesyesyesNew elements need computed styles, their geometry is unknown, and everything after them in normal flow may move. This is the most expensive and most visible form of a flip.
Flag toggles a class that changes only `color`yesnoyesnoA paint-only property: the box does not change, so geometry is untouched and only the affected paint area is redrawn (Cheap and Expensive Animation).
Flag toggles `display: none` to `block`yesyesyesmaybeThe element re-enters flow, so its own geometry and its siblings' positions are computed for the first time. Whether a new compositing layer is involved depends on what the revealed content contains.
Flag gates a lazily imported componentmaybemaybemaybemaybeNothing costs anything until the chunk arrives; then the full mount happens at whatever moment the network delivers it, which is later and less predictable than a flip of already-loaded code (Lazy Loading).
Flag read, but both branches render identical DOMnonononoThe framework may still re-render. A re-render that produces the same DOM costs script time and nothing downstream — which is why "it re-rendered" and "it was expensive" are different claims (What a Component Costs to Render).
Content hidden until flags resolve (anti-flicker)yesyesyesnoThe shift has not been removed, it has been moved to before the first paint — and the page is now blank for as long as the flag service takes. Reserving space is usually the better trade (Visual Stability).

caveat The maybe rows depend on what else is on the page: whether the flipped subtree is inside a containment boundary, whether it is already promoted to its own layer, and whether anything after it in flow is size-dependent. Measure the actual change in the Performance panel rather than reasoning from the property name (CSS Containment).

The flicker window, client-evaluated versus server-evaluatedrelative units — ordering and overlap only, nothing here was measured
Document
App bundle
Parse + execute
First paint: OFF branch
Flag request
Re-render flagged subtree
Layout + paint: ON branch
— server-evaluated: decision inlined in the document
— server-evaluated: first paint is already correct
  • First paint: OFF branchthe user can read it and start reaching for a control
  • Flag requesta third party, on a network you do not control
  • Layout + paint: ON branchthe flip — content moves under the pointer
  • — server-evaluated: first paint is already correctno second branch, no flip, no reflow

The gap between the first paint and the flip is the entire problem, and it widens exactly where you have least control: a slow network, a cold connection to the flag vendor, or a device slow enough that the re-render itself is visible.

A rollout control, never an authorization boundary

This is the rule the lesson exists for. A feature flag answers "should this cohort see this yet". It cannot answer "may this user do this", because it is evaluated inside a runtime the user owns, from code the user can read, against a payload the user can inspect and modify. There is no version of a client-side flag check that survives someone opening devtools.

The confusion is understandable, because the two look identical in a component: both are a boolean guarding a control. They differ entirely in what happens when the boolean is wrong. A wrong flag shows a user an unfinished feature. A wrong permission check shows a user someone else's data — and the flag never protected the endpoint in the first place, only the button that called it (What the Frontend Is Responsible For in Auth).

Flag failures a browser actually produces
TriggerSymptomCauseResponse
Flag SDK request fails or times outA cohort of users silently gets the default configurationA third-party dependency on the critical path with no written defaultCompile a default in per flag, chosen for the unreachable case; cache the last known payload; alert on the default rate.
Flag resolves after first paintThe interface flips and content jumps under the pointerThe decision raced the renderEvaluate server-side for anything above the fold; otherwise reserve space and render a placeholder for the loading state.
Flag flipped mid-sessionA multi-step flow changes shape between stepsThe value was read per render rather than frozen per unit of workFreeze on entry to the flow; apply flips on the next navigation (Route Loading Boundaries).
Flag used as a permission checkAn endpoint is reachable by anyone who reads the bundleA client-evaluated boolean treated as a boundaryEnforce on the server per request; render from server-supplied capabilities (What the Frontend Is Responsible For in Auth).
Flag deleted from the service, branch left in codeBehaviour changes with no deploy and no flag flip anyone remembersAn unknown key resolving to whatever the SDK returns for oneRemove the branch in the same change as the flag; fail loudly on an unknown key in non-production builds.
Two flags interactA screen that only breaks for the small cohort in both cohortsA combination nobody enumerated or testedCap interacting flags per surface, enumerate the reachable combinations, and test those (Choosing the Test Level).
Flag flip and deploy in the same windowA metric moves and the cause is unresolvableTwo independent changes with one timestampSeparate them in time and mark both distinctly on dashboards (Release Health).
Two booleans that look the same and are not
A flag standing in for a permission
// "Only enterprise customers can export."
if (flags.enterpriseExport) {
  return <ExportButton/>
}
return null

// The user edits the cached flag payload, or calls
//   POST /api/reports/export
// directly — the path is in the bundle they downloaded.
// Nothing in this code was ever a boundary.
A flag for rollout, the server for authority
// The flag decides whether the feature exists for this cohort.
// The server decides, per request, whether this user may export
// this report — and says so in the response it already sends.
if (!flags.exportRedesign) return <LegacyExport/>

return report.can.export
  ? <ExportButton/>
  : <ExportButton disabled reason={report.can.exportDeniedBecause}/>

// POST /api/reports/:id/export re-checks authorization
// regardless of what the client rendered. The UI is a
// convenience; the check is the boundary.

The two booleans have different owners and different failure consequences. Rollout state is a product decision that may safely be wrong; authorization is a security decision that must be enforced where the user cannot reach it, which is never the browser (Authorization-Aware UI).

How to build it

Most important first.

  • Decide where each flag is evaluated by what it affects. If it changes the first paint, evaluate it server-side and inline the decision; if it only affects an interaction deep in the app, client-side evaluation is fine and much simpler.
  • Model three states explicitly. loading | on | off, with a default that is *correct when the flag service is unreachable*. Write the default down next to the flag, not in a ternary at the call site.
  • Freeze the value for a coherent unit of work. Read the flag once per session or per route entry and hold it; re-evaluating per render is what produces a screen that is half one branch and half the other.
  • Give every flag an owner and a removal date. A flag is a temporary branch in production; the cleanup is not optional work, it is the second half of the change (Over-Componentization).
  • Delete the branch, not just the value. Turning a flag on permanently and leaving the else in the bundle keeps shipping dead code to every user and keeps a wrong path alive for anyone who can flip it back.
  • Never let a flag decide whether an action is permitted. It decides whether a control is *shown*. The server decides whether the action is allowed, on every request, regardless of what the client rendered (Authorization-Aware UI).
  • Keep interacting flags few and named. If two flags touch the same screen, test the four combinations you can actually ship, and be able to say which are reachable.
  • Separate flag flips from deploys in time, so that when something regresses you can attribute it to one of them (Release Health).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • A flag-driven change landing under a user mid-session is disorienting in a way that is easy to under-rate. Controls move, the tab order changes, and the thing someone was reaching for is somewhere else — which for a keyboard or screen-reader user means starting the navigation over (Keyboard Operability).
  • If the flagged element that changes contained focus, focus can be destroyed entirely and land on body. Decide where it goes before the swap, and put it somewhere meaningful (Focus Management).
  • Flicker is a layout shift with an accessibility cost as well as a visual one: for someone using screen magnification, a reflow moves the viewport's content out from under them and there is no cheap way to find their place again (Visual Stability).
  • If a flag changes the interface substantially while the page is open, say so — a single polite announcement, not a re-render nobody is told about (Live Regions and Announcement).
  • Both branches owe the same accessible experience. A flagged-off fallback with worse semantics, or a flagged-on variant that was never tested with a keyboard, ships a degraded product to a specific cohort and will not show up in any aggregate metric (Accessibility Testing).

What can go wrong

Failure modes
  • The flag SDK fails to load and every flag falls back to its default. If a default was chosen carelessly — or was implicitly "off" because undefined is falsy — an outage in a third-party service becomes a product change for everyone.
  • The anti-flicker mitigation is to hide the page until flags resolve, which converts a visible flip into a blank screen whose length is set by someone else's uptime.
  • A flag is deleted in the service but its branch remains in the code, so the code silently takes whatever the SDK returns for an unknown key — which is not necessarily the branch you assumed.
  • Targeting on an attribute the client supplies. A user who edits their own profile field, or their own local storage, moves themselves into a cohort.
  • A flip lands between step two and step three of a checkout, so the flow the user started no longer exists.
  • An experiment and a deploy ship in the same window and the metric moves. Nobody can say which caused it, and the argument is unresolvable after the fact.
  • Flags accumulate. A codebase with eighty live flags has an untestable number of configurations and no one who can enumerate them.
What can arrive out of order
  • The flag response racing the first render — the flicker case, and the one that is always present unless the decision is inlined.
  • A flip landing between two components rendering, so one screen shows both branches.
  • Two tabs open across a flip, holding different frozen values, writing to the same persisted state (Persistent Client State).
  • An exposure event fired before the assignment payload has arrived, attributing the user to the default variant.
  • A flag flip and a deploy overlapping, which is not strictly a race but is indistinguishable from one in the data.
Security
  • Everything in the bundle is public: flag keys, variant names, the flagged-off code, and the internal endpoints that code calls. A flag hiding an unreleased feature is a naming exercise, not concealment (The Browser Security Model).
  • A client-evaluated targeting payload can leak more than the flag: segment definitions, customer names, internal rollout stages and unreleased product vocabulary all commonly sit in it.
  • A flag is never an authorization boundary. It is evaluated in a runtime the user controls, in code the user can edit, against a rule the user can read. Whether this user may perform this action on this record is a server decision, made per request (What the Frontend Is Responsible For in Auth).
  • Targeting attributes sent to a flag vendor are personal data leaving your systems. A user id, an email domain, a plan name and an IP-derived region are a profile, and their collection carries the same obligations as any other (Analytics Events That Answer a Question).
  • The SDK is a third-party script running with your page's full authority — same origin, same DOM, same cookies. Its supply chain is your supply chain (Third-Party Scripts and the Supply Chain).
Misreads
  • "Flagged off means not shipped." It shipped. It is in the download, it was parsed, and anyone can read it and call the endpoints it references.
  • "A flag can hide a paid feature until the user upgrades." It can hide the button. The endpoint is still there, and only the server can refuse the call (Authorization-Aware UI).
  • "Flags reduce risk." They move it. Deploy risk goes down; configuration risk, combinatorial risk and cleanup debt go up — and configuration changes usually get less review than code does.
  • "We can turn it off instantly." You can turn it off instantly for clients that ask again. Sessions holding a cached or frozen value keep the old behaviour (Long-Lived Clients and Version Skew).
  • "Removing the flag is a cleanup ticket." It is the second half of the feature; a flag left in permanently is a branch in production that no one owns.
  • "The experiment moved the metric." A deploy in the same window moves metrics too, and so does the day of the week (Regression or Tuesday? Telling a Real Change from Noise).

Measuring it, and what changes in the field

How you would see this
  • Distinguish assignment from exposure. A user assigned to a variant who never reached the screen is not evidence about anything; the exposure event is the one that belongs in the analysis (Analytics Events That Answer a Question).
  • Error rate segmented by variant, per flag. A flag that only breaks for a cohort is invisible in the aggregate and obvious in the split (Frontend Error Tracking).
  • Layout instability measured on the flagged subtree specifically, as the direct signal for flicker (Vitals in the Field).
  • Flag age: the count and the oldest. It is the cleanest measure of whether the cleanup half of the practice is actually happening.
  • Flag-flip markers on dashboards, kept distinct from deploy markers, so the two can be told apart (Real User Monitoring).
  • Reachability: which combinations of interacting flags actually occurred in the field, which is usually far fewer than the theoretical number and worth knowing before a test matrix is designed.
Slow device, slow network, large data, old tab
  • On a slow network the flicker window is not a blink — it is long enough to read the old interface, start reaching for a control, and have it move.
  • With server-side rendering the flag can be evaluated before the HTML exists, which removes the flicker entirely and costs you a document that can no longer be cached identically for everyone (Choosing a Rendering Strategy).
  • Offline, or when the flag service is unreachable, only the cached payload and the compiled-in defaults exist. That is the configuration you should design first, not last (Offline UX).
  • On a low-end device the shipped-but-unused branches are a real parse and compile cost paid by every user on every cold load (The Real Cost of JavaScript).
  • In a long-lived tab a flag flip either never arrives — the value was read once at boot — or arrives hours into a session, mid-task. Both are correct behaviours of different designs, and you should know which one you built (Long-Lived Clients and Version Skew).
What this costs
  • Decoupling deploy from release is the whole benefit, and it is paid for with a permanent branching cost: two code paths, two test paths, and a configuration surface that is not in your repository.
  • Server-side evaluation removes flicker and exposure at the cost of per-subject responses, which weakens edge caching and couples document rendering to flag availability (CDN Delivery).
  • Bootstrapping flags into the document removes the extra round trip and makes the document's time-to-first-byte depend on the flag service being healthy.
  • Freezing values per session gives a coherent interface and means a kill switch does not actually reach existing sessions until they reload — which is precisely the thing you wanted a kill switch for.
  • Deterministic bucketing gives stable assignment without storage, and makes re-bucketing a user (to debug their experience) something you cannot do by hand.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe three-state model, the flicker mechanism and the authorization rule follow from where the code runs and hold for every flag vendor and every home-grown implementation. Only the SDK surface differs.
  • FRAMEWORK-SPECIFICHow a flip propagates depends on the reactivity model: a React app usually re-renders a subtree from a context value, a signals-based framework updates only the expressions that read the signal, and a compiled framework may have narrowed it further at build time. The flicker window is the same; the amount of re-render work it triggers is not (Reactivity Models).
  • PLATFORM-SPECIFICWhether server-side evaluation is available at all depends on your rendering strategy — a purely static build has no per-request moment in which to evaluate anything, so a statically hosted app is pushed toward client-side evaluation and must design around the flicker rather than removing it (Static Site Generation).

Where the depth lives

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

Domains that do not exist yet
  • Backend Engineering owns rollout mechanics — percentage rollouts, kill switches, targeting rules and the flag service itself. This lesson is deliberately only about what changes when the evaluation happens inside a browser: the three states, the flicker, the shipped-but-unexecuted branch, and the boundary a client-side check can never be.
  • Testing & Reliability Engineering — the combinatorial test problem a flag matrix creates, and how progressive delivery practices bound it.