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.
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.
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).
Prerequisites
@openfeature/web-sdk≥ 1.x installed in your frontend bundle- Client-side SDK initialization understood — this guide builds on it
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.
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.
// 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
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.
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.
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.
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.
Verification & Testing
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.