Eliminating Layout Shift from Async Flag Loads
This how-to is part of Preventing UI Flicker During Hydration. A flag value that arrives after first paint causes a variant swap: the correct component mounts, its dimensions differ from whatever was there before, and surrounding content jumps. The browser records this as a Cumulative Layout Shift (CLS) event. CLS above 0.1 hurts your Core Web Vitals score and, more importantly, disrupts users mid-read or mid-click. This how-to shows how to get that number to approximately zero without blocking the render.
Prerequisites
@openfeature/web-sdk≥ 1.x installed- UI flicker and the bootstrap pattern — this how-to assumes flags are already embedded server-side where possible
- client-side SDK initialization is deferred
Step-by-Step Procedure
Step 1 — Reserve space for every variant-dependent UI region
Before the flag value arrives, the container must already occupy the space the rendered component will fill. Set min-height to the tallest variant’s rendered height and contain: layout to prevent the reserved space from triggering global reflow.
/* styles/flag-slots.css */
/* Banner slot — visible on "on" variant, height 56px */
.flag-slot-banner {
min-height: 56px;
contain: layout; /* isolate from surrounding layout */
overflow: hidden; /* clip skeleton animation to the slot */
}
/* Pricing panel — switches between compact (240px) and expanded (380px) */
.flag-slot-pricing {
min-height: 380px; /* reserve the larger variant's height */
aspect-ratio: auto; /* reset any auto sizing */
contain: layout;
}
/* Hero image — use aspect-ratio to reserve proportional space */
.flag-slot-hero-image {
aspect-ratio: 16 / 9;
width: 100%;
contain: layout;
}
// BannerSlot.tsx
import { useFlag } from '@openfeature/react-sdk';
export function BannerSlot() {
const { value: showBanner } = useFlag('ui.nav.sticky-header', false);
return (
/* The slot always occupies 56px — banner mounts inside it, not below */
<div className="flag-slot-banner" aria-live="polite" aria-atomic="true">
{showBanner && <StickyBanner />}
</div>
);
}
Pitfall: using display: none on the off-variant removes the element from the flow entirely. When the on-variant mounts it pushes everything below it down — exactly the shift you are trying to prevent. Keep the container in the document flow at all times.
Step 2 — Render from a server-embedded bootstrap value so the first paint is already correct
If the page is server-rendered, resolve the flag server-side and embed the value so the client’s first render matches the server’s HTML. SSR flag consistency covers the full parity strategy; the essential piece here is that the bootstrap must be read synchronously before the React tree mounts.
// lib/bootstrapFlags.ts — called in getServerSideProps or a Server Component
import { OpenFeature, EvaluationContext } from '@openfeature/server-sdk';
export async function bootstrapForRequest(ctx: EvaluationContext) {
const client = OpenFeature.getClient();
return {
'ui.nav.sticky-header': await client.getBooleanValue('ui.nav.sticky-header', false, ctx),
'ui.checkout.new-summary': await client.getBooleanValue('ui.checkout.new-summary', false, ctx),
'ui.pricing.annual-toggle': await client.getBooleanValue('ui.pricing.annual-toggle', false, ctx),
};
}
<!-- Embedded in <head> before the app bundle -->
<script id="__FLAG_BOOTSTRAP__" type="application/json">
{"ui.nav.sticky-header":true,"ui.checkout.new-summary":false,"ui.pricing.annual-toggle":true}
</script>
The client provider reads this tag synchronously during initialization:
// lib/clientFlags.ts
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 initClientFlags() {
await OpenFeature.setProviderAndWait(
new FlagdWebProvider({ host: 'flagd.internal', port: 8013, tls: true, bootstrap: readBootstrap() })
);
}
With this in place, useFlag('ui.nav.sticky-header', false) returns the correct value on the very first render — before any network call completes — so the reserved slot is filled immediately and no swap occurs.
Pitfall: if the server context differs from the client context (e.g. the server uses a real session while the client bootstraps with anon), the bootstrap value will mismatch what the live SDK later resolves. Always pass the same targetingKey and attributes to both.
Step 3 — Hold a stable placeholder of identical dimensions for truly async values
Some flags legitimately cannot be resolved server-side — for example, a flag targeting a property that only exists in browser storage (a stored experiment assignment). For these, render a placeholder that occupies the exact same space as the real component while the flag resolves asynchronously.
// AsyncFlagSlot.tsx — placeholder matches real component dimensions
import { useFlag } from '@openfeature/react-sdk';
interface Props {
flagKey: string;
/** Pixel height of the real component in both variants */
slotHeight: number;
onContent: React.ReactNode;
offContent: React.ReactNode;
}
export function AsyncFlagSlot({ flagKey, slotHeight, onContent, offContent }: Props) {
const { value, isLoading } = useFlag(flagKey, false);
return (
<div
style={{ minHeight: slotHeight, contain: 'layout' }}
aria-busy={isLoading}
aria-live="polite"
>
{isLoading
? /* Placeholder: same height, no visible content, no shift */
<div style={{ height: slotHeight, background: '#F7F3EF', borderRadius: 6 }} />
: value ? onContent : offContent
}
</div>
);
}
// Usage
<AsyncFlagSlot
flagKey="ui.pricing.annual-toggle"
slotHeight={380}
onContent={<AnnualPricingPanel />}
offContent={<MonthlyPricingPanel />}
/>
The placeholder is invisible in practice when the bootstrap is present (because isLoading resolves to false before paint), but it acts as a safety net for the cases where the bootstrap is absent or the flag key is not covered.
Step 4 — Measure CLS before and after
Capture CLS in the field using the Layout Instability API and compare runs with and without the fix applied:
// analytics/cls.ts — send CLS to your metrics endpoint
let clsScore = 0;
const clsObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const shift = entry as LayoutShift;
if (!shift.hadRecentInput) {
clsScore += shift.value;
}
}
});
clsObserver.observe({ type: 'layout-shift', buffered: true });
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
navigator.sendBeacon('/metrics/cls', JSON.stringify({
cls: clsScore,
path: location.pathname,
flagBootstrap: !!document.getElementById('__FLAG_BOOTSTRAP__'),
}));
}
});
A Lighthouse CI gate makes the measurement automatic in your pull-request workflow:
# .lighthouserc.cjs
module.exports = {
ci: {
assert: {
assertions: {
'cumulative-layout-shift': ['error', { maxNumericValue: 0.05 }],
},
},
},
};
Set the threshold at 0.05 during rollout; tighten to 0.02 once the bootstrap is confirmed working across all pages.
Verification
After deploying the reserved-space fix and bootstrap embedding, verify CLS dropped to near-zero:
# One-shot Lighthouse measurement against staging
npx lighthouse https://staging.example.com \
--only-audits=cumulative-layout-shift \
--output=json \
| jq '.audits["cumulative-layout-shift"] | {score, numericValue, displayValue}'
Expected output with fix applied:
{
"score": 1,
"numericValue": 0.003,
"displayValue": "0.003"
}
If numericValue is still above 0.05, add console.log to the PerformanceObserver callback to identify which element is shifting (shift.sources[0].node in Chrome DevTools) and add a reserved slot for that element.
Gotchas & Edge Cases
- Two variants with very different heights: reserve the taller variant’s height. When the shorter variant renders, the extra whitespace is less disruptive than a shift. Alternatively, animate the height change with
transition: height 150ms easeso the shift is imperceptible even if it does occur. - Flags that control images or media: images without explicit
width/heightattributes cause CLS on their own. Combine flag-slot reservation with intrinsic-size attributes oraspect-ratioon the<img>tag so both the flag swap and the image load are covered. - Nested flag-gated components: if an outer and an inner component are both flag-gated, the inner reserved height must be included in the outer slot calculation, or the outer component’s size will jump when the inner one renders.
Troubleshooting & FAQ
CLS is zero in Lighthouse but non-zero in field data — why?
Lighthouse uses a synthetic, low-latency environment where the bootstrap loads before paint. Real users on slower connections or devices may experience the async path if the bootstrap fetch is delayed. Check the flagBootstrap field in your field CLS events to identify which sessions lack the bootstrap and investigate why the embedded tag is absent for those sessions.
The placeholder flashes briefly before the real component appears — how do I stop that?
The placeholder is only visible when isLoading is true, which should not happen if the bootstrap is correctly embedded and the provider initializes before the component mounts. If you see a flash, the provider initialization is completing after the first render. Move initClientFlags() to run earlier — before the React root mounts — rather than inside a useEffect.
Do I need reserved space if I am using React Suspense?
Suspense renders the fallback instead of the real component during loading, but the fallback’s dimensions must still match the real component’s dimensions to avoid a shift when Suspense resolves. Apply the same minHeight and contain: layout to the Suspense fallback element.