Frontend Integration & Client-Side Rendering

The browser is the last mile for feature flags — and the most unforgiving one. Flags that reach the client must arrive before the first render, match what the server already painted, and never expose targeting logic the user could manipulate. This overview covers the full delivery chain: how flags are evaluated on the server, serialized into the HTML, handed to the client SDK, and kept in sync from that point forward. The companion Backend Evaluation & Server-Side SDKs guide covers the server half of that chain.

What makes the frontend hard is that every mistake is public. A server-side evaluation bug shows up in a log or a metric; a client-side one shows up as a visible flicker, a layout shift, or a component that renders one thing on the server and something else a beat later. The user sees it, screenshots it, and files a bug — often before your monitoring does. Worse, the browser is a hostile execution environment: the user can open dev tools, read the bootstrap JSON, flip a boolean in memory, and re-render the tree with a flag you never rolled out to them. That is why the guiding principle for client-side flags is evaluate on the server, deliver a snapshot, and never trust the browser with a decision that has to be authoritative. The client’s job is to render what was already decided, not to decide. Every technique in this guide — bootstrap payloads, hydration seeding, payload minimization, CSP nonces — exists to keep that separation clean while still giving the UI the responsiveness of local, synchronous flag reads.

Client-side flag delivery overview Flags are evaluated on the server, serialized into a bootstrap payload in the HTML, used during hydration, and then kept fresh by the client SDK. Server evaluates flags with full context Bootstrap payload resolved variants inlined in HTML Hydration client SDK reads bootstrap snapshot after hydration Client SDK poll / stream for updates Control plane updated flag config streamed / polled
Flags are resolved server-side, inlined as a bootstrap payload, consumed synchronously during hydration, then kept current by the client SDK through polling or streaming.

Architecture Overview

The client flag delivery path has four distinct stages, and a failure at any one of them shows up immediately in the UI.

Server evaluation runs before the response leaves the origin. The server assembles a full evaluation context — user identity, plan tier, request locale — and resolves every flag the page depends on. This is the only point in the chain where targeting logic can access sensitive attributes safely.

Bootstrap payload is the serialized result of that evaluation, embedded in the HTML. It is a plain JSON object mapping flag keys to their resolved variants. The payload travels with the document so the client has the correct values the instant the parser reaches it — no network round-trip, no evaluation race.

Hydration is where the client SDK reads the bootstrap payload and initializes its local state from it, synchronously, before React (or Vue, or any other framework) reconciles the DOM. Initializing from the snapshot rather than from a fresh fetch eliminates the server-client divergence that causes hydration mismatches.

Client SDK updates begin after the page is interactive. The SDK polls or streams the control plane for flag changes and updates its local state reactively. User-level targeting that depends on browser-only attributes (viewport size, local timezone offset) can be evaluated here — but only for presentational decisions, because anything security-sensitive must already have been settled on the server. A common design mistake is to treat this fourth stage as the primary one: teams reach for a client SDK that fetches flags on mount because it is the simplest thing to wire up, and they inherit a flash on every single page load. The bootstrap payload exists precisely to demote the runtime fetch from load-bearing to eventually-consistent refresh. The first paint should never wait on it.

The boundary between stages three and four deserves particular care. During hydration the flag values must be frozen to exactly what the server serialized, even if a newer value has arrived over the stream in the milliseconds since. Applying a mid-hydration update is the single most common cause of the “works locally, mismatches in production” class of bug, because your local machine hydrates fast enough that no update lands inside the window and production, under real network jitter, does not. The correct pattern is to hydrate from the frozen snapshot, then apply any queued updates in a useEffect (or the framework equivalent) that runs after the first commit — so the correct-but-stale value paints first, and the fresh value arrives as an ordinary, non-destructive re-render.

// Next.js App Router: server component evaluates, serializes, passes to client
import { OpenFeature } from '@openfeature/server-sdk';
import { FlagProvider } from './components/flag-provider';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const client = OpenFeature.getClient('web');
  const ctx = { targetingKey: 'anon', plan: 'free' };

  // Resolve every flag the layout or its children need
  const bootstrapFlags = {
    'web.dashboard.new-nav': await client.getBooleanValue('web.dashboard.new-nav', false, ctx),
    'web.checkout.express-pay': await client.getBooleanValue('web.checkout.express-pay', false, ctx),
  };

  return (
    <html>
      <body>
        {/* FlagProvider inlines bootstrapFlags as a <script> and seeds the client SDK */}
        <FlagProvider bootstrap={bootstrapFlags}>{children}</FlagProvider>
      </body>
    </html>
  );
}

Each stage has a distinct failure signature. A miss at server evaluation serves the safe default site-wide; a missing bootstrap payload forces the client to fetch and flash; a hydration read that diverges from the server throws a mismatch warning and re-renders; and a stalled SDK update leaves the UI on a value the control plane has already changed. Knowing which stage failed from the symptom is the fastest path to a fix.

Notice how the symptoms are ordered by blast radius. A server-evaluation failure is the safest failure — everyone gets the default, which by contract is the off state, so nothing dangerous ships even though the feature is temporarily unavailable. A missing bootstrap degrades gracefully to a flash: annoying, not broken. A hydration mismatch is louder — React discards the server-rendered subtree and re-renders on the client, which is correct but costs you the SSR performance benefit and logs a warning that will drown your console if it happens on a high-traffic component. A stale SDK update is the quietest and most dangerous: nothing errors, nothing flashes, the UI simply lies about the current state of the world until the next successful sync. Because it produces no visible symptom, it is the one you must catch with instrumentation rather than eyeballs — emit the flag-config version the client is running on with your exposure events, and alert when the p99 client version lags the control plane by more than one revision. This is the client-side analogue of monitoring replication lag, and it is just as easy to forget until it bites.

Four delivery stages and their failure signatures Server evaluation, bootstrap payload, hydration, and SDK updates in sequence, each annotated with the UI symptom that appears when it fails. 1 · Server eval full context fail → default site-wide 2 · Bootstrap inlined JSON fail → fetch + flash 3 · Hydration read snapshot fail → mismatch warning 4 · SDK updates poll / stream fail → stale value a failure at any stage is visible in the UI immediately
The four-stage delivery chain, each stage labelled with the UI symptom it produces on failure — so the visible bug points straight at the responsible stage.

Lifecycle & Governance

Client-side flags have an abbreviated lifecycle compared to server-side ones, but the same governance requirements apply: every flag needs an owner, a creation date, and an expiry. Stale flags accumulate in the bootstrap payload and inflate every page response — 10 dead flags at 30 bytes each are invisible noise until a team has 200 of them. Lifecycle policies enforced at creation time (see designing a scalable flag taxonomy) prevent that drift.

The bootstrap payload is where governance debt becomes a runtime cost. Unlike a server-only flag, a client flag ships on every HTML response, so a payload that has grown to hundreds of entries taxes every visitor’s time-to-interactive, not just an internal service. Scope each page’s bootstrap to the flags it actually reads, and treat any flag that has been fully rolled out (100% on) or fully retired as a deletion candidate — its value no longer varies, so it does not belong in a per-request payload. A scheduled audit that flags entries past their expiry date keeps the payload proportional to the flags in active use.

There is a second, subtler governance cost unique to the client: naming. A server-side flag key can be as internal and descriptive as you like — checkout.fraud.aggressive-3ds-challenge leaks nothing because it never leaves your infrastructure. The moment that same key is inlined into HTML, it becomes a public artifact that anyone can read, and a key that names an unreleased product, a competitor comparison, or a security control is a genuine information leak. This is one reason the secure browser delivery step re-keys the payload: the server maps internal keys to opaque, per-response identifiers, or at minimum to sanitized public names, before serialization. Governance therefore has to track two names per client flag — the internal one your rules reference and the external one the browser sees — and CI should assert that no raw internal key ever appears in a rendered response. It is far cheaper to enforce that naming discipline at flag-creation time than to discover a leaked codename in a customer’s screenshot.

Retirement discipline is also harder on the client because of caching. When you delete a server flag, the next deploy simply stops evaluating it. When you delete a client flag, older HTML that still references the key may sit in a CDN cache, a service-worker cache, or a user’s back-forward cache for minutes to hours after the change. A defensive client SDK treats an unknown key in the bootstrap as a no-op and any code path reading a deleted key falls back to its hard-coded default — so a half-propagated retirement degrades to the safe state rather than throwing. Never assume the payload the browser holds matches the flag set your control plane believes is live; the two are eventually consistent, and the gap is exactly one cache TTL wide.

Bootstrap payload growth from unretired flags Two stacked bars: a lean payload of only active flags versus a bloated payload where retired and fully-rolled-out flags were never removed, adding bytes to every page response. Scoped payload active flags · ~1 KB Unretired payload active + retired & 100%-on flags every request pays for every entry → a fully rolled-out flag no longer varies — delete it from the payload
A client flag ships on every response, so unretired entries tax every visitor. Scope the payload to flags whose value still varies.

Ecosystem Integration

The OpenFeature web SDK (@openfeature/web-sdk) is the canonical client implementation. It exposes the same getClient / getBooleanValue / getStringValue API as the server SDK, which means the same flag keys and evaluation logic work on both sides — removing the surface area for divergence.

In CI, validate that the bootstrap payload shape matches what the client SDK expects. A type mismatch between a server-generated string variant and a client-side boolean evaluation call is the kind of bug that slips through integration tests unless flag contracts are tested explicitly. Because the same OpenFeature API runs on both sides of the wire, a single flag definition and a single set of keys drive server evaluation, the serialized payload, and client reads — collapsing the surface area where the two halves can disagree.

The OpenFeature abstraction earns its keep most at provider-swap time. Because your components call getBooleanValue('web.dashboard.new-nav', false) against the OpenFeature client rather than a vendor SDK directly, migrating from one flag backend to another — flagd to a hosted vendor, or a homegrown service to flagd — is a provider change at the SDK’s edge, not a rewrite of every component. The provider architecture guide covers how a provider translates the generic evaluation call into backend-specific traffic; on the client the same contract lets you run a static bootstrap provider during hydration and hot-swap to a streaming provider once the page is interactive, with no component aware that the source of truth changed underneath it. Hooks — OpenFeature’s before/after/error interceptors — are also worth wiring on the client for exactly one purpose: emitting an exposure event on every successful evaluation, centrally, so no individual component has to remember to log. That single after hook is the cleanest way to guarantee your experiment analysis sees every treatment assignment.

One caveat specific to the web SDK: its evaluation is synchronous and reads from an in-memory store, unlike the server SDK where await client.getBooleanValue(...) may hit a network or a cache. That is deliberate — a synchronous read is what lets a React component render a flag-gated branch without suspending — but it means the web SDK is only ever as fresh as its last setContext or provider update. Calling setContext to change the targeting key (for example, after a user logs in) triggers a re-evaluation of the whole flag set against the new context, which is asynchronous and can momentarily change values under a rendered tree. Treat a context change as a deliberate, awaited transition — show a boundary or defer the navigation until the re-evaluation resolves — rather than firing it mid-render.

One OpenFeature API across server and client A shared flag definition and key schema feed both the server SDK and the web SDK, which expose the same evaluation API, so the same keys and logic work on both sides without divergence. Flag definition + keys namespace.service.feature Server SDK getBooleanValue(key, def, ctx) Web SDK getBooleanValue(key, def, ctx) same API
The web SDK mirrors the server SDK's API, so one flag definition and key schema drive both sides — removing the space where server render and client evaluation can diverge.

Progressive Delivery & Experimentation

Client-side flags support progressive delivery through percentage-based rollouts evaluated on the server (stable bucketing across replicas) or on the client (local to the session). Server-side bucketing is strongly preferred for experiment integrity: deterministic assignment survives page reloads and cross-device sessions, while client-side bucketing can shift when local storage is cleared. A/B tests on UI components should record the exposure event alongside the flag key and variant before the user interacts — not on conversion — to avoid selection bias from users who never see the treatment.

The stable-identity requirement is the crux, and it is where anonymous traffic complicates things. For a logged-in user the targeting key is obvious — the account id — and it is identical on the server and every device. For a logged-out visitor you have no such durable identity, so a percentage rollout keyed on a per-tab random value will re-bucket that visitor on the next visit, smearing them across both arms of an experiment and inflating your variance. The usual fix is a first-party cookie set on the server with a long expiry, echoed into both the server evaluation context and the bootstrap payload, so the same anonymous id drives bucketing before login and the account id takes over after. When the visitor authenticates, record the mapping from anonymous id to account id in your analytics so pre-login and post-login exposures for the same human can be stitched together; without that bridge, your conversion funnel silently splits one user into two.

Exposure timing deserves the same rigor as bucketing. “Record exposure when the treatment is seen, not when the flag is evaluated” is the rule that separates a trustworthy experiment from a broken one. A flag evaluated in a layout that renders a component below the fold has not been seen — logging an exposure there dilutes your treatment group with users who scrolled away, and dilution always drags the measured effect toward zero. For above-the-fold UI the evaluation and the exposure coincide closely enough to log together; for anything gated behind a scroll, a tab, or a modal, defer the exposure event to an intersection observer or the interaction that actually reveals the variant. And because the client is where exposures are cheapest to over-count — a component that re-renders ten times must still emit exactly one exposure per user per variant — deduplicate on the client using the session identifier before the event leaves the browser, or your denominators will be wrong in a way that is nearly impossible to reconstruct after the fact.

Server-side versus client-side bucketing stability Server bucketing keyed on a stable user id holds the same variant across reload and a second device; client bucketing keyed on local storage flips the variant when storage is cleared. Server bucketing hash(stable user id) reload → variant A device 2 → A assignment survives reload & device experiment integrity preserved Client bucketing hash(local storage id) reload → variant A cleared → B assignment flips on storage reset cohort contamination risk
Server-side bucketing on a stable identity holds the variant across reloads and devices; client-side bucketing can flip when local storage is cleared, contaminating cohorts.

Operational Safety

A client-side flag that blocks render is an outage. Every flag read must have a hard-coded safe default, and the SDK initialization must not gate DOMContentLoaded. If the flag provider fails to connect, the page renders its defaults; it does not hang. UI flicker prevention and the bootstrap-payload pattern together address the most common form of client-side flag failure: the flash of the wrong variant between server render and client evaluation.

Secure browser delivery matters because the payload is visible to every user. Strip internal flag keys, targeting rule details, and any attribute that amounts to PII before the bootstrap JSON leaves the server. CSP boundaries govern how that inlined payload is permitted to execute, and edge and CDN delivery can pre-evaluate and cache the bootstrap payload closer to the user.

The “safe default” contract is the load-bearing invariant of the whole operational story, and it is worth being precise about what it means. A default is safe when the value it produces is the pre-feature behavior — the world as it existed before this flag was introduced. For a boolean gate that is almost always false; for a string variant it is the control arm, never a new treatment. The failure mode to avoid is the “fail-open” default that ships the new experience when evaluation fails, because a control-plane outage then becomes an uncontrolled global launch of whatever half-finished feature happened to be flagged. Wire your defaults so that losing the flag backend makes the product quieter, not louder. And test that path deliberately: block the provider’s network in an integration test and assert the page still renders defaults and stays interactive. A default that has never been exercised is a default you do not actually have.

Timeouts turn the safe-default contract into a latency guarantee. The client SDK’s initialization must be bounded — if the provider has not resolved within, say, 200 ms, render with defaults and let the real values arrive as a later update rather than holding the first paint hostage to a slow or unreachable backend. The bootstrap payload makes this nearly free, because the “later update” almost never has to happen: the correct values are already inlined, so the network fetch degrades from critical path to background refresh. On a cold path with no bootstrap, that same 200 ms budget is the difference between a snappy skeleton and a spinner that users read as a broken page. Pick the budget from your real p95 provider latency plus headroom, not from a round number, and emit a metric every time the timeout fires so a creeping backend regression surfaces before it becomes a visible slowdown.

Flash-of-default versus bootstrap-seeded render Two timelines: without a bootstrap the UI paints the default, then flips to the correct variant when the SDK connects; with a bootstrap the correct variant paints on first render and never flips. No bootstrap paints default (wrong) SDK connects → flips visible flicker Bootstrap-seeded paints correct variant on first render no flip
Seeding the client SDK from the server snapshot means the first paint is already correct — eliminating the flash-of-default flip that a cold async fetch produces.

Compliance & Audit

Flag exposure events from the client must flow back to the same audit pipeline as server evaluations. Each event should carry the flag key, resolved variant, targeting key, and a session or request identifier. Stripping PII before the event leaves the browser is non-negotiable for GDPR-scoped deployments — the event consumer does not need the raw email address to attribute an exposure correctly. Route these events through the same immutable audit trail as server-side changes so that “which users saw which variant” is answerable from one system of record.

The reason client and server exposures must share one pipeline is that a single user’s journey crosses the boundary constantly. The server renders a flag-gated checkout button; the client re-renders it after an update; the same flag governs both. If those two exposures land in separate stores with separate schemas, reconstructing what a specific user actually saw at a specific moment — the question every incident review and every regulator asks — becomes a join across mismatched systems that nobody can perform under time pressure. One schema, one store, both sources writing to it. Give client-emitted events an explicit source: "web" field so you can still distinguish them for debugging without fragmenting the record.

Client exposure events are also lossy in a way server events are not, and your compliance posture has to account for it. A browser event can be dropped by an ad blocker, a network failure on a flaky mobile connection, a tab closed before the beacon flushed, or a Content-Security-Policy that blocks the analytics endpoint. This means the client exposure stream is a lower bound on true exposures — good enough for experiment analysis with appropriate caveats, but never sufficient as the authoritative record of whether a user was subject to a given treatment when that matters legally. For decisions with regulatory weight — a pricing variant, a consent-gated feature — the authoritative exposure is the server evaluation that produced the bootstrap value, logged server-side where delivery is guaranteed. Use navigator.sendBeacon rather than a fetch on unload to minimize client-side loss, but design the audit story so the answer to “did this user see the treatment” never depends solely on a packet the browser may never have sent.

Client exposure event flows through a PII-stripping boundary A browser exposure event passes through a PII-stripping boundary that replaces raw identifiers with a hashed targeting key before it joins the shared audit pipeline. Browser event key, variant, user PII-strip boundary email → hashed key GDPR-safe Audit pipeline shared with server
Client exposure events pass through a PII-stripping boundary — raw identifiers become a hashed targeting key — before joining the same audit pipeline as server evaluations.

Key Concepts at a Glance

Client delivery chain guide map The delivery chain from edge to browser, with each guide attached to the stage it covers: edge delivery, secure delivery, SSR consistency, SDK init, flicker prevention, React hooks, and CSP boundaries. Client delivery Edge & CDN delivery Secure delivery SSR consistency SDK initialization Flicker prevention React hooks & CSP boundaries
Each guide attaches to a stage of the client delivery chain — from evaluating at the edge through hydrating without flicker in the browser.

Troubleshooting & FAQ

Why does the page flash the wrong variant on load?

The SDK is not initialized from the bootstrap payload before render. The component reads the default value synchronously, then the SDK connects asynchronously and the value changes, causing a visible flip. Fix: seed the client SDK from the server-evaluated snapshot before any component evaluates a flag.

Why does React throw a hydration mismatch error on flag-gated components?

The server rendered variant A (from the server-side evaluation), but the client re-evaluated and got variant B before React hydrated. The mismatch triggers a full re-render and a console warning. Fix: initialize the client SDK from the same snapshot the server used, so the first client-side read returns the same variant.

Can I evaluate flags on the client without a server-side bootstrap?

Yes, but only for purely presentational toggles with no SSR dependencies. The SDK initializes asynchronously, so you must render a loading or skeleton state until the provider is ready. Never do this for flags that gate content the server already rendered — the mismatch is guaranteed.

How many flags can I safely include in the bootstrap payload?

There is no hard limit, but every flag in the payload adds bytes to every page response. Scope the bootstrap to flags the page actually reads. A payload of 20–50 resolved boolean or string variants is typical; anything larger suggests the page is doing too much or flags are not being retired.

Should I evaluate percentage rollouts on the server or in the browser?

On the server, almost always. Server-side bucketing hashes a stable identity — the account id, or a first-party anonymous cookie — so the same user lands in the same arm across reloads and devices, which is what experiment integrity requires. Browser-side bucketing keyed on local storage re-buckets the user whenever storage is cleared, contaminating both cohorts. Reserve client-side evaluation for presentational toggles that depend on browser-only attributes like viewport size, where cross-session stability does not matter.

How do I keep a flag update from breaking hydration mid-render?

Freeze the flag values to the server-serialized snapshot for the entire hydration pass, and apply any updates that arrived over the stream only after the first commit — in a useEffect or the framework equivalent that runs post-hydration. Applying a mid-hydration update makes the client render a different tree than the server sent, which is the classic mismatch. Hydrate from the frozen snapshot first; let the fresh value land as an ordinary, non-destructive re-render a tick later.

What is a safe default for a client-side flag, and why does it matter so much?

A safe default is the pre-feature behavior — the world before the flag existed — which for a boolean gate is almost always false and for a variant is the control arm. It matters because the default is what renders whenever the flag backend is unreachable, so a “fail-open” default that ships the new experience turns a control-plane outage into an uncontrolled global launch. Wire defaults so losing the backend makes the product quieter, not louder, and test the path by blocking the provider’s network in an integration test.

Why do my client exposure counts not match my server-side numbers?

Because the client stream is lossy. Browser exposure events are dropped by ad blockers, flaky mobile networks, tabs closed before the beacon flushes, and CSP rules that block the analytics endpoint, so the client count is a lower bound on true exposures. Use navigator.sendBeacon to reduce loss and deduplicate per user per variant on the client before sending. For any decision with legal weight, treat the server-side evaluation that produced the bootstrap value as the authoritative record, not the browser event.

Can I change the targeting key after login without disrupting the rendered UI?

Yes, but treat it as a deliberate transition. Calling setContext on the web SDK re-evaluates the entire flag set against the new context asynchronously, which can change values under an already-rendered tree. Defer the context change to a controlled moment — after the login navigation, behind a boundary or a brief loading state — rather than firing it mid-render, and record the anonymous-id-to-account-id mapping in analytics so pre-login and post-login exposures for the same person can be stitched together.