Preventing UI Flicker During Hydration

This guide is part of the Frontend Integration & Client-Side Rendering series. The moment a React or Vue component reads a feature flag whose value arrives after the first paint, you risk a visible variant swap — a flash of the default, a jump in layout, or a hydration warning in the console. This guide explains why that happens, how to stop it with server-embedded state, and how to prevent any residual async loads from shifting the layout.

The flicker is deceptively expensive. It is not merely cosmetic — a variant swap that fires after paint counts against your Cumulative Layout Shift score, which is a ranking signal and a Core Web Vitals gate, and it degrades perceived performance because the user watches the interface rearrange itself under their cursor. Worse, the swap is timing-dependent: it appears on a throttled mobile connection and vanishes on a fast office network, so it slips through local testing and only surfaces in field data weeks later. The fix is structural rather than a tuning knob — you close the timing gap once, at the transport layer, and every flag-gated component downstream inherits a stable first paint. Everything below assumes the canonical OpenFeature + flagd stack, but the pattern is provider-agnostic: any SDK that exposes a synchronous cache and a way to seed it will do.

Flicker vs no-flicker render timeline comparison Two horizontal timelines show that without server-embedded flag state a variant swap occurs after first paint causing visible flicker; with embedded state the correct variant renders from the start. Without server-embedded flag state SSR / first paint default variant SDK init (async) flags resolving… Variant swap visible flicker / CLS ⚠ flicker With server-embedded flag state SSR / first paint correct variant (bootstrap) SDK hydrates silently no DOM change, no shift ✓ stable CLS contribution: without bootstrap ≈ 0.15 · with bootstrap ≈ 0.00
Server-embedding the flag bootstrap state ensures the first paint already shows the correct variant, eliminating the async variant swap that causes layout shift.

Problem Framing

When a component reads a flag value during hydration, the framework compares the server-rendered HTML against the React tree generated on the client. If the client SDK has not yet resolved the flag — because the fetch is still in flight — the component falls back to the coded default. The server rendered variant="on", the client renders variant="off", and React either throws a hydration warning or silently patches the DOM, both of which produce a visible jump.

The subtle part is that React’s reconciliation makes the failure mode worse than a plain race. During hydration React does not re-run your render and diff the output the way it does for a normal update; on React 18 it attaches to the existing server markup and, on detecting a text or attribute mismatch, it discards the server-rendered subtree and re-renders it on the client. That means the flicker is not a gentle attribute patch — it is a full unmount-and-remount of the affected branch, which resets component state, restarts CSS transitions, and can retrigger useEffect hooks. A flag mismatch inside a large subtree therefore throws away work the server already did and forces the browser to repaint a region it had already committed. Understanding this is what justifies the effort of server-embedding: you are not shaving milliseconds, you are preventing React from tearing down and rebuilding a chunk of the page.

This guide covers flash-of-default-variant, blocking vs deferred flag reads, server-embedded initial state, skeleton patterns, and Cumulative Layout Shift (CLS). It does not cover the full Next.js App Router hydration flow (see Next.js App Router feature flag hydration) or how to ensure long-term SSR/CSR parity across multiple routes (see SSR flag consistency).

Why the mismatch happens The server renders the resolved variant on, but the client SDK has not finished loading so it falls back to the coded default off; React reconciles the difference and produces a visible swap. Server render variant = on Client, SDK still loading falls back = off React reconciles visible swap The gap between the server's knowledge and the client's is where flicker lives.
The server already knows the variant; the flicker is purely the client failing to recover that knowledge before first paint.

Prerequisites

Prerequisites at a glance Three capabilities are required: a web SDK in the bundle, a server evaluation path that can embed state, and a way to measure CLS before and after. Web SDK in bundle @openfeature/web-sdk Server embed path writes into HTML shell CLS measurement Lighthouse or field
Without the server embed path you cannot close the timing gap; without CLS measurement you cannot prove you did.

Core Concept & Architecture

The root problem is a timing gap: the server knows the resolved flag value, but the client does not recover that knowledge before the first paint. Closing that gap requires server-to-client state transfer — the server serializes its resolved variants into the HTML response and the client reads them synchronously before the React tree mounts.

Server-to-client state transfer The server resolves variants and serializes them into an inline JSON script tag; the client provider reads that tag synchronously during initialization so the first evaluation resolves from memory rather than the network. Server Client Resolve variants server SDK, real context Serialize to JSON tag type="application/json" Read tag synchronously before React mounts First eval from memory no network round-trip
The inline JSON tag is the transport: the server writes it, the client reads it before mount, and the first evaluation never touches the network.

Two transport options exist:

Approach When to use Trade-off
Inline JSON script tag Every SSR framework Adds a small HTML payload; most reliable
HTTP response header Edge middleware only No HTML footprint; harder to consume in deep components
Cookie (pre-rendered) Persistent sessions Works without SSR; leaks variant names

The inline JSON approach works in all frameworks and is the canonical pattern here. The tag uses type="application/json" so the browser never executes it; the SDK reads it synchronously during useState initialization. The type="application/json" choice is not incidental — a plain <script> that assigns to a global would run as JavaScript, which means it needs a Content-Security-Policy nonce under a strict CSP and it exposes you to injection if any flag key or value is attacker-influenced. A JSON script tag is inert data: the browser parses it as text, never as code, so it is safe to emit without a nonce even under script-src 'self'. Read it with JSON.parse(el.textContent), never eval.

Keep the payload minimal. Embed only the flags the initial render actually reads, not your entire flag catalogue — every key you serialize is bytes on the critical path and a variant name visible in view-source. If the bootstrap grows past a few dozen keys, split it per-route so each page ships only what it renders, and consult Securely Passing Flags to the Browser for how to strip internal metadata and avoid leaking targeting logic. A good rule of thumb: if a flag is not read above the fold on the first paint, it does not belong in the bootstrap — let the live SDK resolve it after hydration.

// server.ts — Next.js Route Handler or getServerSideProps
import { OpenFeature } from '@openfeature/server-sdk';

export async function getServerSideProps() {
  const client = OpenFeature.getClient();
  const ctx = { targetingKey: 'anon' }; // replace with real session

  const bootstrap = {
    'ui.checkout.new-summary':   await client.getBooleanValue('ui.checkout.new-summary', false, ctx),
    'ui.nav.sticky-header':      await client.getBooleanValue('ui.nav.sticky-header', false, ctx),
    'ui.pricing.annual-toggle':  await client.getBooleanValue('ui.pricing.annual-toggle', false, ctx),
  };

  return { props: { flagBootstrap: bootstrap } };
}
// _document.tsx — embed the payload before the React root
export default function Document({ flagBootstrap }: { flagBootstrap: Record<string, boolean> }) {
  return (
    <Html>
      <Head />
      <body>
        {/* type="application/json" — never executed, safe without nonce */}
        <script
          id="__FLAG_BOOTSTRAP__"
          type="application/json"
          dangerouslySetInnerHTML={{ __html: JSON.stringify(flagBootstrap) }}
        />
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

On the client, the provider reads the tag synchronously during initialization so the first evaluation call resolves from memory, not from a network fetch:

// flagProvider.ts — initialize with bootstrap state
import { OpenFeature } from '@openfeature/web-sdk';
import { FlagdWebProvider } from '@openfeature/flagd-web-provider';

function readBootstrap(): Record<string, boolean> {
  try {
    const el = document.getElementById('__FLAG_BOOTSTRAP__');
    return el ? JSON.parse(el.textContent ?? '{}') : {};
  } catch { return {}; }
}

export async function initFlags() {
  const bootstrap = readBootstrap();
  const provider = new FlagdWebProvider({
    host: 'flagd.internal',
    port: 8013,
    tls: true,
    // bootstrap pre-populates the cache — first evaluations are synchronous
    bootstrap,
  });
  await OpenFeature.setProviderAndWait(provider);
}

Step-by-Step Implementation

The five steps move from server embed to client init to layout stability to measurement — each one closes a distinct source of shift.

The five implementation steps Embed the bootstrap, initialize the client provider from it, reserve space for variant-dependent UI, use skeletons for always-async components, then measure CLS. 1 Embed bootstrap JSON 2 Init client from bootstrap 3 Reserve space min-height 4 Skeletons async slots 5 Measure CLS below 0.05
Steps 1–2 remove the variant swap; steps 3–4 remove residual layout shift; step 5 proves both are gone.

Step 1 — Evaluate flags on the server and embed the bootstrap

Resolve all flags needed for the initial render server-side and serialize them into the HTML. Use the real user context (session ID, user ID, tenant) so the resolved variants match what the user will see on subsequent client-side navigations.

// lib/flagBootstrap.ts — shared server utility
import { OpenFeature, EvaluationContext } from '@openfeature/server-sdk';

const FLAG_KEYS = [
  'ui.checkout.new-summary',
  'ui.nav.sticky-header',
  'ui.pricing.annual-toggle',
] as const;

export async function resolveBootstrap(ctx: EvaluationContext) {
  const client = OpenFeature.getClient();
  const entries = await Promise.all(
    FLAG_KEYS.map(async (key) => [key, await client.getBooleanValue(key, false, ctx)])
  );
  return Object.fromEntries(entries);
}

Pitfall: resolving flags inside getServerSideProps for every page adds latency if the provider is remote. Use a local in-process provider with server-side SDK integration patterns so resolution is sub-millisecond.

Two details make or break this step. First, resolve all keys in parallel with Promise.all, as the utility above does — resolving them sequentially serializes the latency, and on a page that reads eight flags against a provider with a 3ms round-trip you turn a 3ms hit into a 24ms one that sits directly on time-to-first-byte. Second, the context you pass here must be the same context the client will later use for live evaluations. If the server keys targeting on a signed session cookie but the client provider initializes with an anonymous context, the bootstrap and the post-hydration re-evaluation can disagree, and you have simply moved the flicker from first paint to the first live flag update. Extract the context once, server-side, and pass the same object (or the fields needed to reconstruct it) down through the bootstrap. For percentage rollouts this matters doubly: the bucketing hash is computed from the targeting key, so a mismatched key does not just change one flag — it can flip the user into a different rollout bucket entirely.

Step 2 — Initialize the client provider from the bootstrap

The client provider must consume the bootstrap before the React tree mounts. In Next.js App Router, this means calling initFlags() in a Client Component that wraps the root layout. The provider must be ready before children evaluate any flag.

// components/FlagProvider.tsx — client component
'use client';
import { useEffect, useState } from 'react';
import { OpenFeatureProvider } from '@openfeature/react-sdk';
import { initFlags } from '@/lib/flagProvider';

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

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

  // Render children immediately — bootstrap gives them synchronous values.
  // The `ready` state gates only post-init live updates, not the initial render.
  return (
    <OpenFeatureProvider>
      {children}
    </OpenFeatureProvider>
  );
}

Pitfall: blocking the render on ready replaces flag flicker with a blank screen. Return children immediately — the bootstrap ensures they already have the correct variant values.

The mental model that keeps this correct: the bootstrap is your source of truth for the first paint, and the live provider is your source of truth for everything after. The ready flag exists only so that once the real provider connects you can, if you choose, react to flag changes that arrived while the page was open — a config edit, a kill-switch flip, a rollout percentage bump. It must never gate the initial tree. A common regression is to wrap children in {ready ? children : <Spinner/>} “just to be safe”; that single line reintroduces a blank frame on every navigation and is often the actual cause of a CLS number that refuses to drop below 0.1. If you need to distinguish “rendered from bootstrap” from “rendered from live provider” for analytics, expose it as a passive attribute or context value, not as a render gate. And when the live provider does connect, let it patch silently: because the bootstrap already matched the server, the reconciliation is a no-op and no DOM changes — the whole point is that the user never sees the hand-off.

Step 3 — Reserve space for variant-dependent UI

Some flag-gated components differ in size between variants. Even with a correct bootstrap value, if the component is lazy-loaded or conditionally rendered, the layout can shift when it mounts. Reserve the space it will occupy before the component loads.

/* Reserve exact height so the layout does not shift on mount */
.flag-gated-banner {
  min-height: 56px;          /* matches the rendered component height */
  contain: layout;           /* browser skips costly cross-element reflow */
}

.flag-gated-banner:empty::before {
  content: '';
  display: block;
  height: 56px;
}
// BannerSlot.tsx — stable container that never changes size
export function BannerSlot() {
  const showBanner = useFeatureFlag('ui.nav.sticky-header');
  return (
    <div className="flag-gated-banner" aria-live="polite">
      {showBanner && <StickyHeaderBanner />}
    </div>
  );
}

Pitfall: using display: none for the off-variant removes the element from flow entirely. When the on-variant mounts it pushes content down. Use a fixed-height placeholder instead so the surrounding layout does not move.

The contain: layout declaration earns its place here for a specific reason: it tells the browser that nothing inside the container can affect the geometry of anything outside it, so when the flag-gated child finally mounts, the reflow is scoped to the container’s own box instead of rippling out to reposition the footer and everything between. On a long page with many gated slots that containment is the difference between one small, contained shift and a cascade of shifts that each register separately in your CLS budget. Be honest about the reserved height, though — min-height that is too small still shifts when the real content is taller, and min-height that is too large leaves a permanent gap that hurts the design. Measure the rendered variant at your common breakpoints and reserve the tallest, since a shift that only fires at 375px wide is still a shift for the majority of mobile traffic. Where the two variants differ substantially in height, prefer reserving the larger and letting the smaller sit inside whitespace rather than animating the container, because an animated height change is itself a layout shift unless it is driven by transform, which does not affect layout.

Step 4 — Use skeletons for components that are always async

Some flag-gated components require their own async data fetch regardless of the flag value. A skeleton with the same dimensions as the real component prevents layout shift during that fetch.

// PricingPanel.tsx — skeleton matches real component dimensions
import { Suspense } from 'react';

function PricingSkeleton() {
  return (
    <div
      className="pricing-skeleton"
      aria-busy="true"
      aria-label="Loading pricing options"
      style={{ height: '320px', borderRadius: '8px', background: '#F7F3EF' }}
    />
  );
}

export function PricingPanel() {
  const showAnnual = useFeatureFlag('ui.pricing.annual-toggle');
  return (
    <Suspense fallback={<PricingSkeleton />}>
      <PricingContent showAnnual={showAnnual} />
    </Suspense>
  );
}

Step 5 — Measure CLS before shipping

Use Lighthouse or the PerformanceObserver Layout Instability API to confirm the embed eliminated the shift.

// measure-cls.ts — field measurement utility
const clsEntries: PerformanceEntry[] = [];

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!(entry as LayoutShift).hadRecentInput) {
      clsEntries.push(entry);
    }
  }
});
observer.observe({ type: 'layout-shift', buffered: true });

window.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    const cls = clsEntries.reduce((sum, e) => sum + (e as LayoutShift).value, 0);
    navigator.sendBeacon('/analytics/cls', JSON.stringify({
      cls,
      page: location.pathname,
    }));
  }
});

Target CLS below 0.1 (Google’s “Good” threshold). Flag-induced shifts typically appear in the 0.05–0.25 range before this fix; after embedding the bootstrap they should read 0.00–0.02.

Two measurement traps are worth calling out. First, CLS is a session metric, not a single-load number: the Layout Instability API keeps accumulating shift values for the lifetime of the page, and Google’s field metric reports the largest burst within a session window. Measuring only the first few hundred milliseconds after load will miss a flag update that fires when a config change propagates thirty seconds in — beacon the score at visibilitychange as the snippet does, not on a fixed timer. Second, hadRecentInput is your friend and your enemy: shifts within 500ms of a user interaction are excluded from CLS because they are assumed to be intentional (an accordion opening, say), which correctly ignores a click-driven expansion but can also mask a genuine flag-driven shift that happens to land just after a scroll. When you are hunting a flicker in development, log every entry including the ones with hadRecentInput: true so you see the full picture; only exclude them when you compute the score that must match the field. Finally, always test on a throttled connection — the bug is invisible at full speed because the SDK resolves before paint, so a passing local run on fast Wi-Fi proves nothing.

Verification & Testing

Two-gate verification for flicker A throttled Playwright run asserts measured CLS stays below 0.05 with the flag-gated element present, and a Lighthouse CI audit fails the build if cumulative layout shift regresses. Playwright, throttled 3G delay SDK relative to paint CLS < 0.05 Lighthouse CI gate audits cumulative-layout-shift fail build on regress
The Playwright test proves the fix under adverse timing; the Lighthouse gate stops a future change from silently reintroducing the shift.

Run a Playwright test that loads the page with network throttling and measures shift:

// tests/hydration.spec.ts
import { test, expect } from '@playwright/test';

test('flag-gated banner does not shift layout', async ({ page }) => {
  // Simulate slow 3G so async SDK init is delayed relative to paint
  await page.route('**/flagd/**', route => setTimeout(() => route.continue(), 800));

  await page.goto('/');
  await page.waitForLoadState('networkidle');

  // Measure CLS via the exposed field metric
  const cls = await page.evaluate(() =>
    (window as any).__CLS_VALUE__ ?? 0
  );
  expect(cls).toBeLessThan(0.05);
  expect(await page.locator('.flag-gated-banner').count()).toBeGreaterThan(0);
});

Also run npx lighthouse http://localhost:3000 --output=json | jq '.audits["cumulative-layout-shift"].numericValue' as a CI gate.

Troubleshooting & FAQ

Why do I still see a hydration warning even with the bootstrap?

The bootstrap value must exactly match what the server rendered. If the server evaluated the flag with targetingKey: session-abc and the client bootstrap was built with targetingKey: anon, the values can differ. Trace the flag key resolution on both sides and confirm the evaluation contexts are identical.

The flicker is gone but the banner height jumps on slow connections — why?

The banner content itself (images, text loaded asynchronously) is causing the shift, not the flag value. Lock the container to the final rendered height with min-height and contain: layout so the internal loading does not affect surrounding elements.

Should I block rendering until the SDK is fully initialized?

No. Blocking on SDK initialization trades flag flicker for a blank-screen delay, which is worse for both CLS and LCP. The bootstrap gives you the correct variant values synchronously; render immediately with those values and let the live SDK take over silently after hydration.

How do I handle a flag that controls which of two differently-sized components renders?

Reserve the larger variant’s height in the container so the smaller variant never causes a collapse. Alternatively, render both variants positioned absolutely in the same container and show only the active one with visibility rather than display — the container retains the larger height either way.

Does the bootstrap approach work with React Server Components and streaming SSR?

Yes, but the timing changes. With streaming SSR the shell flushes before all data resolves, so you must emit the bootstrap script tag in the document head or the earliest flushed chunk, before any flag-reading component streams in. If you resolve flags inside a Suspense boundary that flushes late, the client can hydrate an earlier chunk before the bootstrap arrives and you are back to a mismatch. Resolve the flags needed for the initial render at the top of the request, embed them in the first flush, and keep late-streaming boundaries for data that genuinely cannot be known up front.

How do I keep the bootstrap in sync when a flag changes mid-session?

You do not try to — the bootstrap is a point-in-time snapshot for the first paint only, and the live provider owns every change after that. When a flag flips server-side while a tab is open, the connected web provider receives the update over its streaming channel and re-evaluates; your components re-render with the new value through the normal React update path, which is a controlled state change, not a hydration mismatch. The only rule is to never re-read the stale bootstrap tag after initialization. If you need the DOM tag to reflect later state for a subsequent soft navigation, refresh it from the live provider rather than trusting the server-rendered original.

Can I use cookies instead of an inline script tag to carry the bootstrap?

You can, and it is the one option that works without SSR because the cookie is available to the client on the very first request. The trade-offs are real, though: cookies are sent on every request to your origin, so a large flag payload inflates every HTTP request, not just the document; the values are visible and mutable by the user; and variant names leak in plain sight unless you hash or encode them. Reserve the cookie approach for a small number of stable, non-sensitive flags on statically rendered pages. For anything server-rendered, the inline JSON tag is lighter on the wire and does not tax subsequent requests.

Why does the flicker appear only for logged-in users?

Almost always because anonymous users resolve to the coded default on both server and client — the values happen to agree — while authenticated users resolve to a targeted variant on the server that the client cannot reproduce before the SDK loads. The mismatch is therefore invisible until a real session context is present. Confirm by comparing the bootstrap payload for a logged-in versus anonymous load; if the authenticated payload carries a non-default variant that the coded fallback does not match, that gap is your flicker. The fix is the same embed pattern, just make sure the server evaluates with the authenticated context and the client reads that exact bootstrap.