Client-Side SDK Initialization Best Practices

This guide is part of the Frontend Integration & Client-Side Rendering series. Getting client-side SDK initialization right determines whether flags are available when the first component renders, whether your React hooks ever see a stale default, and whether the init code itself adds meaningful weight to your critical-path bundle.

This guide covers bootstrap order, initializing from a server-provided snapshot, lazy and deferred init, bundle-size impact, and readiness gating. It does not cover backend targeting rules or server-side evaluation — those live in the Backend Evaluation & Server-Side SDKs series.

The reason initialization deserves its own guide is that the browser SDK operates under constraints the server SDK never faces. On the server you initialize once at process start and every request reuses a warm, fully-synced client; a few hundred milliseconds of cold-start cost is amortized across millions of evaluations. In the browser the SDK is cold on every page load, the “process” is a single user’s tab, and the cost lands squarely on the metrics your users feel — largest-contentful-paint, first-input-delay, and cumulative-layout-shift. An initialization sequence that is merely acceptable server-side becomes a visible defect client-side: a spinner where content should be, a header that snaps from one variant to another after hydration, or a checkout button that renders its control variant for 400 ms before the SDK catches up. Every decision below is ultimately about moving work off the critical path so the first frame the user sees is already correct.

Client SDK initialization timeline A timeline showing bootstrap snapshot loading at page start, SDK reaching ready state, then entering live update mode once the streaming connection is established. Bootstrap snapshot server-inlined JSON SDK ready provider initialized Live updates streaming connection t=0 — no network call t≈0 — gates render t+idle — push updates Client SDK initialization phases
A server-inlined snapshot eliminates the network call needed before first render; the provider upgrades to live streaming once idle.

Prerequisites

Prerequisites for client SDK initialization Four prerequisites: the web SDK and a compatible provider, an SSR page that can inline a snapshot, a CSP that permits the streaming endpoint, and an agreed readiness strategy. Web SDK + provider @openfeature/web-sdk SSR page inlines snapshot CSP allows stream connect-src Readiness strategy block / skeleton
The SSR snapshot capability is what lets the SDK be ready before first paint — without it, initialization is a race against the render.

Core Concept & Architecture

The client SDK follows three distinct phases, and getting the ordering wrong is the most common source of UI flicker during hydration:

  1. Bootstrap — load an initial flag set with zero network round-trips. The server inlines a snapshot into the HTML payload.
  2. Ready — the provider is initialized from that snapshot and reports PROVIDER_READY. Components can now read flags synchronously.
  3. Live — the SDK upgrades to a streaming or polling connection that pushes subsequent changes.

Skipping the bootstrap phase means the SDK must complete a network round-trip before it is ready, which either blocks the render or forces every flag to its default.

The mental model that keeps these phases straight is provenance over freshness. During bootstrap and the first paint you deliberately trade a few seconds of staleness for correctness at t=0 — the snapshot may be milliseconds old or, in a cache-heavy CDN setup, a few seconds old, and that is fine because the alternative is a wrong or missing value on screen. Only once the page is interactive does the SDK shift to prioritizing freshness, upgrading to a stream that pushes changes the instant an operator flips a flag. Conflating the two — trying to guarantee live-fresh values before first paint — is what drives teams to block render on a network call and ship a spinner. Accept that the first frame is a snapshot and the tension disappears.

A subtle consequence of this phasing is that the bootstrap and live sources must resolve flags identically for the same targeting key, or a flag will visibly change value the moment the stream connects even though no operator touched it. This is why the server that inlines the snapshot and the streaming endpoint the client upgrades to should share one evaluation engine and one flag definition set — the snapshot is a cached read of exactly what the stream would return, not a separately-computed approximation. If your architecture computes the snapshot in a different service than the one serving the stream, budget explicit tests that assert the two agree for a matrix of representative targeting keys.

With a bootstrap snapshot versus without Without a snapshot, the SDK must round-trip the network before it is ready, blocking render or defaulting; with a snapshot it is ready at t=0 and the first paint sees real variants. No snapshot network round-trip before ready render blocked or defaults With snapshot ready t=0 first paint sees real variants, stream upgrades on idle
The snapshot moves the network cost off the critical path: the SDK is ready before render, and the streaming upgrade happens after the page is interactive.

Step-by-Step Implementation

Five steps take the SDK from server to interactive: inline the snapshot, initialize the provider from it, gate rendering on readiness, subscribe to live updates, and pass a safe default at every call site.

The five initialization steps Inline a bootstrap snapshot, initialize the provider from it, gate rendering on readiness, subscribe to live updates, and handle failure with safe defaults. 1 · Inline snapshot 2 · Init provider from snapshot 3 · Gate render on readiness 4 · Subscribe live updates 5 · Safe defaults at call site
Steps 1–3 make the first paint correct; steps 4–5 keep it current and resilient after the page is interactive.

Step 1 — Inline a bootstrap snapshot from the server

The server resolves a compact set of flags for the current user and writes the result into the HTML as a <script> tag. The client reads that value before the SDK makes any network request.

// server.ts (Next.js / Express render handler)
import { evaluateBootstrapFlags } from './flag-server';

export async function renderPage(req: Request): Promise<string> {
  // Resolve the flags every above-the-fold component needs
  const snapshot = await evaluateBootstrapFlags({
    targetingKey: req.session.userId,
    'web.nav.new-header': false,
    'web.checkout.express-flow': false,
  });

  // Inline as window.__FLAG_BOOTSTRAP__ — keys use namespace.service.feature
  const snapshotJson = JSON.stringify(snapshot);
  return `<script>window.__FLAG_BOOTSTRAP__ = ${snapshotJson};</script>`;
}

Keep the snapshot small — only the flags needed before first render. Full flag payloads inflate HTML size and defeat the purpose. As a rough budget, aim to keep the inlined JSON under a couple of kilobytes; on a typical page that is ten to twenty above-the-fold flags, not the two hundred your organization has defined. Every byte here is uncompressed weight on the HTML document’s critical path, and because it is inline it cannot be cached separately — it re-downloads on every navigation that is not served from the back/forward cache. For payload security guidance see securely passing flags to the browser.

One detail that is easy to get wrong: serialize the snapshot with a JSON encoder that escapes <, >, and &, or an attacker who controls a string flag value (or even a flag key echoed into the object) can break out of the <script> element with a literal </script> sequence. JSON.stringify alone does not escape those characters — wrap it, or use a framework helper such as Next.js’s serialized-data mechanism that handles the escaping for you. This is a genuine stored-XSS vector, not a theoretical one, whenever any part of the snapshot is influenced by user-supplied data.

Pitfall: never include targeting-rule logic in the snapshot object. Send resolved variants only — rule trees belong server-side. Shipping the rules to the browser both bloats the payload and leaks your segmentation logic (which cohorts exist, what thresholds gate them) to anyone who opens View Source.

Pitfall: the snapshot is computed for a specific targetingKey. If your HTML is cached by a CDN and served to multiple users, you will hand user A’s resolved variants to user B. Either mark the response Cache-Control: private for authenticated pages, or scope the snapshot to only anonymous, non-user-specific flags on cacheable routes and let the stream resolve the personalized ones after the SDK is ready.

Step 2 — Initialize the provider from the snapshot

Wire the provider to consume window.__FLAG_BOOTSTRAP__ before connecting to the remote endpoint. This makes PROVIDER_READY fire synchronously, so the first render sees real values.

// flag-client.ts
import { OpenFeature } from '@openfeature/web-sdk';
import { FlagdWebProvider } from '@openfeature/flagd-web-provider';

let initPromise: Promise<void> | null = null;

export function initFlags(): Promise<void> {
  if (initPromise) return initPromise; // idempotent

  const bootstrap: Record<string, boolean | string | number> =
    (window as any).__FLAG_BOOTSTRAP__ ?? {};

  const provider = new FlagdWebProvider({
    host: 'flags.example.com',
    port: 443,
    tls: true,
    // Hydrate from the inlined snapshot so PROVIDER_READY fires immediately
    cache: { initialValues: bootstrap },
  });

  initPromise = OpenFeature.setProviderAndWait(provider);
  return initPromise;
}

Call initFlags() at the top of your application entry point, before any component tree mounts. Await it in frameworks that support async root setup (Next.js App Router layouts, Nuxt plugins). The idempotent guard on initPromise matters more than it looks: React’s Strict Mode deliberately double-invokes effects in development, module federation can load your entry twice, and a fast-refresh cycle re-runs module-level code — without the guard each of these registers a fresh provider and you leak connections. Returning the same promise on every call means every caller awaits one initialization and observes one PROVIDER_READY.

Note the difference between setProvider and setProviderAndWait. The former is fire-and-forget: it registers the provider and returns immediately, leaving the client in a NOT_READY state until initialization completes asynchronously in the background. setProviderAndWait returns a promise that resolves only once the provider has finished initializing, which is what you want when the next line of code renders flag-dependent UI. Mixing them up — awaiting nothing, then reading a flag — is the single most common cause of a first-render default, because your code races the provider’s async setup and usually loses.

Pitfall: calling getClient() before setProviderAndWait resolves returns a client backed by the no-op provider, and the no-op provider answers every evaluation with the default you passed and a reason of PROVIDER_NOT_READY. It does not throw, so the bug is silent — you get plausible-looking defaults, not an error. Always await init before rendering flag-dependent UI, and in tests assert on the evaluation reason, not just the value, so a NOT_READY leak fails loudly.

Step 3 — Gate rendering on provider readiness

In React, a context provider that awaits initFlags() is the cleanest gate. Components below it can call getBooleanValue synchronously without ever seeing the SDK’s internal default state.

// FlagProvider.tsx
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import { OpenFeature } from '@openfeature/web-sdk';
import { initFlags } from './flag-client';

const ReadyCtx = createContext(false);

export function FlagProvider({ children }: { children: ReactNode }) {
  const [ready, setReady] = useState(false);

  useEffect(() => {
    initFlags().then(() => setReady(true));
  }, []);

  if (!ready) {
    // Render a skeleton or null while the provider resolves.
    // With a server snapshot this resolves synchronously — the skeleton
    // is visible only on the very first hydration tick.
    return null;
  }

  return <ReadyCtx.Provider value={true}>{children}</ReadyCtx.Provider>;
}

export const useFlagReady = () => useContext(ReadyCtx);

For SSR consistency the server and client must agree on the initial value, so the snapshot approach is doubly important: it ensures the variant the server rendered is what the client hydrates with. If the two disagree, React logs a hydration mismatch and — in React 18 and later — discards the server-rendered DOM for the mismatched subtree and re-renders it on the client, which is exactly the flicker you were trying to avoid, now with a console error attached. The snapshot is what guarantees getBooleanValue returns the same variant on the server pass and the first client pass.

There is a spectrum of gating strictness worth choosing deliberately. Hard gating (render null or a skeleton until ready) is correct when a wrong variant would be actively harmful — a pricing page, a payments flow, a legal-consent banner. Soft gating (render immediately with safe defaults, then re-render when ready) is better for non-critical surfaces where a brief default is invisible or acceptable, because it never delays the first paint. With a server snapshot the provider resolves synchronously and the distinction mostly evaporates, but on routes where a snapshot is not available — a client-only single-page app section, say — you must pick one consciously rather than letting the framework’s default behavior pick for you.

Pitfall: returning null without a skeleton triggers cumulative layout shift when the real content later appears and pushes the page around. Render a fixed-height placeholder whose dimensions match the resolved content, so the swap is a paint, not a reflow.

Pitfall: gating the entire app on flag readiness turns one slow provider into a blank page. Gate only the subtree that actually reads flags; the shell, navigation, and static content should render regardless of provider state.

Step 4 — Subscribe to live updates

Once above-the-fold content has rendered and the page is interactive, the provider upgrades its connection from snapshot-only to a live stream. Subscribe to the PROVIDER_CONFIGURATION_CHANGED event so components react to updates without requiring a page reload.

// live-updates.ts
import { OpenFeature } from '@openfeature/web-sdk';

export function subscribeToFlagUpdates(onUpdate: () => void): () => void {
  const client = OpenFeature.getClient();
  client.addHandler('PROVIDER_CONFIGURATION_CHANGED', onUpdate);
  return () => client.removeHandler('PROVIDER_CONFIGURATION_CHANGED', onUpdate);
}

Use this in a top-level effect to force a re-render of any component that reads flags from context. Combine with React hooks for feature flag state for per-flag subscription granularity. Be aware that PROVIDER_CONFIGURATION_CHANGED is a coarse signal — it fires when any flag in the set changes, not just the one a given component cares about. A naive top-level handler that bumps a single version state will re-render your whole tree on every unrelated flag change. For a busy control plane that can be dozens of re-renders a minute, so either debounce the handler or subscribe per-flag through the hooks layer, which diffs the specific keys a component reads and skips the re-render when they are unchanged.

Consider what a live update means for a user mid-session, too. Flipping a flag from off to on while someone is halfway through a flow can teleport them into a different UI between clicks — a form field that vanishes, a step that reorders. For flags that change layout or navigation, it is often better to latch the value for the duration of a session or a flow and apply the new variant only on the next full navigation, treating the live stream as a signal for new page loads rather than an instruction to mutate the current one. Reserve instantaneous live application for kill-switches and content that is safe to swap under the user’s feet.

Pitfall: subscribing before PROVIDER_READY fires is harmless but noisy. Attach the listener inside the initFlags().then(...) callback.

Pitfall: forgetting to return the unsubscribe function from your effect leaks a handler on every mount. Over a long-lived single-page session that is a slow memory climb and, worse, duplicate re-renders as multiple stale handlers all fire on the same event.

Step 5 — Handle network failure and safe defaults

The streaming connection will fail intermittently. Define safe defaults at the call site rather than relying on the provider’s built-in fallback, so the behavior under failure is explicit and tested.

// Usage in any component
import { OpenFeature } from '@openfeature/web-sdk';

const client = OpenFeature.getClient();

// The second argument is the safe default — returned on any error, timeout, or NOT_READY
const showExpressFlow = client.getBooleanValue(
  'web.checkout.express-flow',
  false // safe default: old flow
);

Keep defaults conservative — the off or degraded variant, not the experimental one. This is especially important for lazy-initialized SDKs where the SDK may not be ready when a below-the-fold component first evaluates a flag.

The discipline that makes safe defaults actually safe is treating the default as the contract, not the fallback. Ask of every call site: “if this flag never resolves for the entire session, does the app still work?” If the answer is no — if the default renders a broken layout or a dead button — then the default is wrong regardless of how reliable your streaming endpoint is, because the browser is a hostile runtime where ad-blockers, corporate proxies, flaky mobile radios, and aggressive privacy extensions will block your flags host for some non-trivial slice of real users. Design so the default variant is a fully-functional product, and treat the flagged variant as the enhancement. This also keeps the failure blast radius small: a flags outage degrades gracefully to a known-good experience instead of taking the page down.

One more subtlety: the type of the default must match the flag’s type, and the value must be one the flag can actually take. Passing false as the default for a string flag, or a variant name the flag’s schema does not define, means the type-mismatch or invalid-value error path returns your bad default — you have silently baked a value the control plane can never produce into your UI. Keep call-site defaults in sync with the flag definitions, ideally by generating typed accessors from the flag manifest so a rename or type change fails the build rather than surfacing at runtime.

Verification & Testing

After implementing all steps, confirm the initialization sequence in browser DevTools:

# 1. Open the Network panel and reload — the flags endpoint should NOT appear
#    before DOMContentLoaded (the snapshot serves those values)
# 2. After idle, confirm a single WebSocket or SSE connection to the flags host
# 3. In the Console, run:
window.__FLAG_BOOTSTRAP__
# Expect: { "web.nav.new-header": false, "web.checkout.express-flow": false }
# 4. Toggle a flag in the control plane and confirm the UI updates without reload

For automated verification, write an integration test that mounts your FlagProvider with a seeded snapshot and asserts useFlagReady() returns true before any flag evaluation fires. Go one step further and add a test that mounts without a snapshot and asserts every flag-dependent component renders its safe default without throwing — this is the test that actually protects you in production, because the snapshot-present path is the happy one and the snapshot-absent path is where real users end up when something upstream fails. Assert on the evaluation reason field too: a healthy first render from a snapshot should report CACHED or STATIC, and a leaked NOT_READY read reports PROVIDER_NOT_READY, so asserting the reason turns a subtle ordering bug into a clear test failure.

It is also worth adding a synthetic check in CI or your uptime monitor that loads the real page and inspects the Network waterfall: the flags endpoint appearing before DOMContentLoaded is a regression that means someone removed the snapshot inlining, and it will not show up in a functional test because the page still works — just slower and with a flicker. Catching it requires asserting on the shape of the load, not just the final DOM.

Three DevTools checks confirm the init sequence The flags endpoint must not appear before DOMContentLoaded, a single streaming connection should open after idle, and window.__FLAG_BOOTSTRAP__ should hold the resolved variants. no early fetch before DCL snapshot serves first paint one connection after idle single WS / SSE __FLAG_BOOTSTRAP__ populated resolved variants present
The Network panel proves the snapshot did its job (no pre-render fetch) and the Console proves the bootstrap object reached the client.

Troubleshooting & FAQ

Why does my flag always return its default value on the first render?

The provider is not ready when the component mounts. This usually means initFlags() was not awaited before the component tree rendered, or the bootstrap snapshot was missing from the server response. Check window.__FLAG_BOOTSTRAP__ in the browser console; if it is undefined, the server inlining step failed. If it is populated but flags still default, confirm the provider’s cache.initialValues option is wired to the snapshot object.

How do I avoid a flash of unstyled or wrong-variant content?

Use the server-inlined snapshot so the provider reaches PROVIDER_READY before the first paint. If a snapshot is not feasible, render a neutral skeleton for flag-dependent UI and swap it once the provider is ready. The preventing UI flicker during hydration guide covers the full set of strategies.

Can I initialize the SDK inside a React component rather than at the module level?

Technically yes, but it is a footgun: every component unmount/remount creates a new provider registration, and concurrent renders may call setProviderAndWait more than once. Initialize exactly once at the app entry point using the idempotent pattern in Step 2, then distribute the client via context.

Does the client SDK need the same flags as the server snapshot?

No. The snapshot covers only the flags needed before first render. The provider fetches the complete flag set from the remote once the streaming connection establishes. You do not need to enumerate every flag in the snapshot — only the ones whose absence would cause a visible layout difference on first paint.

How do I initialize the SDK for anonymous users before I know the targeting key?

Bootstrap with only the flags that do not depend on user identity — global kill-switches, layout toggles, anonymous experiments keyed on a stable device or session identifier. Resolve those on the server for the anonymous context and inline them as usual. Once the user authenticates or a stable anonymous ID is assigned, call OpenFeature.setContext() with the new targeting key; the provider re-evaluates and fires PROVIDER_CONFIGURATION_CHANGED, and your subscribed components pick up the personalized variants. The key discipline is that the pre-identity render must be correct on its own, because a real fraction of sessions never progress past it.

Should I initialize one shared client or a client per component?

Initialize the provider exactly once and share a single named client. OpenFeature’s client is a lightweight handle over a shared provider, so creating many clients does not multiply connections, but it does multiply the surface where someone forgets to await readiness or attaches a duplicate handler. A single client obtained through context, seeded by one idempotent initFlags(), keeps the connection count at one per tab and the readiness logic in one place. Use named clients only when genuinely distinct flag domains need different providers.

What happens if the bootstrap snapshot and the live stream disagree on a flag’s value?

The live value wins once the stream connects, and the flag visibly changes to match. If that change is jarring, the underlying problem is almost always that the snapshot was computed by a different code path or with a stale flag definition than the stream serves. Fix it at the source by having both read the same evaluation engine and definition set, rather than papering over it on the client. A brief, rare correction after a legitimate operator change is expected; a consistent flip on every page load means your two sources of truth have drifted and need reconciling.

Performance & Scale Considerations

The bundle cost of @openfeature/web-sdk is roughly 30–40 KB gzipped for the core client. Named imports and an SDK with a "sideEffects": false declaration in its package.json let your bundler eliminate evaluation branches you don’t use — see minimizing bundle size with tree-shakable SDKs for the specifics. For pages where flags are only needed below the fold, defer the full SDK init to after first paint — see lazy-initializing the client SDK after first paint.

Weigh that 30–40 KB against what the SDK buys you before reaching for it on a marketing landing page that toggles a single hero variant. If one flag drives one above-the-fold decision, the snapshot alone — read directly from window.__FLAG_BOOTSTRAP__ — may be all you ship on the critical path, with the full SDK loaded lazily only if and when live updates or additional flags are actually needed. The SDK earns its weight on application surfaces with many flags, live updates, and per-flag subscriptions; it is overkill for a static page whose one flag is decided at request time. Measuring the delta both ways in your own bundle analyzer, on your own routes, beats any general rule.

At scale, the cost that surprises teams is not the bundle but the connection count. Each tab that reaches the live phase opens a streaming connection to your flags host, so a popular page open in many tabs across many users is a standing fan-out your infrastructure must carry. Deferring the stream until after idle (as the phasing here does) smooths the connection storm that would otherwise coincide with every deploy or traffic spike, and consolidating to one connection per tab — rather than one per provider or per component — is why the idempotent single-init pattern matters for backend load, not just client correctness. If your flags host charges or rate-limits by concurrent connection, the streaming phase, not the SDK download, is your dominant cost.

Two levers to keep init off the critical path Tree-shaking trims the roughly thirty-to-forty kilobyte SDK to only the branches you use, and deferring init to after first paint protects the largest-contentful-paint metric on flag-below-the-fold pages. tree-shake ~30–40 KB gzipped core drop unused branches defer below-fold init init after first paint protects LCP
Tree-shaking shrinks the shipped SDK, and deferring init for below-the-fold flags keeps the largest-contentful-paint metric off the SDK's startup cost.