cf.completefrontend
My progress
SYSTEM DESIGN · #65

Design a feature flag SDK

A small client library that tells a React app which features are on for this user. Reading a boolean is easy; the real work is where flags get evaluated, showing the right value on the first frame, keeping each user in the same rollout bucket, pushing kill switches live, logging exposures that experiments can trust, and never crashing the app.

The question

Our product teams want to ship every new feature behind a flag: roll it out to 1 percent of users, then 10, then everyone, run A/B tests, and turn a broken feature off in seconds without a deploy. Design the client SDK our React app uses to read those flags.

The app is a Next.js site with server rendering, about 2 million daily users on desktop and mobile web. There is already a flag service with an admin dashboard where product managers create flags and targeting rules.

Real-life examples: LaunchDarkly's JavaScript and React SDKs, Statsig's JavaScript client, Unleash's proxy and frontend client, GrowthBook's JavaScript SDK, Flagsmith's JavaScript client, In-house flag clients at large product companies

Requirements: what flags are for and what the SDK must do

A feature flag is a named switch that decides, at run time, whether some code path is on or off for a given user. A flag SDK (software development kit) is the small library inside the app that answers the question is checkout-v2 on for this person? quickly, correctly, and the same way every time. The flag service and its admin dashboard already exist; we are designing the part that lives in the browser and in our server-rendering layer.

It sounds like a single if statement around a fetch call, which is why the question is popular. The naive version shows the old checkout for half a second and then swaps to the new one. It moves a user from the test group on Monday to the control group on Tuesday, and ships beta customers' emails to every browser. It logs that 2 million people saw an experiment that only 80,000 actually opened, and takes the whole page down when the flag service has a bad minute.

A strong answer is about those details. Each one is a hard part of the SDK, and each gets its own chapter.

What makes the SDK hard

  • It runs before everything else. Components read flags during their first render, so flag values must be ready before React renders, or the page draws the wrong thing first.
  • It must be deterministic. The same user must get the same answer on the server, in the browser, in every tab, and on every device, or experiments become noise and hydration breaks.
  • It must be private. Targeting rules can name customers, internal plans, and unreleased features. The browser should only learn the answers for this user.
  • It must be live, but not jumpy. A kill switch should apply now. A layout experiment must not swap under someone mid-task.
  • It is measured. Experiments depend on knowing exactly who saw which variant. A wrong exposure log quietly ruins every result.
  • It must never break the app. It sits on the hottest path in the codebase. If the SDK throws, every page throws.

Five jobs behind one API

Before designing the SDK, be clear about what people will use it for. The words "feature flag" cover five quite different jobs. They share one API, but they want different things from it: how long they live, who may change them, and whether a change should reach open tabs at once. The feature-flags lesson covers the practice in general; here we only care about what each kind asks of the client.

KindExampleLives forWhat it needs from the SDK
Release togglecheckout-v2 hides unfinished code that is already deployedDays to weeksPercentage rollout with stable buckets, then removal from code
Experimentpricing-layout with variants control, a, b2 to 6 weeksStable assignment, exact exposure events, no variant change mid-session
Kill switchrecommendations-enabled turns off a slow widget during an incidentYearsLive updates within seconds, a safe default if the service is down
Operational flagsearch-debounce-ms = 200, a tunable numberMonths to yearsTyped non-boolean values, live or next-page updates
Entitlement flagbeta-reports shown to customers on the beta listMonthsServer-side evaluation, since the list is private. Never a security boundary

The last row needs a warning. Flags are good for deciding what to show, but a flag in the browser is never a permission check: anyone can open dev tools and flip beta-reports to true. The server must still refuse the beta API for users who aren't on the list. The security section of the performance chapter comes back to this.

Questions to ask

The middle column is what this guide assumes. The right column is why the answer matters to the design.

QuestionAnswer we assumeWhat it changes
Are we building the SDK or wrapping a vendor's?Building our own client for our own flag service. The same shape applies if the service is a vendor's.We design the wire format and the evaluation endpoint too, not only the React hooks.
How many flags exist, and how many does the web app read?About 600 flags across the company; about 150 are read by the web app.Payload size and the need to send only client-visible flags.
What kinds of values?Mostly booleans, some string variants like control, a, b, and a few small JSON configs.Typed flags with unions, not only booleans.
How is the app rendered?Server-rendered pages with Next.js; a few static marketing pages served from a CDN.Whether we can put evaluated flags into the HTML, which decides if the first frame is right.
How fast must a change reach open tabs?Kill switches within 5 seconds. Everything else can wait for the next page load.Some kind of push channel, and a rule for which changes apply live.
Do we run experiments on these flags?Yes, about 20 at any time; results are computed from exposure events in our analytics pipeline.Exposure logging must be exact, deduplicated, and tied to the value the user actually saw.
Who uses the SDK?About 40 frontend engineers in 7 teams.The API must be hard to misuse: typed keys, required defaults, one hook.

Functional requirements

  • Read any flag by key, synchronously, with a typed default value: useFlag("checkout-v2") returns true or false, never undefined.
  • Evaluate flags for an evaluation context: who the user is (a stable key, or an anonymous id before login) and attributes like plan, country, and app version.
  • Show the right value on the first frame for server-rendered pages: no flash of the old feature.
  • Keep the user in the same variant across reloads, tabs, and devices for percentage rollouts.
  • Apply kill switches live in open tabs; apply other changes at a safe moment.
  • Log an exposure (the fact that a user was shown a variant) once, when the value is really used.
  • Switch context on login and logout without a page reload.
  • Let tests, QA, and developers override any flag locally.

Non-functional requirements

RequirementTargetWhy this number
SDK size in the main bundleUnder 8 KB gzippedIt loads on every page, before any feature code. Every KB here delays every page.
Extra delay on server-rendered pages0 ms on the clientValues arrive inside the HTML, so the browser waits for nothing.
Client-only pages: time to real valuesUnder 300 ms at the 75th percentile. A flagged region waits at most 300 ms; the request itself is abandoned at 1,000 msA blank region longer than 300 ms starts to feel broken, and an answer later than 1 second is not worth waiting for.
Flag read costA map lookup, no network, no promiseComponents read flags during render, sometimes dozens per page.
Payload for 150 flagsAbout 9 KB raw, about 2 KB gzippedSmall enough to inline into the HTML.
Kill switch reaches open tabsUnder 5 secondsA broken checkout costs money every second it stays on.
Failure behaviorThe app never crashes because of the SDKFlags guard features; they must not become a single point of failure.

Out of scope

  • The admin dashboard where flags and rules are edited. We touch on governance later, but we don't design its screens.
  • The statistics engine that decides which experiment variant won. We only make sure its input, the exposure events, is trustworthy.
  • Server SDKs for backend services and native mobile SDKs. They share the bucketing rules, which we design, but not the React parts.

Check your understanding · Exercise 1

Which answers from the interviewer would change the SDK's design the most? Select all that apply.

Check your understanding · Exercise 2

Which statements about flag kinds are true? Select all that apply.

Where evaluation happens

Evaluation is running a flag's rules against a context to get a value. A rule for checkout-v2 might say: employees get true; users in Germany on the Pro plan get true; everyone else is in a 10 percent rollout. The biggest decision in the SDK is where that rule runs. Everything else (payload size, privacy, latency, live updates) follows from it.

The naive approach is to download all the rules and evaluate in the browser. It feels fast and simple: one fetch, then every check is local. Now look at what that payload contains for our 150 client flags.

A rule like email in [list of 3,000 beta customers] ships 3,000 emails to every visitor. A rule like plan == enterprise-unreleased tells competitors your pricing plans, and a flag named acme-acquisition-banner leaks news before the announcement. The payload also grows with the rules, not with the user: the 3,000-email list alone is about 90 KB.

Three places to evaluate flags, side by side. Server-side: the flag service runs the rules and sends only results, about 2 KB, to the browser, which reads them; private and small. Client-side: the flag service sends all rules and segment lists, 90 KB or more, to the browser, which runs them; this leaks the rules. Edge: the flag service syncs rules to an edge function near the user, which runs them and sends results to the browser; private and fast.server-sideflag servicesends resultsbrowserreads resultsprivate, smallclient-sideflag servicesends rulesbrowserruns rulesleaks rulesedgeflag servicesyncs rulesbrowserreads resultsprivate, fastresultsabout 2 KBall rules90 KBor moreedge functionruns rulesrulesresults
The question is what crosses the arrow into the browser. Results are small and reveal only this user's answers. Rules are big and reveal everyone's targeting.

Decision Where should the rules run for the browser's flags?

Server-side evaluation (remote evaluation)

The client sends its context to an evaluation endpoint, or the server-rendering layer already has it.

The flag service runs every rule and returns only the results: { "checkout-v2": true, "pricing-layout": "b" }.

This is the model of LaunchDarkly's, Statsig's, and Unleash's frontend clients.

Pros

  • Rules, user lists, and other users' data never leave the server.
  • The payload grows with the number of flags, not the size of the rules: about 2 KB gzipped for 150 flags.
  • The SDK is tiny: no rule engine, no hashing code in the bundle.

Cons

  • Changing the context (login, plan upgrade) needs a round trip to get new values.
  • Each distinct context means a fresh evaluation, so responses are hard to share in a CDN cache.

Client-side evaluation (local evaluation)

The client downloads the rule set for all client flags and evaluates locally, usually with the same hashing code as the server SDKs.

GrowthBook's JavaScript SDK works this way by default. It can encrypt the downloaded features, but the SDK must hold the key to decrypt them, so anyone can still read the rules; GrowthBook's own docs suggest remote evaluation for sensitive targeting.

Pros

  • The rule set is the same for everyone, so it caches well on a CDN.
  • Context changes are instant: re-run the rules locally, no network.
  • Works offline once the rules are cached.

Cons

  • Leaks targeting rules, attribute names, segment lists, and unreleased flag names to anyone who opens dev tools.
  • The payload grows with the rules. Big segment lists make it huge.
  • The rule engine and hashing code add to the bundle, and must match the server's bit for bit.

Edge evaluation

The rules are replicated to CDN edge locations. A function at the edge evaluates for each request and passes results to the page or to the client.

LaunchDarkly, for example, publishes edge SDKs for Cloudflare, Fastly, Vercel, and Akamai that read flag data from the CDN's edge store.

Pros

  • Fast: the edge location that serves the page also evaluates, with no trip to a distant data center.
  • Rules stay off the device, as with server-side evaluation.
  • Works for pages that would otherwise be static and shared on the CDN.

Cons

  • Another runtime to deploy and monitor, with limits on CPU time and memory.
  • Rule updates must reach every edge location; they arrive with some delay.
  • Still needs a client-side path for context changes after load.

What we pick: Server-side evaluation for everything the browser sees. Our rules contain customer lists and unreleased plan names, so shipping them is not an option, and our pages are server-rendered anyway, so the server is already there to evaluate. Edge evaluation is the add-on for the few static CDN pages. Client-side evaluation is a good fit only when every rule is safe to publish, such as a public open-source app with pure percentage rollouts.

The three placements on the concerns that matter for a browser SDK.
ConcernServer-sideClient-sideEdge
Rules visible to the userNoYes, all of themNo
Payload for 150 flagsAbout 2 KB gzipped, fixedGrows with rules: 10 KB to 100+ KBAbout 2 KB gzipped, fixed
CDN caching of the payloadPer user, so mostly noYes, one copy for allPer user, computed at the edge
Context change after loadOne round trip, 50 to 150 msInstantOne round trip to a nearby edge, usually shorter
Works offlineWith last cached valuesYes, with cached rulesWith last cached values
Bundle costSmallestAdds a rule engine and hashingSmallest on the client

One detail of server-side evaluation is easy to miss: the response must also carry metadata for each flag, not only the value. We need the variation index (which variant this is, for exposure events), a reason (rule match, rollout, default; very useful for debugging), whether exposures should be tracked for this flag, and whether it may update live. That metadata is a few bytes per flag and saves a lot of guessing later.

Check your understanding · Exercise 3

A team proposes switching to client-side evaluation to make the login transition instant. What are the real costs? Select all that apply.

The evaluation context and stable buckets

Rules need something to evaluate against. The evaluation context is everything the SDK tells the flag service about the current viewer: a stable key that identifies them, and attributes the rules can test. Getting the context right matters more than it looks, because a percentage rollout is only as stable as the key it hashes.

ts
type EvaluationContext = {
  // Stable id for bucketing: the user id when signed in,
  // the anonymous device id otherwise.
  key: string;
  anonymous: boolean;
  // Always present, even after login, to keep logged-out experiments stable.
  deviceId: string;
  attributes: {
    plan?: "free" | "pro" | "enterprise"; // also checked against the session
    country?: string;      // "DE", from the server, not trusted from the client
    appVersion: string;    // "2026.10.3"
    platform: "desktop-web" | "mobile-web";
    employee?: boolean;    // set from the session; ignored if the client sends it
  };
};
The context the SDK sends. Sensitive attributes like email stay on the server, which adds them before evaluating.

Where does the anonymous deviceId live? It must be readable on the server during server rendering, so the first HTML can be evaluated for this visitor. That rules out localStorage, which the server never sees. We store it in a first-party cookie, ff_did, set by the server on the first response with a 1-year expiry, SameSite=Lax, and Secure.

The cookie can even be HttpOnly. The browser code gets the id inside the context the server embeds in the page, and the cookie rides along on same-origin evaluation requests anyway. The client-storage lesson covers the trade-offs between cookies and web storage.

Stable percentage rollouts: hash, don't roll dice

The naive rollout calls Math.random() < 0.1 on each page load. A user then sees the new checkout on one load and the old one on the next: at 10 percent, about one load in six flips them. Storing the dice roll in localStorage fixes it on one device, but not across devices or on the server.

The fix is to make the outcome a pure function of the user. Hash the flag key, a per-flag salt, and the user key together, map the hash to a bucket from 0 to 9,999, and compare the bucket to the rollout percentage.

ts
// FNV-1a, 32 bit: tiny and well mixed for short strings.
function hash32(input: string): number {
  let h = 0x811c9dc5;
  for (let i = 0; i < input.length; i++) {
    h ^= input.charCodeAt(i);
    h = Math.imul(h, 0x01000193);
  }
  return h >>> 0; // unsigned
}

// 0..9999, so rollouts can be set in steps of 0.01 percent.
export function bucket(flagKey: string, salt: string, userKey: string): number {
  return hash32(`${flagKey}.${salt}.${userKey}`) % 10_000;
}

export function inRollout(b: number, percent: number): boolean {
  return b < percent * 100; // 10 percent means buckets 0..999
}
Same inputs, same bucket, on every machine. Vendors differ only in the details: LaunchDarkly hashes the flag key, salt, and context key with SHA-1, Unleash uses MurmurHash3, and GrowthBook uses FNV-1a.

Three properties make this work. First, determinism: the server, the browser, and a mobile app all compute the same bucket for the same user, with no shared storage. Second, monotonic growth: moving from 10 to 20 percent adds buckets 1,000 to 1,999 and keeps everyone in 0 to 999, so nobody who had the feature loses it.

Third, independence: the flag key is part of the input, so the 1 percent in the checkout-v2 rollout is not the same 1 percent in new-search. Without it, the same small group of users would try every launch first. The number of buckets only sets the smallest step: 10,000 buckets allow steps of 0.01 percent, and LaunchDarkly's 100,000 allow 0.001 percent.

The salt is a random string stored with the flag. Changing it reshuffles everyone, which is exactly what you want when restarting a broken experiment, and exactly what you must never do by accident to a running one. Multivariate flags use the same bucket: control takes 0 to 3,332, a takes 3,333 to 6,665, b takes 6,666 to 9,999.

Flag
Assign by
Page load 1
Twenty users and their buckets for checkout-v2, assigned by hash of flag key, salt, and user key. The rollout is 25 percent, so buckets below 2500 get the feature. 6 of 20 users have it: dev, gus, jo, lea, nia, raj. Since the last change, 0 gained it and 0 lost it.ana 9797ben 7786chen 6637dev 1452eli 6503fay 6963gus 1774hana 8207ivan 3505jo 1766kai 3496lea 2081mo 8933nia 1483omar 5646pia 9993raj 2sam 9516tess 4768uma 2674gets it when bucket is below 25006 of 20 have itgained 0, lost 0stable across reloadsstream: no change
Reload as often as you like: the same users stay in. Raise the slider and nobody loses the feature. Switch flags and a different group goes first.

When the context changes: login and logout

A visitor browses anonymously, is bucketed by deviceId into variant b of the pricing experiment, then signs in. If the SDK now buckets by user id, their hash changes and they may land in control. The page they just saw changes under them, and the experiment counts one person in two groups.

Two rules avoid this. Each flag declares what it buckets by: experiments that start on logged-out pages bucket by deviceId for their whole life, and features for signed-in users bucket by user key. And on login, the SDK sends an identify event linking the device id to the user id, so analytics can join the two histories.

Mechanically, client.identify(newContext) does four things: it bumps a context version number, requests a fresh evaluation, keeps serving the old values until the new ones arrive (so nothing flashes to defaults), and ignores any response tagged with an older version. That last step stops a slow response for the anonymous context from overwriting the signed-in values when the two race.

A sequence diagram of signing in. Columns: user, app, SDK, flags API, UI. The SDK has posted an evaluation for the anonymous context, version 1, and the reply is slow. The user signs in, and the app calls identify, which bumps the context to version 2 and keeps serving the old values. The SDK posts an evaluation for version 2. The late version 1 reply arrives and is ignored. The version 2 reply arrives; the SDK applies it and notifies only the keys that changed, and the components that read them re-render.userappSDKflags APIUIPOST v1sign inidentify, v2POST v2v1: ignoredv2: valuesnotify keysre-render
The context version makes the race harmless: a reply for an older context is dropped, and the old values stay on screen until the new ones arrive, so nothing flashes to defaults.

Check your understanding · Exercise 4

A rollout goes from 10 percent to 25 percent. Which statements hold with hash-based buckets? Select all that apply.

Bootstrapping: the right value on the first frame

Here is the bug every flag SDK ships with at first. The page renders with the default checkout-v2 = false, so the old checkout button appears. 400 ms later the flag fetch returns true, React re-renders, and the button jumps to the new design.

Users see a flash of the default value: the old feature for a moment, then the new one. Worse, a user in an experiment saw both variants. The dark-mode-theming guide fights the same kind of flash for colors; here the stakes include layout shift and broken experiment data.

Bootstrapping means giving the SDK its values before the first render, so the first frame is already right. There are four ways to get values early, and they combine.

Decision How does the SDK have correct values at the first render?

No bootstrap: render defaults, then fetch

Render immediately with code defaults.

Fetch flags after mount and re-render when they arrive.

Pros

  • Simplest. Never delays the page.

Cons

  • Flashes on every page load for every user who doesn't get the default.
  • Layout shift that counts against Cumulative Layout Shift.
  • Experiment users see two variants.

Block render on init, with a short cap

Start the flag request as early as possible, and hold the first render (or just the flagged region) until it returns.

Give up after a short cap, such as 300 ms, and render with defaults or cached values.

Pros

  • No flash when the service is fast.
  • Works on any page, including static ones.

Cons

  • Adds the full round trip, 100 to 400 ms on mobile, to every page's first paint.
  • When the cap passes you face the same choice again: stay on defaults, or swap late and flash.

Cached values, stale-while-revalidate

Save the last evaluated values in localStorage, keyed by context key.

On the next visit, render with them at once, fetch fresh values in the background, and apply changes at a safe moment.

Pros

  • Instant and right for returning visitors whose flags didn't change.
  • Works offline.

Cons

  • Useless on a first visit, which is when most experiments start.
  • Values can be hours or days stale, including a kill switch that has since been flipped.

Server bootstrap: evaluated values in the HTML

The server-rendering layer evaluates flags for this request's context, renders with them, and embeds the same values as inline JSON.

The client SDK reads that JSON synchronously before hydrating.

Pros

  • Zero client delay, no flash, even on first visit.
  • Server HTML and client hydration use identical values, so no hydration mismatch.

Cons

  • Only works for HTML rendered per user. Shared CDN HTML can't carry one user's flags.
  • Adds the evaluation call, about 5 to 20 ms within the data center, to server render time.

What we pick: Server bootstrap for every server-rendered page, which is nearly all of ours. For the few static CDN pages, use cached values for returning visitors, plus a blocking wait of at most 300 ms for the region under an experiment, falling back to defaults. The request itself keeps running until its 1,000 ms timeout, but never swap a rendered experiment variant late on the same page: an answer that arrives after the 300 ms cap waits for the next navigation.

Strategy
Visitor
Late value
A timeline of the flagged region with no bootstrap, a first-time visitor, and a 700 ms flag request. The user should see the new variant. At 0, 250, 500, 750, 1000, 1500, 2000 ms the region shows old, old, old, new, new, new, new. First paint at 0 ms. Flash: yes. Wrong value: 700 ms.0 msold250 msold500 msold750 msnew1000 msnew1500 msnew2000 msnewfirst paint: 0 msflash: old, then newwrong value: 700 msrequest: 700 ms
Defaults paint first and the real value replaces them when the fetch returns: the flash, on every load.

Embedding the values safely

The server puts the values in a JSON data block. A script tag with type="application/json" is not executed, so it needs no Content Security Policy hash. The one trap is escaping: a flag value containing the text </script> would end the tag early and let an attacker inject HTML. Escape every < as \u003c when serializing.

tsx
// Server: inside the root layout, per request.
const payload = await flagService.evaluate(context); // 5-20 ms in the data center
const json = JSON.stringify({ context, payload }).replace(/</g, "\\u003c");

return (
  <html>
    <body>
      <script id="ff-bootstrap" type="application/json"
        dangerouslySetInnerHTML={{ __html: json }} />
      <FlagProvider bootstrap={{ context, payload }}>{children}</FlagProvider>
    </body>
  </html>
);

// Client: read once, synchronously, before hydrateRoot runs.
const el = document.getElementById("ff-bootstrap");
const boot = el ? JSON.parse(el.textContent ?? "null") : null;
The same object feeds the server render and the client SDK, which is what makes hydration match.

Put together, a page load runs in a fixed order. The server evaluates before it renders, the browser reads the JSON before it hydrates, and the stream opens only after hydration, so nothing on the network sits between the HTML and a correct first frame.

A sequence diagram of one page load. Columns: browser, server, flags API, SDK, React. The browser requests the page with its ff_did cookie. The server asks the flags API to evaluate this context and gets the values back in 5 to 20 ms. The server sends HTML with the values embedded as JSON. The browser hands the JSON to the SDK, which creates the client before React hydrates. React hydrates with the same values, so the first frame is right. Only then does the SDK open the stream to the flags API.browserserverflags APISDKReactGET pageevaluatevaluesHTML, JSONread JSONhydrateopen stream
Nothing on the network sits between the HTML and a correct first frame. The stream is the last step, because nothing on the first paint needs it.

The SDK's start-up states

The SDK should be honest about where its values came from, because callers and exposure logging both care. It moves through a small state machine. It starts bootstrapped (values from the HTML), cached (values from storage), or empty (only code defaults).

It then becomes ready when fresh values arrive, timed-out if they don't arrive within 1,000 ms, or failed if the request errors. A timed-out or failed client keeps retrying in the background with backoff and moves to ready later. It applies those late values at the next safe moment rather than mid-page.

A state machine of the SDK's start-up. Three starting states across the top: bootstrapped, with values from the HTML; cached, with values from storage; and empty, with code defaults only. Bootstrapped goes straight to ready, since its values are already fresh. Cached and empty go to fetching, with a 1,000 ms timeout. From fetching: a 200 reply leads to ready, with the stream open; 1 second passing leads to timed-out, which keeps what it shows; an error leads to failed, which keeps its values. Timed-out leads to ready when a late reply arrives. A note says timed-out and failed retry with backoff, and late values apply on the next navigation.bootstrappedvalues from HTMLcachedfrom storageemptycode defaultsfetchingtimeout 1,000 msfresh alreadyreadystream opentimed-outkeeps what it showsfailedkeeps its values200 OK1 s passeserrortimed-out, failed: retry with backofflate values apply on next navigation
Only the bootstrapped path reaches ready without a wait. Every other path renders something valid at once, and nothing that arrives late changes the current page unless the flag is a live kill switch.

Think first

On a static page, the pricing experiment's region is waiting for flags and 300 ms have passed. What should the user see during those 300 ms, and after the cap?

Check your understanding · Exercise 5

Which bootstrapping choices avoid the flash of the default value for a first-time visitor on a server-rendered page? Select all that apply.

Live updates: when a change should reach an open tab

At 14:02 the recommendations widget starts timing out and slows every product page by 3 seconds. The on-call engineer flips the kill switch recommendations-enabled to false. Users who load a page after 14:02 get the new value from the server bootstrap.

But about 300,000 tabs are already open, and some stay open for hours. Without live updates, they keep calling the broken service until the user reloads.

Decision How do open tabs learn that a flag changed?

Polling

Every N seconds, re-request the evaluated flags, sending the last known version so an unchanged answer is a few bytes.

Pros

  • Works through every proxy. No long-lived connections to run.

Cons

  • Meeting a 5 second target means 300,000 tabs times 12 requests a minute: 60,000 requests per second, almost all returning nothing new.

Server-Sent Events (SSE)

One long-lived HTTP response per tab, text/event-stream, on which the server writes an event when flags change.

The browser's EventSource reconnects on its own and resends the last event id.

Pros

  • Push within about a second. One direction is all we need.
  • Plain HTTP, works over HTTP/2 without the old 6-connection limit per domain.

Cons

  • Hundreds of thousands of open connections to hold on the server side.
  • EventSource can't set custom headers, so auth goes in a cookie or the URL.

WebSocket

A two-way socket, possibly shared with chat or notifications if the app already runs one.

Pros

  • Push within about a second; one connection can carry many features.

Cons

  • Two-way is more than flags need. Reconnection, heartbeats, and resume logic are all ours to write.

What we pick: SSE, unless the app already has a WebSocket for other features, in which case flag changes ride on it as one more message type. The server-sent-events lesson covers reconnection and Last-Event-ID. To save connections, a tab hidden for more than 5 minutes closes its stream and on visibilitychange back to visible it fetches the latest values once and reopens the stream.

What travels on the stream

Because evaluation happens on the server, the stream can't carry rules. It has two choices. The stream server can keep each connection's context and push freshly evaluated values for that tab, or it can push only a small notice (flags changed, version 9918, keys: recommendations-enabled) and let each client re-fetch its own evaluation. The first saves a round trip but makes the stream servers evaluate 300,000 contexts in a burst; the second keeps them simple.

We choose the notice, and each client waits a random 0 to 2 seconds before re-fetching. That spreads 300,000 requests over 2 seconds instead of the same 50 ms: about 150,000 per second, which the evaluation tier must be sized for, or the window widened to 0 to 4 seconds. The whole path still lands under 5 seconds.

If none of the changed keys is a live flag (see below), the client doesn't re-fetch at all yet. It marks its values stale and fetches on the next navigation, which spreads the load naturally.

A sequence diagram of a kill switch reaching an open tab. Columns: admin, flags API, stream, SDK, widget. The admin flips recommendations-enabled off in the flags API. The flags API publishes a notice with version 9918 to the stream. The stream sends a flags-changed event marked live to the SDK. The SDK waits a random 0 to 2 seconds, then posts to the evaluate endpoint. The flags API replies with recommendations-enabled false. The SDK applies it at once because the flag is live, and notifies only that key's subscribers. The widget re-renders and removes the recommendations rail. Total time under 5 seconds.adminflags APIstreamSDKwidgetflip offnotice 9918changed, livewait 0 to 2 sPOST evaluatevalue: falsenotify keyrail removed
The stream carries a notice, not values, so rules never leave the server. Only subscribers of recommendations-enabled re-render; the rest of the page doesn't move.

Apply now, or apply on the next navigation?

Fresh values arriving is one thing; showing them is another. If a live update swaps the checkout layout while someone is typing a card number, we have built the flash again, on purpose. So every flag carries an update mode set by its owner, and the SDK keeps two snapshots: the applied values that components read, and the pending values that have arrived but are waiting.

Update modeUsed forWhen the new value shows
liveKill switches, operational limitsImmediately: merged into applied values and subscribers re-render
next-navigationRelease toggles, UI changesOn the next route change, when the page is redrawn anyway
sessionExperimentsOnly on a new session or full reload, so a user stays in one variant for their visit

The router tells the SDK when a navigation starts, and the SDK merges pending next-navigation values into the applied snapshot before the new route renders. Since the new page renders fresh, nothing visibly changes under the user. A kill switch, on the other hand, can and should yank a widget out of the page: a missing recommendations rail is better than a frozen one.

Check your understanding · Exercise 6

Which choices in this streaming design are sound? Select all that apply.

Defaults and failure: the SDK must never break the app

The SDK is on the hottest path in the codebase: almost every page reads a flag during its first render. That makes it a perfect single point of failure. If useFlag throws because the payload was malformed, every component that calls it throws, and React unmounts the page.

The rule is absolute: the SDK degrades, it never fails. Every public method returns a valid value of the right type, whatever happens inside.

Defaults live in code, and they are the safe state

Every flag read names its default right next to the key, and the default is chosen so the app works if the flag service vanished. For a release toggle the safe default is false: the old, proven path. For a kill switch on an established feature the safe default is usually true, since the feature is normally on and the flag service is how you turn it off.

That asymmetry is worth saying aloud in an interview. The rule "default everything to false" turns a flag-service outage into an outage of every feature that has a kill switch.

What goes wrongWhat the SDK doesWhat the user sees
Evaluation request takes over 1,000 msAbort it with AbortController, use cached or default values, retry in the backgroundThe page with defaults; no spinner
Request returns 5xx or the network is offlineKeep current values; retry after 1, 2, 4, 8 seconds with jitter, capped at 60Nothing changes
Payload fails validationDrop it whole, keep current values, report once to error trackingNothing changes
Flag missing from the payloadReturn the code default with reason flag-not-foundThe default
Value has the wrong type (a string for a boolean flag)Return the code default with reason wrong-type, report onceThe default
localStorage throws (blocked by browser settings, or full)Run from memory onlyNothing, apart from no cache next visit
Stream disconnectsEventSource reconnects; after 3 failures, fall back to polling every 60 secondsLive changes arrive later
A bug inside the SDK throwsCatch at every public entry point, return the default, reportThe default
ts
variation<K extends FlagKey>(key: K, fallback: FlagValue<K>): FlagValue<K> {
  try {
    const flag = this.applied.get(key);
    if (!flag) return this.miss(key, fallback, "flag-not-found");
    if (typeof flag.value !== typeof fallback)
      return this.miss(key, fallback, "wrong-type");
    this.exposures.maybeRecord(key, flag);
    return flag.value as FlagValue<K>;
  } catch (error) {
    this.reportOnce("variation", error); // sampled, never throws
    return fallback;
  }
}
Every exit returns a value of the right type. The fallback is required by the type signature, so nobody can forget it.

Error reporting needs its own care. If a bad payload makes every read fail, a naive SDK sends one error per read: 50 reads a page times 2 million users floods the error tracker and costs real money. Report each distinct error once per page load, and sample them. The error-tracking lesson covers grouping and sampling.

A sequence diagram of the failure path on a static page. Columns: page, SDK, flags API, tracker. The page starts the SDK with no bootstrap. The SDK posts to the evaluate endpoint, which doesn't answer. After 300 ms the SDK gives the page its default, and the experiment region renders the control variant. At 1 second the SDK aborts the request. It retries after 1 second and gets a 503. It reports the error once to the error tracker. It retries after 2 seconds and gets a 200, which goes into the pending values for the next navigation.pageSDKflags APItrackerinit, no bootstrapPOST evaluateno reply300 ms: defaultrenders control1 s: abortretry after 1 s503report onceretry after 2 s200: to pending
The region waits at most 300 ms and never sees an exception. The request is abandoned at 1 second, and the late answer lands in pending, so control stays on screen for this page.

Offline and the cache

Values are written to localStorage under a key that includes the context key and a schema version, like ff:v3:user-8812, with the time they were fetched. A cache entry older than 7 days is ignored, which limits how stale a returning user can be. On logout we delete the signed-in user's entry: flags can reveal what a person has access to, and the next person on a shared computer shouldn't learn that.

Check your understanding · Exercise 7

The flag service is down for 10 minutes. Which behaviors do you want from the SDK? Select all that apply.

Living with 150 flags: types, cleanup, tests, and governance

With 150 flags and 40 engineers, string keys go wrong in boring ways. Someone types useFlag("checkout_v2") with an underscore and gets the default forever, silently. Someone compares a variant to "B" when the flag returns "b", and someone deletes a flag in the dashboard while code still reads it. Types catch all three at compile time, if the types come from the same source of truth as the flags.

Decision Where do the flag types come from?

No types: string keys, unknown values

useFlag(key: string): unknown, and callers cast.

Pros

  • Nothing to set up.

Cons

  • Typos and wrong variants fail silently at run time. Removing a flag means a text search and hope.

Hand-written types in the repo

A flags.ts file listing each key and its type, edited by whoever adds a flag.

Pros

  • Typed reads, simple tooling.

Cons

  • Drifts from the dashboard: someone adds variant c there and forgets the file.

Types generated from the flag definitions

A command fetches the definitions of all client-visible flags for the web project and writes flags.gen.ts.

CI runs it and fails the build if the committed file is out of date.

Pros

  • One source of truth. Variant names become string unions. Archived flags disappear from the types.

Cons

  • A build step and a network call in CI. Needs a rule for flags created in the dashboard before their code lands.

What we pick: Generated types. The cost is one CI step; the payoff is that every typo, wrong variant, and dangling read becomes a compile error.

ts
// flags.gen.ts, generated. Do not edit.
export interface FlagTypes {
  "checkout-v2": boolean;
  "pricing-layout": "control" | "a" | "b";
  "recommendations-enabled": boolean;
  "search-debounce-ms": number;
}
export type FlagKey = keyof FlagTypes;
export type FlagValue<K extends FlagKey> = FlagTypes[K];

export const flagDefaults: { [K in FlagKey]: FlagTypes[K] } = {
  "checkout-v2": false,
  "pricing-layout": "control",
  "recommendations-enabled": true,
  "search-debounce-ms": 200,
};
Keys, value types, and defaults in one generated file. The defaults come from the definitions, so code and dashboard agree.

With variants as unions, an exhaustive switch over pricing-layout gets a compile error when someone adds variant c and regenerates, pointing at every place that must handle it. And because flagDefaults is generated too, the useFlag(key) hook can make the default optional for most callers while the code still always has one.

The life of a flag, and why stale flags are a cost

A release toggle is born when a developer wraps new code in a check of checkout-v2. It ships off, then goes to employees, then 1 percent, 10, 50, and 100 percent. Then, and this is the step teams skip, someone deletes the check, the old branch, and the flag itself.

A flag stuck at 100 percent for a year is stale: dead code that still costs a lookup, still lives in the payload, and still confuses the next reader, who can't tell whether the old branch matters.

The life of a release toggle. Top row, the rollout: off on day 0, staff on day 1, 1 percent on day 3, 10 percent on day 6, 50 percent on day 10, and 100 percent on day 14. Bottom row, the cleanup: delete the code and keep the new branch, wait until flag reads reach zero so old bundles are gone, then archive the flag so the types regenerate. A warning note: skipping cleanup leaves a stale flag and a dead branch.rolloutoffstaff1%10%50%100%day 0day 1day 3day 6day 10day 14cleanup, weeks 3 to 5delete codekeep new branchreads reach 0old bundles gonearchive flagtypes regenerateskip cleanup: stale flag, dead branch
The rollout is the part everyone remembers. The bottom row is what keeps 600 flags from turning into 600 dead branches, and its order matters: code first, archive last.

The SDK can help with cleanup instead of leaving it to discipline. Each flag gets a kind and an expected lifetime when it is created, and the flag service knows when each flag was last changed and when the SDK last reported reading it. A release toggle at 100 percent for 30 days with no rule changes gets marked stale in the dashboard and in a weekly report to its owner. The generated types above then make the removal itself a safe compile-time task.

Removing a flag: the order matters

Say checkout-v2 has been at 100 percent for a month and we want it gone. The tempting order is to archive the flag in the dashboard first. Don't: open tabs and cached pages run yesterday's bundle for days, and that bundle still reads checkout-v2.

Once the flag is archived, the server stops sending it, those clients fall back to the code default false, and users get the old checkout back. The safe order ends with the three steps in the bottom row of the figure above:

  1. Roll the flag to 100 percent and leave it for a week to be sure.
  2. Delete the flag from the code: keep the new branch, remove the old one. Ship it.
  3. Wait until no client reads the flag. The SDK reports, at a low sample rate, which flag keys it read; when reads of checkout-v2 drop to zero for 7 days, old bundles are gone.
  4. Archive the flag in the dashboard. Codegen removes it from the types, and any read that slipped in meanwhile fails to compile.

Check your understanding · Exercise 8

What do generated flag types catch at compile time? Select all that apply.

Testing every path

A flag doubles the number of paths through the code it guards. Five independent flags on one page make 32 combinations nobody will test by hand. So the SDK has to make two things easy: forcing any value in a test or a browser, and making sure changes in production happen on purpose, by the right people, with a record.

Overrides in tests

Unit and component tests never call the flag service. A TestFlagProvider takes plain values, typed by the generated FlagTypes, and everything else falls back to the generated defaults. Tests for a flagged feature run once per variant, so both branches stay tested until the flag is removed. End-to-end tests pass overrides through the same URL mechanism QA uses, below.

tsx
describe.each(["control", "a", "b"] as const)("pricing page, layout %s", (layout) => {
  it("shows a price for every plan", () => {
    render(
      <TestFlagProvider values={{ "pricing-layout": layout }}>
        <PricingPage />
      </TestFlagProvider>,
    );
    expect(screen.getAllByText(/per month/i)).toHaveLength(3);
  });
});
One test, three variants. A typo in the variant name fails to compile.

Overrides in the browser: URL and debug toolbar

Designers, QA, and support need to see a variant without changing anyone else's experience. The URL parameter ?ff=pricing-layout:b,checkout-v2:true sets overrides for the tab, saved in sessionStorage so they survive navigation. Staff also get a debug toolbar that shows every flag with its value, reason, and source, plus the current context and the stream status, and lets them override any value. The toolbar's code loads only for staff sessions.

A wireframe of the staff debug toolbar. At the top, the context: user-8812, pro, DE, and the stream status: connected. A table with columns flag, value, reason, and source. checkout-v2 is true, reason rollout, from the bootstrap. pricing-layout is b, reason override, set by the URL, highlighted. recommendations-enabled is false, reason off, updated by the stream. search-debounce-ms is 200, reason rule, from the bootstrap. Below, a note says overrides apply to this tab only and are logged with reason override, and a button clears overrides.Flags (staff only)user-8812, pro, DEstream: connectedflagvaluereasonsourcecheckout-v2truerolloutbootstrappricing-layoutboverrideURLrecommendations-enabledfalseoffstreamsearch-debounce-ms200rulebootstrapoverrides: this tab onlylogged with reason overrideclear overrides
Every row answers "why do I see this?" at a glance. The reason comes from the payload, which is why debug mode keeps it on the wire; the source says whether the value came from the HTML, the stream, or an override.

Two guards keep overrides from becoming a problem. In production, the server honors URL overrides only for signed-in staff, so a shared link can't put customers into an experiment variant of their choosing. And every exposure recorded under an override carries reason override, so the analysis drops it.

Governance: who may flip what

  • Roles per environment. Anyone can change flags in development. In production, only the flag's owning team and the on-call engineer can change it, and flags tagged high-risk (checkout, auth) need a second person to approve.
  • Audit log. Every change records who, when, the before and after rules, and a required comment. During an incident, the first question is often "what changed in the last hour?", and the audit log answers it alongside the deploy log.
  • Scheduled rollouts. A plan like 1 percent Monday, 10 Wednesday, 50 the next Monday, 100 a week later runs on its own, so nobody forgets a step or flips one at 2 a.m.
  • Guarded rollouts. At each step, the service compares error rate and latency between users with and without the new value, and pauses or rolls back if the new value is significantly worse. LaunchDarkly offers this as guarded rollouts, with optional automatic rollback, and Statsig offers rollout alerts on a gate's metrics. An in-house service can build a simpler version.

The client SDK's part in guarded rollouts is small but essential: the comparison is only possible if errors and performance data say which variant the user had. The SDK adds the active values of tracked flags to every error report and every real-user monitoring (RUM) session, the performance data collected from real visitors' browsers. Without that tag, an error spike during a rollout can't be pinned on the flag.

Check your understanding · Exercise 9

A rollout of checkout-v2 at 10 percent coincides with a rise in checkout errors. What lets the system decide, quickly and safely, to roll back? Select all that apply.

Exposure events that experiments can trust

An experiment compares outcomes between groups: did people who saw pricing layout b buy more than people who saw control? The analysis needs to know exactly who saw what. That record is the exposure event: user X was shown variant b of pricing-layout at time T. The ab-testing lesson covers the statistics; this chapter is about making the input clean, because a statistics engine can't fix bad exposure data.

The naive SDK logs an exposure for every flag in the payload when it loads. Count what that means: every page load logs 150 exposures, at least 300 million events a day for 2 million users. Worse, it counts everyone as exposed to the pricing experiment, though only about 4 percent of visitors ever open the pricing page.

The 96 percent who never saw either layout land in both groups and behave the same in each. Their numbers add noise and no difference, so a 5 percent lift among the real viewers is diluted across everyone. The experiment then needs much more traffic to detect it, or misses it.

Log when the value is used, after it is shown

  • Only on read. An exposure is logged when code reads the flag through useFlag or variation, not when the payload arrives. Debug tools use a separate peek read that never logs.
  • Only after commit. React may render a component and throw the result away (Strict Mode in development, concurrent rendering, a suspended tree). So useFlag logs from an effect, which runs only when the component really reached the screen.
  • Only in the browser. The server render reads the same flags, but logging there too would double-count. The client effect is the single source.
  • Only for flags that track exposures. The payload marks experiment flags with trackExposure: true; a kill switch read 50 times a page logs nothing.
  • The same read point in every variant. If the control branch never calls useFlag but variant b does, only b gets exposures and the groups look wildly uneven. Read the flag once, above the branch.

That last bullet is how a sample ratio mismatch (SRM) happens: a 50/50 experiment shows 50,000 exposures in one group and 41,000 in the other. A gap that large is almost never bad luck. It means something in assignment, logging, or the data pipeline is broken, and the result can't be trusted until the cause is found.

Experiment platforms run an SRM check automatically, but it is far cheaper to design the cause out. Vendor SDKs follow the same rules: LaunchDarkly's client SDKs send an evaluation event when code calls variation, and Statsig's log an exposure when code checks a gate or experiment, deduplicated within the session. LaunchDarkly's JavaScript allFlags call is the warning: by default it sends an event for every flag, unless sendEventsOnlyForVariation is set.

ts
type ExposureEvent = {
  type: "flag_exposure";
  flagKey: string;          // "pricing-layout"
  variationIndex: number;   // 2
  value: string | number | boolean;
  reason: "rule" | "rollout" | "default" | "timeout-default" | "override";
  contextKey: string;       // bucketing key used
  flagVersion: number;      // rules version that produced this value
  timestamp: number;
};

// Dedupe: one exposure per context, flag, and variation per page session.
const seen = new Set<string>();
function maybeRecord(e: ExposureEvent) {
  const id = `${e.contextKey}|${e.flagKey}|${e.variationIndex}`;
  if (seen.has(id)) return;
  seen.add(id);
  analytics.enqueue(e); // the app's analytics SDK batches and sends
}
Overrides and timeout defaults are logged with their reason, so the analysis can leave them out.

Deduplication keeps the volume sane: a header component that reads pricing-layout on every route logs once, not 30 times. If the variation changes (after a session-mode update on reload), the dedupe key changes and a new exposure is logged, which is correct. The events go into the app's existing analytics queue rather than a second network pipeline. The analytics-sdk guide covers batching, sendBeacon on page hide, and retries, and the analytics-tracking lesson covers event design; the flag SDK doesn't repeat any of it.

The path of an exposure event. Top row, in the browser: useFlag reads the value; the commit effect runs only after the component is on screen; the dedupe set keeps one event per context, flag, and variation and drops repeats; the analytics SDK batches events. An arrow labelled batch POST goes down to the collector on the server, then to the warehouse, then to the stats engine. A note lists what is never logged: peek reads, server renders, and kill switches.useFlagreads valueeffectafter commitdedupe setonce per keyanalyticsSDK batchesrepeats droppedbatch POSTcollectorwarehousestats enginenever logged:peek, SSR,kill switches
The flag SDK owns only the first three boxes. Batching, sendBeacon, and retries belong to the analytics SDK the app already has.

Check your understanding · Exercise 10

A 50/50 experiment shows 52,000 exposures for control and 44,000 for variant b. What are likely causes? Select all that apply.

React integration: one hook, few re-renders, matching hydration

Engineers should touch exactly two things: a FlagProvider near the root and a useFlag(key) hook. Everything in earlier chapters (bootstrap, streaming, apply modes, exposures, failure handling) hides behind them. The interesting decision is how a component learns that its flag changed.

The naive provider puts the whole flags object in React context: value={flags}. Every useContext reader re-renders whenever the object changes. Our product page has about 300 components that read some flag.

A kill switch update for recommendations-enabled creates a new flags object, and all 300 re-render, though only one cares. On a mid-range phone that can be a 100 to 200 ms stall, right in the middle of an incident when the site is already slow. The global-vs-local-state lesson covers this trap in general.

Decision How do components subscribe to flag values?

Context holding the flags object

The provider stores flags in state and passes the object through context.

Pros

  • Ten lines of code. Easy to explain.

Cons

  • Any change re-renders every reader of every flag.

Stable client in context, per-key subscription with useSyncExternalStore

Context holds the SDK client, which never changes identity, so context alone never triggers re-renders.

useFlag subscribes to one key in the client's store and re-renders only when that key's applied value changes.

Pros

  • A change re-renders only that flag's readers.
  • React's own API for external stores: safe under concurrent rendering, and it takes a server snapshot for hydration.

Cons

  • The store must return the same reference for unchanged values, or every check looks like a change.

A general state library with selectors

Put flags in Redux or Zustand and read with useSelector(s => s.flags[key]).

Pros

  • Fine-grained updates, familiar tooling.

Cons

  • Ties the SDK to one library, which other teams or micro-frontends may not use. Adds bundle weight for something the SDK can do in 40 lines.

What we pick: A stable client in context plus useSyncExternalStore per key. It has no dependencies, works with any app state library, and gives hydration the server snapshot it needs.

tsx
export function useFlag<K extends FlagKey>(key: K, fallback = flagDefaults[key]) {
  const client = useContext(FlagContext); // stable, never re-renders by itself
  const subscribe = useCallback(
    (onChange: () => void) => client.subscribe(key, onChange), [client, key]);

  const value = useSyncExternalStore(
    subscribe,
    () => client.peek(key, fallback),          // applied value, no exposure
    () => client.bootstrapValue(key, fallback)  // what the server rendered with
  );

  useEffect(() => {
    client.recordExposure(key); // after commit only; deduped inside
  }, [client, key, value]);

  return value;
}
Re-renders only when this key's applied value changes. The third argument makes hydration use the server's values.
Per-key subscriptions. At the top, the flag store holds four keys: checkout, pricing, recs, and search; recs has just changed. Below, the React tree under FlagProvider has four components, Cart, PricingTable, RecsRail, and SearchBox, each subscribed to one key with an arrow from its key. Only RecsRail is highlighted: it re-renders. The others don't.flag store, inside the clientcheckoutCartpricingPricingTablerecsRecsRailsearchSearchBoxre-rendersReact tree, under FlagProvider
The provider passes the client, which never changes, so context causes no renders. Each useFlag subscribes to its own key, and a change to recs wakes only RecsRail.

Same values on the server and in the browser

A hydration mismatch happens when the browser's first render differs from the server's HTML. React reports an error, throws away the server markup up to the nearest Suspense boundary (or the whole root), and renders that part again on the client, which is slow and can flash. The ssr lesson covers hydration in general. Flags can cause mismatches in three ways, and the SDK prevents each.

  • Wrong starting values. The client SDK is created from the embedded bootstrap JSON before hydrateRoot, never from localStorage, so it starts from the server's exact values.
  • Updates during hydration. Stream updates that arrive while React hydrates wait in the pending snapshot until hydration finishes.
  • A value that changed in between. getServerSnapshot returns bootstrap values, so even a value that changed in the meantime hydrates as the server saw it. React then sees the new client snapshot and updates in a normal render, if the flag's mode is live.

In the Next.js App Router, server components don't use hooks at all: they call the server-side evaluate(context) once per request (cached for the request), read values directly, and pass them down as props. The same evaluation result is what gets embedded for the client, so both sides agree by construction.

Check your understanding · Exercise 11

Which changes reduce re-renders when one flag changes? Select all that apply.

Architecture, data model, and interface

Now we can put the decisions together. The SDK splits into a framework-free core and a thin React layer. The core owns all state and all network traffic; the React layer only subscribes.

That split is what lets a Vue widget, a web worker, or another micro-frontend share the same client and the same values. The micro-frontend-shell guide shows how a shell hands one such runtime service to every app it hosts.

The architecture. In the browser zone: components call useFlag; FlagProvider hands them the stable client. Inside the SDK core: the flag store with applied and pending values; the transport with fetch and a timeout; the cache in localStorage; the stream listener using EventSource; overrides for staff only, dashed; and the exposure tracker that dedupes. In the server zone: the SSR server embeds bootstrap JSON into the page for the provider; the flags API evaluates and answers the transport; the stream fan-out sends notices to the stream listener; analytics collects batched exposures from the tracker.BrowserServercomponentsuseFlag(key)FlagProviderstable clientSDK coreflag storeapplied, pendingtransportfetch, timeoutcachelocalStoragestreamEventSourceoverridesstaff onlyexposuresdedupeSSR serverembeds JSONflags APIevaluatesstream fan-outchange noticesanalyticscollector
The core owns every value and every request; the React layer only subscribes. Rules stay in the server zone, and only results and notices cross into the browser.
PieceLives inResponsibilityWhy the boundary is here
FlagProvider, useFlagReact layer, about 1 KBHand the client to components; subscribe per key; record exposures after commitKeeps React-specific code out of the core
Flag storeCoreHolds applied and pending snapshots, notifies per-key listeners, applies update modes on navigationOne place where values change, so every rule about when they change lives here
Evaluation transportCorefetch with AbortController timeout, known-version check, retries with backoff, context versioningIsolates network failure handling from the store
Stream listenerCoreEventSource, reconnect, jittered re-fetch on change notices, close when hiddenOptional: pages without live flags skip loading it
CacheCoreRead and write localStorage entries per context key, with age limitMust fail silently when storage is blocked
Exposure trackerCoreDedupe and hand events to the analytics queueReuses the analytics pipeline instead of building a second one
OverridesCore, dev and QA onlyApply URL, toolbar, and test overrides on top of served valuesTree-shaken out of production unless enabled for staff
Server evaluatorServer-rendering layerBuilds the context from the request, evaluates, embeds bootstrap JSONThe only place that can make the first frame right
Evaluation API and stream fan-outFlag serviceRun rules, return client-visible values, broadcast change noticesRules never leave this tier

Follow the values through one page load. The request arrives with the ff_did cookie and a session. The server builds the context, calls the evaluation API in the same data center (5 to 20 ms), renders with the result, and embeds it. The browser parses the JSON, creates the client, hydrates with identical values, and then opens the stream.

Later, a change notice arrives, the client re-fetches its evaluation, and the store applies or parks the new values. Exposures flow the other way, from useFlag through the tracker to the analytics queue.

Data flow end to end. Server side on the left: SSR server, flags API, stream fan-out, collector. Browser side on the right: HTML, flag store, components, analytics SDK. The flags API sends values for this context, about 2 KB, to the SSR server, which sends HTML with inline JSON to the browser. The browser parses it before hydrating, into the flag store. The store gives components a per-key snapshot. The store posts the context to the flags API and gets values back. When a rule changes, the flags API tells the stream fan-out, which sends a notice with the changed keys to the store. Components send exposures, after commit, to the analytics SDK, which sends batched events to the collector.serverbrowserSSR serverflags APIstream fan-outcollectorHTML + JSONflag storecomponentsanalytics SDKvalues, 2 KBrule changedHTML, inline JSONparse firstPOST context, values backnotice: keyssnapshotexposures, after commitbatched events
Values flow right, exposures flow back left, and rules never cross the middle. The notice on the stream names keys only; the store fetches its own values.

Data model: where each value lives

The client holds very little data, but where each piece lives decides most of the bugs above. The flag values for this context live in the SDK's store, in memory, as two snapshots, next to the context and its version number. A copy lives in localStorage for the next visit. Nothing about flags belongs in the app's own Redux or query cache: one owner means one set of rules about when values change.

ts
type EvaluatedFlag = {
  value: boolean | string | number | Record<string, unknown>;
  variationIndex: number;
  reason: "rule" | "rollout" | "default" | "off";
  version: number;              // bumps on every rule change
  trackExposure: boolean;       // experiments only
  updateMode: "live" | "next-navigation" | "session";
};

type ClientState = {
  context: EvaluationContext;
  contextVersion: number;       // ignore responses for older contexts
  applied: ReadonlyMap<string, EvaluatedFlag>;  // what components read
  pending: ReadonlyMap<string, EvaluatedFlag>;  // arrived, waiting for a safe moment
  source: "bootstrap" | "cache" | "network" | "defaults";
  status: "empty" | "bootstrapped" | "cached" | "ready" | "timed-out" | "failed";
};
Snapshots are immutable maps, replaced whole on change, so subscribers can compare references cheaply.
DataWhereLifetime
Applied and pending valuesSDK store, in memoryThe page session
Last values per contextlocalStorage, key ff:v3: plus context key7 days; deleted on logout
Anonymous device idFirst-party cookie ff_did1 year
OverridesURL parameter, then sessionStorageThe tab's session
Exposure dedupe setSDK, in memoryThe page session

The evaluation endpoint

http
POST /sdk/v1/evaluate
Content-Type: application/json
X-Client-Key: web-prod-pk_4f9a

{ "knownVersion": 9917, "context": { "key": "user-8812", "anonymous": false, "deviceId": "d-77a1",
    "attributes": { "plan": "pro", "appVersion": "2026.10.3", "platform": "mobile-web" } } }

200 OK
Cache-Control: private, no-store
{ "version": 9918,
  "flags": {
    "checkout-v2": { "value": true, "variationIndex": 1, "reason": "rollout",
      "version": 41, "trackExposure": false, "updateMode": "next-navigation" },
    "pricing-layout": { "value": "b", "variationIndex": 2, "reason": "rollout",
      "version": 7, "trackExposure": true, "updateMode": "session" } } }
The client key is public and only names the project. A POST so the context isn't logged in URLs. When nothing changed since knownVersion, the reply is just { "unchanged": true }.

Errors use the app's standard format, { "error": { "code": "invalid_context", "message": "..." } }, and the SDK treats every error the same way: keep current values, back off, retry. A 401 for a revoked client key is the exception: retrying is pointless, so the SDK stops and reports once.

Stream messages

text
GET /sdk/v1/stream?project=web-prod      (Accept: text/event-stream)

id: 9918
event: flags-changed
data: {"version":9918,"keys":["recommendations-enabled"],"live":true}

event: ping
data: {}

retry: 5000
A change notice names the keys and says whether any is live, so clients know whether to re-fetch now. A ping every 25 seconds keeps proxies from closing an idle connection.

The SDK's public API

ts
interface FlagClient {
  variation<K extends FlagKey>(key: K, fallback?: FlagValue<K>): FlagValue<K>; // logs exposure
  peek<K extends FlagKey>(key: K, fallback?: FlagValue<K>): FlagValue<K>;      // never logs
  identify(context: EvaluationContext): Promise<void>; // resolves when fresh or timed out
  subscribe(key: FlagKey, onChange: () => void): () => void;
  onNavigation(): void;                // router calls this; applies pending values
  status(): ClientState["status"];
  close(): void;
}

function createFlagClient(options: {
  clientKey: string;
  bootstrap?: { context: EvaluationContext; payload: EvaluationPayload };
  timeoutMs?: number;                  // default 1000
  streaming?: boolean;                 // default true
  overrides?: Partial<FlagTypes>;      // tests and dev
}): FlagClient;
Small on purpose: two reads, one context change, one subscription, one navigation hook.

This shape is close to OpenFeature, a vendor-neutral standard for flag APIs. Its web SDK uses a static context that the app sets once and changes with OpenFeature.setContext, typed evaluation calls that always take a default, and evaluation details that carry the variant and a reason. Its React SDK adds an OpenFeatureProvider and hooks like useBooleanFlagValue. Wrapping our client as an OpenFeature provider would let teams use the standard hooks and swap the backing service later.

Think first

The core is about 5 KB gzipped, the React layer about 1 KB, and the stream listener about 1 KB. Which parts belong in the main bundle and which can load later?

Performance and security

Payload: send less, and send it compactly

The company has 600 flags, but most guard backend code: queue settings, database migrations, partner integrations. Each flag gets a client-visible setting, off by default, and the evaluation API only returns client-visible flags. That one switch cuts the payload from 600 flags to 150 and stops server-only flag names, which often describe unreleased work, from reaching browsers. LaunchDarkly, for example, has a per-flag client-side availability setting for exactly this.

Then the format. The verbose shape from the data model section costs about 130 bytes per flag: 20 KB raw for 150 flags. Most fields hold their default value, so the wire format leaves them out: no trackExposure unless true, no updateMode unless it isn't next-navigation, and no reason unless the flag tracks exposures or debug mode is on.

That brings it to about 60 bytes per flag, 9 KB raw, and around 2 KB after gzip, since the keys repeat. Inline in the HTML, 2 KB costs a few milliseconds even on a slow connection, which is far cheaper than a separate request.

What flags do to Core Web Vitals

  • Largest Contentful Paint (LCP). Server bootstrap adds 5 to 20 ms of evaluation to the server response and nothing to the client. Blocking init on a static page adds a full round trip, which is why we keep it to the experiment region with a 300 ms cap.
  • Cumulative Layout Shift (CLS). A late flag that swaps a 400 px banner in or out is a layout shift. Bootstrap removes it; where we must wait, a placeholder of the final size does.
  • Interaction to Next Paint (INP). A flag update that re-renders 300 components can block the main thread for 100 ms or more, and if it lands during a tap, that tap feels slow. Per-key subscriptions keep an update to a handful of components.

Flags also help measure performance. The variant tags the SDK adds to every RUM session for guarded rollouts let a dashboard compare LCP and INP for pricing-layout = b against control. A variant that wins on sign-ups but adds 400 ms of LCP is a trade-off people should see before shipping it. The real-user-monitoring lesson covers how to collect those numbers.

Security: what must never go in a client flag

  • The client key is public. Anyone can read it from the bundle. It identifies the project; it authorizes nothing. All it can do is ask for client-visible flags.
  • Flags are not permissions. A user can override any value in their own browser. A flag can hide the admin menu, but the admin API checks roles on the server, every time.
  • No secrets in values. Not API keys, not signed URLs, not internal hostnames. Everything in the payload is readable by the user.
  • Trusted attributes come from the server. The context in the request body is user-controlled: anyone can send plan: enterprise or someone else's user key and see what that person would get. The evaluation API takes the user key, plan, and employee status from the session cookie and only accepts low-risk attributes, like screen size, from the body.
  • Sign the key when a vendor evaluates. A vendor's service can't read your session cookie, so LaunchDarkly's secure mode has your server compute an HMAC SHA-256 hash of the context key with the secret SDK key. The browser sends it along, and requests for any other key are rejected.
  • Rules stay on the server. Server-side evaluation, chosen earlier, keeps customer emails and segment lists out of the payload. If a team later wants client-side evaluation for some flags, only flags whose rules are safe to publish qualify.
  • Names leak too. A client-visible flag called acme-acquisition-banner tells the world about the deal. Sensitive launches use neutral names until they are public; Statsig's client payload goes further and hashes flag names.
  • Escape the bootstrap JSON, as covered in the bootstrapping chapter, so a flag value can never inject HTML.

Check your understanding · Exercise 12

A user edits their evaluation request to send employee: true and key: user-1. What should stop them seeing employee-only flags or another user's values? Select all that apply.

Running it in the interview

This question rewards people who see past the if statement. Most candidates design a fetch and a context provider and stop. Your goal in 45 minutes is to show the four decisions that matter (where evaluation runs, how the first frame is right, how buckets stay stable, how exposures stay clean) and the safety rules around them.

MinutesWhat to doWhat to say out loud
0 to 5Ask the scope questions: rendering model, flag count, kill switch speed, experiments, sensitive targeting"If pages are server-rendered, I can evaluate there and embed the values, which removes the flash."
5 to 12Name the flag kinds and the hard parts; decide where evaluation runs"Rules contain customer lists, so they stay on the server. The browser only gets results."
12 to 22Draw the architecture: server evaluator, bootstrap JSON, client store, stream, React layer"The store has applied and pending snapshots, so updates land at a safe moment."
22 to 32Deep dive on two: bootstrapping and bucketing, or exposures"Hash flag key, salt, and user key into 10,000 buckets: stable, monotonic, independent per flag."
32 to 40Failure, typed flags, security"Every read returns a typed value, whatever fails. Flags are never a permission check."
40 to 45Governance and what you'd build next"The SDK tags errors with variants so a guarded rollout can roll itself back."

Draw the page-load sequence from the bootstrapping chapter first: request, server evaluation, HTML with embedded JSON, hydration, stream. It explains bootstrapping, hydration consistency, and the architecture in one picture, and gives the interviewer something to point at.

Follow-up questions to expect

Think first

"Our marketing pages are static and cached on a CDN. How do you avoid the flash there?"

Think first

"A user in variant b logs in and their bucket would change. What happens?"

Think first

"What happens when someone flips a flag and 300,000 tabs are open?"

Summary

  • Evaluate on the server for anything the browser sees. Send results, not rules: rules leak customer lists and unreleased plans, and they grow without bound.
  • Bootstrap evaluated values into the server-rendered HTML so the first frame is right and hydration matches. Use cached values and a short, capped blocking init only where the server can't evaluate.
  • Bucket by hashing the flag key, a salt, and a stable user key into 10,000 buckets. Rollouts are then stable everywhere, grow without moving anyone out, and differ per flag.
  • Keep applied and pending snapshots. Kill switches apply live, release toggles on the next navigation, experiments once per session.
  • Log exposures when the value is used and committed, deduplicated, from the same read point in every variant, with the reason attached.
  • Every read returns a typed value whatever fails. Defaults live in code and are the safe state.
  • Generate flag types from the definitions, and remove flags in order: code first, wait for old bundles to drain, then archive.
  • Flags decide what to show, never what is allowed. The client key is public, and trusted attributes come from the session.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 3
Mid-level 0 of 5

References

Concepts behind it

Try next