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.
| Kind | Example | Lives for | What it needs from the SDK |
|---|---|---|---|
| Release toggle | checkout-v2 hides unfinished code that is already deployed | Days to weeks | Percentage rollout with stable buckets, then removal from code |
| Experiment | pricing-layout with variants control, a, b | 2 to 6 weeks | Stable assignment, exact exposure events, no variant change mid-session |
| Kill switch | recommendations-enabled turns off a slow widget during an incident | Years | Live updates within seconds, a safe default if the service is down |
| Operational flag | search-debounce-ms = 200, a tunable number | Months to years | Typed non-boolean values, live or next-page updates |
| Entitlement flag | beta-reports shown to customers on the beta list | Months | Server-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.
| Question | Answer we assume | What 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")returnstrueorfalse, neverundefined. - 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
| Requirement | Target | Why this number |
|---|---|---|
| SDK size in the main bundle | Under 8 KB gzipped | It loads on every page, before any feature code. Every KB here delays every page. |
| Extra delay on server-rendered pages | 0 ms on the client | Values arrive inside the HTML, so the browser waits for nothing. |
| Client-only pages: time to real values | Under 300 ms at the 75th percentile. A flagged region waits at most 300 ms; the request itself is abandoned at 1,000 ms | A blank region longer than 300 ms starts to feel broken, and an answer later than 1 second is not worth waiting for. |
| Flag read cost | A map lookup, no network, no promise | Components read flags during render, sometimes dozens per page. |
| Payload for 150 flags | About 9 KB raw, about 2 KB gzipped | Small enough to inline into the HTML. |
| Kill switch reaches open tabs | Under 5 seconds | A broken checkout costs money every second it stays on. |
| Failure behavior | The app never crashes because of the SDK | Flags 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.
Decision Where should the rules run for the browser's flags?
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.
| Concern | Server-side | Client-side | Edge |
|---|---|---|---|
| Rules visible to the user | No | Yes, all of them | No |
| Payload for 150 flags | About 2 KB gzipped, fixed | Grows with rules: 10 KB to 100+ KB | About 2 KB gzipped, fixed |
| CDN caching of the payload | Per user, so mostly no | Yes, one copy for all | Per user, computed at the edge |
| Context change after load | One round trip, 50 to 150 ms | Instant | One round trip to a nearby edge, usually shorter |
| Works offline | With last cached values | Yes, with cached rules | With last cached values |
| Bundle cost | Smallest | Adds a rule engine and hashing | Smallest 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.
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
};
};
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.
// 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
}
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.
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.
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?
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.
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.
// 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;
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.
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.
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?
During the wait: a placeholder with the same size as the pricing table, so nothing shifts when it fills in. Not a spinner for a short wait, and not either variant, because showing one and then swapping is the flash we are avoiding.
After the cap: the control variant, which is the default in code. Log the exposure with reason timeout-default so the experiment analysis can exclude these users rather than count them as control. When the real answer arrives, keep control on screen until the next navigation.
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?
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.
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 mode | Used for | When the new value shows |
|---|---|---|
live | Kill switches, operational limits | Immediately: merged into applied values and subscribers re-render |
next-navigation | Release toggles, UI changes | On the next route change, when the page is redrawn anyway |
session | Experiments | Only 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 wrong | What the SDK does | What the user sees |
|---|---|---|
| Evaluation request takes over 1,000 ms | Abort it with AbortController, use cached or default values, retry in the background | The page with defaults; no spinner |
| Request returns 5xx or the network is offline | Keep current values; retry after 1, 2, 4, 8 seconds with jitter, capped at 60 | Nothing changes |
| Payload fails validation | Drop it whole, keep current values, report once to error tracking | Nothing changes |
| Flag missing from the payload | Return the code default with reason flag-not-found | The default |
| Value has the wrong type (a string for a boolean flag) | Return the code default with reason wrong-type, report once | The default |
localStorage throws (blocked by browser settings, or full) | Run from memory only | Nothing, apart from no cache next visit |
| Stream disconnects | EventSource reconnects; after 3 failures, fall back to polling every 60 seconds | Live changes arrive later |
| A bug inside the SDK throws | Catch at every public entry point, return the default, report | The default |
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;
}
}
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.
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?
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.
// 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,
};
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 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:
- Roll the flag to 100 percent and leave it for a week to be sure.
- Delete the flag from the code: keep the new branch, remove the old one. Ship it.
- Wait until no client reads the flag. The SDK reports, at a low sample rate, which flag keys it read; when reads of
checkout-v2drop to zero for 7 days, old bundles are gone. - 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.
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);
});
});
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.
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
useFlagorvariation, not when the payload arrives. Debug tools use a separatepeekread 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
useFlaglogs 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
useFlagbut variantbdoes, onlybgets 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.
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
}
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.
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?
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.
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;
}
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 fromlocalStorage, 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.
getServerSnapshotreturns 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 islive.
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.
| Piece | Lives in | Responsibility | Why the boundary is here |
|---|---|---|---|
FlagProvider, useFlag | React layer, about 1 KB | Hand the client to components; subscribe per key; record exposures after commit | Keeps React-specific code out of the core |
| Flag store | Core | Holds applied and pending snapshots, notifies per-key listeners, applies update modes on navigation | One place where values change, so every rule about when they change lives here |
| Evaluation transport | Core | fetch with AbortController timeout, known-version check, retries with backoff, context versioning | Isolates network failure handling from the store |
| Stream listener | Core | EventSource, reconnect, jittered re-fetch on change notices, close when hidden | Optional: pages without live flags skip loading it |
| Cache | Core | Read and write localStorage entries per context key, with age limit | Must fail silently when storage is blocked |
| Exposure tracker | Core | Dedupe and hand events to the analytics queue | Reuses the analytics pipeline instead of building a second one |
| Overrides | Core, dev and QA only | Apply URL, toolbar, and test overrides on top of served values | Tree-shaken out of production unless enabled for staff |
| Server evaluator | Server-rendering layer | Builds the context from the request, evaluates, embeds bootstrap JSON | The only place that can make the first frame right |
| Evaluation API and stream fan-out | Flag service | Run rules, return client-visible values, broadcast change notices | Rules 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 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.
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";
};
| Data | Where | Lifetime |
|---|---|---|
| Applied and pending values | SDK store, in memory | The page session |
| Last values per context | localStorage, key ff:v3: plus context key | 7 days; deleted on logout |
| Anonymous device id | First-party cookie ff_did | 1 year |
| Overrides | URL parameter, then sessionStorage | The tab's session |
| Exposure dedupe set | SDK, in memory | The page session |
The evaluation endpoint
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" } } }
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
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
The SDK's public API
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;
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?
The store, the bootstrap reader, and the React layer must be in the main bundle, since components read flags in their first render.
The stream listener can load after hydration with a dynamic import(): nothing needs it for the first paint.
The overrides panel and its UI load only when a staff member opens the debug toolbar, so regular users never download them.
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: enterpriseor 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-bannertells 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.
| Minutes | What to do | What to say out loud |
|---|---|---|
| 0 to 5 | Ask 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 12 | Name 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 22 | Draw 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 32 | Deep 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 40 | Failure, typed flags, security | "Every read returns a typed value, whatever fails. Flags are never a permission check." |
| 40 to 45 | Governance 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?"
Three layers. Returning visitors render at once from cached values. An edge function can evaluate per request and inject the values into the cached HTML, which gives first-time visitors the right frame too. Without an edge, the experiment region blocks for at most 300 ms behind a same-size placeholder, then falls back to control and keeps it for the page.
Whatever the fallback, the exposure is logged with its real reason, so timeouts don't pollute the experiment.
Think first
"A user in variant b logs in and their bucket would change. What happens?"
Experiments that run on logged-out pages bucket by the device id for their whole life, so login doesn't move anyone. Signed-in features bucket by user key.
On login, identify fetches new values but keeps the old ones until the new ones arrive, ignores out-of-date responses by context version, and sends an identify event linking device and user for analytics.
Think first
"What happens when someone flips a flag and 300,000 tabs are open?"
The stream sends a small notice with the changed keys. If none is live, clients only mark values stale and re-fetch on next navigation. If one is live, each client waits a random 0 to 2 seconds before re-fetching, which spreads the load while still meeting the 5 second target.
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.
References
- Feature Toggles (aka Feature Flags) Pete Hodgson's classic on toggle categories, lifetimes, and keeping them manageable.
- OpenFeature specification A vendor-neutral standard for flag evaluation APIs, including evaluation context, reasons, and hooks.
- useSyncExternalStore React's API for subscribing to an external store, including the server snapshot used during hydration.
- hydrateRoot How hydration works and what happens on a mismatch.
- Using server-sent events The event stream format, event ids, and reconnection.
- Cumulative Layout Shift (CLS) Why late content swaps hurt, and how the metric is measured.
- GrowthBook SDK documentation A well-documented SDK that evaluates locally, useful to contrast with remote evaluation.
- LaunchDarkly secure mode How a vendor stops a browser from evaluating flags for someone else's context key.
- OpenFeature React SDK The standard provider and hooks, including evaluation details with variant and reason.
- LaunchDarkly guarded rollouts A vendor's take on rollouts that watch metrics and roll back on regressions.
Concepts behind it
- Feature flagsSwitches in your code that turn features on or off for chosen users without a deploy: for gradual releases, experiments, emergency kill switches, and plan-based access, if you avoid flicker, leaked code, and a pile of dead flags.
- A/B testingShowing different versions of a page to randomly split groups of users and measuring which works better: stable bucketing, no flicker, honest statistics, and guardrails that catch harm.
- Server-sent eventsOne HTTP response that never finishes: the server keeps writing small text events to it, and the browser's
EventSourceparses them, reconnects on its own, and resumes where it left off. The simplest push channel when data flows from server to client. - Server-side renderingThe server builds the full HTML for each request, so users see content before any JavaScript runs, then the browser hydrates it: faster first paint and better SEO, paid for with server work, a later first byte, and a second render in the browser.
- Product analyticsRecording what users do as well-named events that follow a tracking plan, delivered reliably even offline, with consent respected, and checked so that the numbers people decide with are true.
- Client storage optionsThe six places a web app can keep data on the user's device (cookies, localStorage, sessionStorage, IndexedDB, the Cache API, and OPFS), how big, how fast, and how long-lived each one is, and how to pick the right one for each kind of data.
- Local vs global stateWhere each piece of state lives decides what re-renders when it changes: keep it next to its readers, lift it only as far as needed, and use context or a store with selectors only for state that many distant parts share.