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

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

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.