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.

Layout-shift-before vs reserved-space-after comparison Two side-by-side diagrams show a page before and after the reserved-space fix. Before: content jumps down when a flag-gated banner mounts. After: a reserved slot holds the space so no shift occurs. Before — no reserved space t=0 First paint Hero content Body paragraph… flag resolves t=Δ Banner mounts → shift Flag-gated banner (NEW) Hero content (pushed down) After — reserved space t=0 First paint Reserved slot (min-height: 44px) Hero content Body paragraph… flag resolves t=Δ Banner fills slot — no shift Flag-gated banner (in slot)
Without a reserved slot, mounting the flag-gated banner pushes all subsequent content down. With a reserved slot of matching height, the banner fills pre-existing space and nothing moves.

Prerequisites

Prerequisites for eliminating layout shift You need the web SDK installed, a server render path for bootstrapping, and a CLS measurement pipeline. Web SDK installed reads flag values Server render path for the bootstrap CLS pipeline field or Lighthouse CI
The reserved-space technique works without a server render path, but the bootstrap is what removes shift entirely — hence it is listed as the middle prerequisite.

Step-by-Step Procedure

The four steps layer three defenses — reserved space, a correct bootstrap, and a stable placeholder — then measure the result.

The four procedure steps Reserve space with min-height, render from the server-embedded bootstrap, hold a same-size placeholder for truly async flags, then measure CLS. 1 Reserve space min-height 2 Bootstrap correct first paint 3 Placeholder same dimensions 4 Measure CLS before / after
Reserved space alone caps the shift; the bootstrap and placeholder drive it to zero, and step 4 confirms it.

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

CLS before and after the fix A before bar shows cumulative layout shift around 0.18 in the failing range; the after bar shows about 0.003, well inside the good threshold of 0.1. good threshold 0.10 before CLS 0.18 after CLS 0.003
The reserved slot plus bootstrap moves the score from a failing 0.18 to a negligible 0.003 — the number Lighthouse should report on staging.

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

Three residual shift sources Mismatched variant heights, unsized images, and nested flag-gated components each cause layout shift even after the primary fix; each has its own reservation strategy. Mismatched heights reserve the taller variant or animate Unsized images add width/height or aspect-ratio Nested gating include inner height in outer slot
Each residual source has its own fix; the outer-slot rule for nested gating is the one most often missed.

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.