Lazy-Initializing the Client SDK After First Paint

This how-to is part of Client-Side SDK Initialization Best Practices. Eagerly loading the feature flag SDK on page start forces the browser to parse and execute its JavaScript before it can finish rendering above-the-fold content, directly delaying Largest Contentful Paint. But deferring init entirely until after a user interaction is too late for most below-the-fold flags. The solution is a small server-embedded bootstrap set for the first render, combined with a full SDK init that runs during browser idle time after the first paint has landed.

Lazy SDK init paint timeline A horizontal timeline showing first paint at the start, browser idle callback firing after the paint, full SDK initialization starting during idle, and live updates arriving once the SDK is connected. First paint bootstrap flags t=0 Idle callback dynamic import fires t+idle SDK init provider ready t+idle+net Live updates streaming active t+stream LCP window — SDK must not block Lazy SDK init: first paint → idle → SDK ready → live
The browser renders above-the-fold content from the bootstrap snapshot; the full SDK init runs in idle time and does not compete with LCP resources.

Prerequisites

Step-by-Step Procedure

Step 1 — Render above-the-fold content from a server-embedded bootstrap set

The server resolves only the flags that affect content visible before a user scrolls and writes them into the HTML. The client reads these from window.__FLAG_BOOTSTRAP__ with no network call, so there is nothing for the SDK to do before first paint.

// server.ts — Next.js / Express render handler
import { evaluateBootstrapFlags } from './flag-server';

export async function renderPage(req: Request): Promise<string> {
  // Only above-the-fold flags — keep this list short
  const snapshot = await evaluateBootstrapFlags(req.session.userId, [
    'web.nav.new-header',       // navigation variant
    'web.hero.experiment-v2',   // hero banner variant
  ]);

  const json = JSON.stringify(snapshot);
  return `<script>window.__FLAG_BOOTSTRAP__ = ${json};</script>`;
}
// Above-the-fold component — reads from the snapshot synchronously
const bootstrap: Record<string, boolean> =
  (window as any).__FLAG_BOOTSTRAP__ ?? {};

const showNewHeader = bootstrap['web.nav.new-header'] ?? false;
const showHeroV2    = bootstrap['web.hero.experiment-v2'] ?? false;

The component has no dependency on the SDK at all at this stage — it reads a plain object. This means there is zero SDK parse cost on the critical path.

Pitfall: inlining too many flags bloats the HTML payload and slows Time to First Byte. Limit the bootstrap set to the three to five flags that visibly affect above-the-fold layout.

Step 2 — Defer full SDK init to after first paint using requestIdleCallback

Schedule the full SDK initialization to run during the browser’s first idle period. Use a dynamic import so the SDK chunk itself is not even fetched until that moment.

// app-entry.ts
type IdleDeadline = { timeRemaining: () => number; didTimeout: boolean };

function scheduleSDKInit(): void {
  const init = async () => {
    // Dynamic import: the browser fetches the SDK chunk only now,
    // after first paint. The chunk name comes from your bundler config.
    const { initFlags } = await import(
      /* webpackChunkName: "flag-sdk" */
      './flag-client'
    );
    await initFlags();
    console.debug('[flags] SDK ready after idle');
  };

  if ('requestIdleCallback' in window) {
    window.requestIdleCallback(
      (deadline: IdleDeadline) => {
        // If the browser is under pressure, timeout forces init anyway
        // so we never wait forever
        if (deadline.timeRemaining() > 10 || deadline.didTimeout) {
          init();
        }
      },
      { timeout: 4000 } // force init within 4 s even under heavy load
    );
  } else {
    // Fallback: next task after render
    setTimeout(init, 1);
  }
}

// Call from the app entry point — not inside any component
scheduleSDKInit();

requestIdleCallback fires between frames when the main thread is not busy. The timeout: 4000 option ensures the SDK init is not indefinitely deferred on slow devices.

Pitfall: do not defer inside a useEffect. React effects run after every render cycle which can delay init further on pages with many effects, or run it multiple times. Call scheduleSDKInit() once at the module level.

Step 3 — Subscribe for live updates once the SDK is idle-initialized

After the SDK is ready, attach the live-update listener. Components that read below-the-fold flags will pick up the correct variant when they enter the viewport, because by that point the SDK is initialized and streaming.

// flag-client.ts — the module loaded via dynamic import
import { OpenFeature } from '@openfeature/web-sdk';
import { FlagdWebProvider } from '@openfeature/flagd-web-provider';

let initPromise: Promise<void> | null = null;

export function initFlags(): Promise<void> {
  if (initPromise) return initPromise;

  const bootstrap: Record<string, boolean | string | number> =
    (window as any).__FLAG_BOOTSTRAP__ ?? {};

  const provider = new FlagdWebProvider({
    host: 'flags.example.com',
    port: 443,
    tls: true,
    cache: { initialValues: bootstrap },
  });

  initPromise = OpenFeature.setProviderAndWait(provider).then(() => {
    // Attach live-update listener immediately after init
    const client = OpenFeature.getClient();
    client.addHandler('PROVIDER_CONFIGURATION_CHANGED', () => {
      // Dispatch a custom event so any interested component can re-render
      window.dispatchEvent(new CustomEvent('flags:updated'));
    });
  });

  return initPromise;
}

For components that render below the fold, read flags via a hook that listens to the flags:updated event. Those components render after scroll so by then the idle callback will have fired and the SDK will be ready.

Pitfall: a component that renders above the fold must never depend on lazy-initialized flags. Keep the above-the-fold / below-the-fold boundary explicit and document which flags belong in the server snapshot.

Step 4 — Avoid layout shift when flag values arrive

When the SDK is ready and a below-the-fold component first evaluates a flag, it must not cause a visible content jump. Reserve space for flag-gated content using CSS before the SDK is ready, then fill it in.

// BelowFoldFeature.tsx
import { useState, useEffect } from 'react';
import { OpenFeature } from '@openfeature/web-sdk';

export function BelowFoldFeature() {
  const [sdkReady, setSDKReady] = useState(false);
  const [showFeature, setShowFeature] = useState(false);

  useEffect(() => {
    // Listen for the event dispatched after idle init completes
    function onSDKReady() {
      const client = OpenFeature.getClient();
      // web.dashboard.new-widget — a below-the-fold flag
      setShowFeature(client.getBooleanValue('web.dashboard.new-widget', false));
      setSDKReady(true);
    }

    // If init already finished before this component mounted
    if ((window as any).__FLAG_SDK_READY__) {
      onSDKReady();
    } else {
      window.addEventListener('flags:updated', onSDKReady, { once: true });
      return () => window.removeEventListener('flags:updated', onSDKReady);
    }
  }, []);

  // Reserve a fixed height to prevent CLS when content swaps in
  return (
    <div style={{ minHeight: 120 }}>
      {sdkReady ? (
        showFeature ? <NewWidget /> : <LegacyWidget />
      ) : (
        <div aria-hidden="true" style={{ height: 120, background: '#F7F3EF' }} />
      )}
    </div>
  );
}

The minHeight matches the tallest possible variant so there is no reflow when the flag resolves. This is the same defensive pattern used for UI flicker and layout shift prevention.

Pitfall: do not use visibility: hidden as the skeleton — screen readers still announce hidden elements. Use aria-hidden="true" on a placeholder that matches the expected dimensions.

Verification

Confirm the SDK does not block LCP with Lighthouse and the Performance panel:

# Run Lighthouse against a local production build
npx lighthouse http://localhost:3000 --only-categories=performance --output=json \
  | jq '{lcp: .audits["largest-contentful-paint"].displayValue,
          fcp: .audits["first-contentful-paint"].displayValue}'

# Expected: LCP < 2.5 s; flag SDK requests absent from the waterfall before LCP

In the Chrome Performance panel, record a cold load and verify:

  1. No @openfeature/web-sdk chunk appears in the waterfall before the LCP element is painted.
  2. The requestIdleCallback fires after the LCP marker.
  3. A single streaming connection to your flags host opens after the idle period.

Gotchas & Edge Cases

Troubleshooting & FAQ

My below-the-fold component still shows the default variant after scrolling into view.

The flags:updated event fired before the component mounted, so the { once: true } listener was never attached. Check whether window.__FLAG_SDK_READY__ is being set after init completes, and add the synchronous fallback branch (shown in Step 4). Alternatively, store the initFlags() promise at module scope and await it inside the component’s effect instead of relying on the custom event.

The requestIdleCallback never fires on my test device.

Under CPU throttling in DevTools idle time shrinks dramatically and the callback may time out. Verify the timeout option is set so the browser is forced to run the callback eventually. On very low-end devices, setTimeout(init, 1) fires more reliably than the idle callback.

Does lazy init break secure browser delivery of the flag payload?

No. The bootstrap snapshot is inlined by the server before the idle callback runs. The SDK only fetches the full flag set from the remote endpoint during idle init, and that request is governed by the same CSP and authentication headers as an eager init. The lazy scheduling does not change what is fetched — only when.