Avoiding Hydration Mismatch with Server-Evaluated Flags

This how-to is part of Server-Side Rendering Flag Consistency. It addresses one specific failure mode: the server renders variant A for a flag, the page arrives in the browser, the client SDK evaluates and returns variant B, and React throws Hydration failed because the server-rendered HTML didn't match the client.

The scenario is deterministic and reproducible: the server and client are evaluating independently. The server has the full user context and a warm rule set; the client SDK is still initializing and falls back to its default. These two evaluations return different values, React detects the DOM divergence, and the visible result is either a layout jump or a full re-render that discards the server’s work.

The fix is straightforward but must be applied in the right order: evaluate on the server, embed the resolved values in the HTML, and seed the client SDK from that embedded snapshot synchronously before React touches the DOM.

Server-render vs client-hydrate variant comparison Left side shows the broken state where server and client evaluate independently and mismatch; right side shows the correct state where both read from the same embedded snapshot. Broken Server eval variant: true Client SDK variant: false (default) Hydration error / layout jump server markup discarded Correct Server eval → inline snapshot { "web.checkout.express-pay": true } Server HTML variant: true Client SDK reads snapshot: true Hydration succeeds — no mismatch
Left: independent evaluation produces mismatched variants and a hydration error. Right: both sides read from the same inline snapshot and agree.

Prerequisites

Step-by-Step Procedure

Step 1 — Evaluate flags on the server with a stable context

Build the evaluation context from the server request before touching any flag. The context must be stable for the lifetime of the render — do not derive it from any browser-only value (window, localStorage, navigator) because those do not exist on the server.

// app/layout.tsx — server component (no 'use client' directive)
import { OpenFeature } from '@openfeature/server-sdk';
import { cookies } from 'next/headers';

async function getServerFlags() {
  const cookieStore = cookies();
  const ctx = {
    targetingKey: cookieStore.get('session_id')?.value ?? 'anon',
    plan: cookieStore.get('plan')?.value ?? 'free',
  };

  const client = OpenFeature.getClient('ssr');
  // Evaluate with a stable, server-side context
  return {
    'web.checkout.express-pay': await client.getBooleanValue('web.checkout.express-pay', false, ctx),
    'web.dashboard.new-nav': await client.getBooleanValue('web.dashboard.new-nav', false, ctx),
  };
}

This runs once per request, on the server. The returned object is the authoritative source of truth for this render. Cross-link: see SSR flag consistency for the broader architectural context.

Step 2 — Embed the resolved flag set as a bootstrap payload

Serialize the resolved map into the HTML before the React component tree renders. The payload must be present in the <head> or before the first flag-gated component so the client can read it before any JavaScript runs.

// app/layout.tsx (continued)
export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const flags = await getServerFlags();

  return (
    <html lang="en">
      <head>
        {/* Bootstrap payload: written by server, read by client before any JS runs */}
        <script
          id="__flag_bootstrap__"
          type="application/json"
          dangerouslySetInnerHTML={{ __html: JSON.stringify(flags) }}
        />
      </head>
      <body>{children}</body>
    </html>
  );
}

The type="application/json" prevents the browser from executing the script as JavaScript while making the text content accessible to DOM reads. Sanitize all values before serialization — they must be primitives (booleans, strings, numbers). Cross-link: see securely passing flags to the browser for payload hardening.

Step 3 — Initialize the client SDK from the snapshot synchronously before render

Read the bootstrap script tag and pass its contents to the provider before any client component evaluates a flag. The bootstrap option tells the provider to serve from this map for its initial reads, without waiting for the first network response.

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

export function initClientFlags() {
  const el = document.getElementById('__flag_bootstrap__');
  const bootstrap = el ? (JSON.parse(el.textContent ?? '{}') as Record<string, boolean | string>) : {};

  const provider = new FlagdWebProvider({
    host: 'flagd.internal',
    port: 8013,
    tls: true,
    bootstrap, // served synchronously — no network round-trip needed for initial reads
  });

  // setProvider is synchronous for the bootstrap reads;
  // the first live fetch happens in the background
  OpenFeature.setProvider('web', provider);
}

Call initClientFlags() inside the root client component’s module scope — not inside useEffect — so it runs before the component tree first renders. Cross-link: flicker prevention is a related concern handled by preventing UI flicker during hydration.

Step 4 — Gate any re-evaluation until after hydration completes

The critical window is between the client SDK initializing and React finishing hydration. If the SDK’s live fetch completes during that window and returns a different value, it can trigger a state change that React sees as a mismatch. Defer live updates with a useEffect:

// components/flag-sync-trigger.tsx
'use client';

import { useEffect } from 'react';
import { OpenFeature } from '@openfeature/web-sdk';

/**
 * Place this component near the root of the client tree.
 * useEffect only runs in the browser, after hydration,
 * so live updates never arrive mid-hydration.
 */
export function FlagSyncTrigger() {
  useEffect(() => {
    // Signal the provider that hydration is complete and live updates are welcome
    const provider = OpenFeature.getProvider('web') as { enableLiveUpdates?: () => void };
    provider.enableLiveUpdates?.();
  }, []);

  return null;
}

Render <FlagSyncTrigger /> as the last child in the root layout so it runs after all other client components have hydrated.

Verification

Open Chrome DevTools and check two things after implementing the pattern:

  1. No hydration warning in the console. The warning text begins with Warning: Hydration failed because the server rendered HTML didn't match the client. If it appears, a component is still reading from the SDK before the bootstrap is applied — trace the component and confirm it uses the bootstrap context.

  2. Identical server and client markup. Use DevTools’ Elements panel to compare the initial HTML (viewable via “View page source”) with the live DOM after JavaScript runs. Flag-gated elements (buttons, banners, nav items) should be identical in both.

# Automated: compare SSR output against a client-hydrated snapshot
curl -s http://localhost:3000/dashboard \
  | pup 'script#__flag_bootstrap__' \
  | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['web.dashboard.new-nav'])"
# Expect: True (matching the server-evaluated value for this session)

Gotchas & Edge Cases

Troubleshooting & FAQ

I applied the pattern and the warning is gone in dev but appears in production. Why?

Dev builds run with React Strict Mode, which double-invokes renders and often surfaces mismatches that coincidentally pass in production. In production the inverse is true: caching, CDN layers, or SSR streaming can cause the server HTML to be emitted in chunks, and a flag evaluated in a later chunk may not be in the bootstrap written in the first chunk. Resolve all flags before streaming begins, or use Suspense boundaries carefully.

The bootstrap reads false but the live SDK returns true right after mount. How do I debug the divergence?

Check whether the server-side provider and the live control plane are serving the same flag configuration. Log the evaluation context on the server (Step 1) and compare it against the context the client SDK sends on its first fetch. A difference in targetingKey or plan is almost always the cause — the session cookie is not being passed correctly to the server component, so the server evaluates for the wrong user.

Can I use this pattern with server-side streaming (React 18 renderToPipeableStream)?

Yes, but place the __flag_bootstrap__ script in the non-streaming shell (the part of the document emitted before any Suspense boundaries resolve). If the bootstrap is inside a Suspense boundary, it arrives after the client has already started hydrating the shell, which defeats its purpose.