Fallback UI and Skeleton Strategies for Streaming Islands

When a streaming SSR response pauses — waiting for a database query or a third-party API — the browser holds an incomplete DOM tree. Without deliberate fallback UI, users see blank regions, layout jumps, and unresponsive controls. This page explains how to design skeleton screens and error fallbacks that eliminate those problems: by reserving layout space before data arrives, transferring serialised state across the server-client boundary, and buffering interactions until hydration completes.

Performance engineers and SaaS teams reach this page when a seemingly smooth streaming setup still registers non-zero Cumulative Layout Shift (CLS) or when user clicks during the skeleton phase are silently dropped.

Concept Definition & Scope

A fallback UI is any markup the server renders in place of unresolved content. A skeleton screen is a specific fallback that mimics the shape of the eventual component using muted, unanimated or subtly animated placeholder shapes. Both exist to cover the hydration gap — the period between First Contentful Paint and the moment an island becomes interactive.

What this page covers:

  • Skeleton authoring and deterministic layout reservation
  • Hydration boundary markers and state serialisation
  • Interaction buffering during the skeleton phase
  • CLS, INP, and hydration-gap measurement

What is out of scope: error boundary design for caught runtime exceptions, loading spinners unrelated to streaming, and client-only useEffect loading states that never touch the server render path.

This topic sits inside the broader Server-Client Boundaries & State Synchronization area, which covers how data, props, and events travel across the execution divide between server and browser.

Streaming SSR and the Skeleton’s Role

Streaming SSR changes how HTML reaches the browser. Rather than waiting for a complete document, the server emits chunks over Transfer-Encoding: chunked as data resolves. Each chunk maps to a rendering boundary — typically a <Suspense> node in React or an async slot in Astro. When the server cannot yet resolve a chunk, it flushes a fallback in its place and resumes once the awaited data arrives.

The skeleton’s job during that pause is threefold:

  1. Spatial reservation — occupy exactly the same footprint the resolved component will use, so the browser layout engine never reflows.
  2. State transport — carry a serialised snapshot of initial data so the island can mount without an extra round-trip.
  3. Interaction proxy — accept and queue user events so nothing is lost before the island mounts.

The diagram below shows the lifecycle from initial flush to full hydration.

Streaming SSR Skeleton Lifecycle A timeline showing four phases: server flush, skeleton phase, hydration handoff, and interactive island. Arrows indicate the direction of data flow and the hydration boundary demarcation point. SERVER BROWSER EVENT QUEUE Flush skeleton chunk Await data promise Flush resolved chunk Stream closed Paint skeleton (FCP) Display skeleton aria-busy="true" Mount island reconcile state Interactive (TTI) Buffer click/key events (FIFO) Replay buffered events post-hydrate hydration boundary time →

Technical Mechanics

Deterministic Layout Reservation

Skeletons must occupy exactly the spatial footprint of the resolved component. If a skeleton card renders at 320 px tall but the hydrated component is 480 px, the browser triggers a forced reflow — and CLS rises. Lock dimensions with aspect-ratio and CSS containment:

/* CLS-optimised skeleton — reserve layout before data arrives */
.skeleton-card {
  aspect-ratio: 3 / 4;       /* matches resolved card ratio exactly */
  min-height: 320px;
  background: var(--skeleton-base, #e5e7eb);
  border-radius: 0.75rem;
  overflow: hidden;
  position: relative;

  /* Isolate this element from parent layout recalculations */
  contain: layout style paint;
  content-visibility: auto;
}

/* Shimmer lives on the compositor thread only */
.skeleton-card::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(
    90deg,
    transparent 0%,
    rgba(255, 255, 255, 0.6) 50%,
    transparent 100%
  );
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite linear;
}

/* Honour prefers-reduced-motion — vestibular safety */
@media (prefers-reduced-motion: reduce) {
  .skeleton-card::after {
    animation: none;
    background: transparent;
  }
}

@keyframes shimmer {
  0%   { transform: translateX(-100%); }
  100% { transform: translateX(100%); }
}

contain: layout style paint tells the browser that nothing inside this box can affect anything outside it — meaning layout calculations for sibling elements proceed without waiting for the skeleton to resolve. content-visibility: auto lets the renderer skip off-screen skeletons entirely.

Hydration Boundary Markers and State Serialisation

The transition from inert HTML to an interactive island requires an explicit marker the hydration scheduler can parse. Use a data-hydrate-boundary attribute on the wrapper element and embed a serialised state payload alongside the skeleton. This is the same mechanism that cross-boundary prop passing relies on to transfer server state without a second network request.

// React 18 — streaming Suspense boundary with state transfer
// 'use server' directive on the parent ensures this runs server-side only
import { Suspense, lazy, useId } from 'react';

// Lazily import the island so its JS chunk is deferred
const InteractiveIsland = lazy(() => import('./InteractiveIsland'));

export function StreamingFallbackWrapper({ dataPromise, islandConfig }) {
  const boundaryId = useId(); // stable, server-generated ID avoids hydration mismatch

  return (
    <div
      data-hydrate-boundary={`island-${boundaryId}`}
      className="streaming-boundary"
      aria-busy="true"   // screen readers announce "busy" while skeleton shows
    >
      {/*
        Suspense suspends here while dataPromise is pending.
        The fallback is flushed to the stream immediately.
      */}
      <Suspense fallback={
        <div className="skeleton-card" role="status" aria-label="Loading content">
          <div className="skeleton-header" />
          <div className="skeleton-body" />
          <div className="skeleton-footer" />
        </div>
      }>
        {/*
          State payload: the client reads this before mounting so it avoids
          a second fetch and eliminates flash-of-unstyled-content.
        */}
        <script
          type="application/json"
          data-state-id={`state-${boundaryId}`}
          // dangerouslySetInnerHTML is intentional — this is serialised, not user input
          dangerouslySetInnerHTML={{ __html: JSON.stringify(islandConfig) }}
        />

        {/* Suspense resolves here when dataPromise settles → island mounts */}
        <InteractiveIsland data={dataPromise} boundaryId={boundaryId} />
      </Suspense>
    </div>
  );
}

Interaction Buffering During the Skeleton Phase

Users click and type while skeletons are visible. Without deliberate buffering, those events either hit no listener at all or trigger handlers on stale DOM. Attach a delegated listener at the boundary during the skeleton phase and replay the FIFO queue once hydration finishes. This technique pairs directly with event delegation in partially hydrated apps, which covers the broader island-communication patterns.

// IslandHydrationController — buffers pre-hydration events
// and reconciles server-rendered state with the mounted island.

interface InteractionEvent {
  type: string;
  target: HTMLElement;
  timestamp: number;
  payload: Record<string, unknown>;
}

export class IslandHydrationController {
  private eventQueue: InteractionEvent[] = [];
  private isHydrated = false;
  private boundaryEl: HTMLElement;
  private versionStamp: number; // monotonic; stale responses are discarded

  constructor(boundaryId: string, initialVersion: number) {
    this.boundaryEl = document.querySelector(
      `[data-hydrate-boundary="${boundaryId}"]`
    )!;
    this.versionStamp = initialVersion;
    this.attachEventDelegation();
  }

  // Capture phase so we intercept before any island handler sees the event
  private attachEventDelegation() {
    this.boundaryEl.addEventListener('click', this.handleInput, { capture: true });
    this.boundaryEl.addEventListener('keydown', this.handleInput, { capture: true });
  }

  private handleInput = (e: Event) => {
    if (!this.isHydrated) {
      e.preventDefault();   // suppress default browser action during skeleton
      e.stopPropagation();
      this.eventQueue.push({
        type: e.type,
        target: e.target as HTMLElement,
        timestamp: performance.now(),
        payload: { key: (e as KeyboardEvent).key ?? 'click' },
      });
    }
  };

  // Called by the island after its first render completes
  public async reconcileAndHydrate(resolvedState: unknown, newVersion: number) {
    // Discard responses that arrived out of order
    if (newVersion < this.versionStamp) return;
    this.versionStamp = newVersion;
    this.isHydrated = true;

    // Remove skeleton DOM nodes now that the island is mounted
    this.boundaryEl.querySelectorAll('.skeleton-card').forEach(el => el.remove());

    // Replay buffered interactions
    await this.flushEventQueue();

    // Signal completion so streaming scheduler can advance
    this.boundaryEl.setAttribute('aria-busy', 'false');
    this.boundaryEl.dispatchEvent(
      new CustomEvent('island:hydrated', { detail: { version: newVersion } })
    );
  }

  private async flushEventQueue() {
    const queue = [...this.eventQueue];
    this.eventQueue = [];

    for (const event of queue) {
      const synthetic = new Event(event.type, { bubbles: true });
      Object.assign(synthetic, event.payload);
      event.target.dispatchEvent(synthetic);

      // Yield between replays to keep each task under 50 ms (INP budget)
      if (typeof scheduler !== 'undefined') await scheduler.yield();
    }
  }
}

Skeleton Approach Comparison

Choosing the right skeleton strategy depends on how predictable the component’s shape is at server render time.

Approach Layout predictability Streaming compatible Bundle cost CLS risk Best for
Static HTML skeleton High — fixed shapes Yes — no JS needed Zero Low with contain Cards, grids, nav, headers
CSS-only animated High Yes ~0.5 KB CSS Low Any predictable layout
JS-generated skeleton Low — shape computed at runtime Requires client JS 2–8 KB Medium Variable-height feeds
loading="lazy" image placeholder Medium Yes (native) Zero Low with aspect-ratio Image-heavy lists
Full-page loader N/A Incompatible with streaming Variable High (replaces FCP) Avoid in streaming SSR

Static HTML skeletons are preferred for streaming contexts because they never stall the server’s output. JS-generated skeletons are acceptable only when the component’s shape genuinely cannot be known on the server.

Step-by-Step Integration Pattern

1. Measure the resolved component’s dimensions

Before authoring the skeleton, capture the real component’s dimensions in a test render:

# Use Playwright to snapshot computed height for a representative data fixture
npx playwright test --headed skeleton-dimensions.spec.ts

Record the aspect-ratio and minimum block size. These become the skeleton’s CSS constraints.

2. Author the static skeleton markup

Add the skeleton as a standalone file or co-locate it with the island component:

<!-- SkeletonCard.html — server-rendered fallback for ProductCard island -->
<div
  class="skeleton-card"
  role="status"
  aria-label="Loading product details"
  aria-live="polite"
>
  <!-- Mimic the image area -->
  <div class="skeleton-image" style="aspect-ratio: 16/9;"></div>

  <!-- Mimic two lines of title text -->
  <div class="skeleton-line" style="width: 75%; margin-top: 1rem;"></div>
  <div class="skeleton-line" style="width: 50%; margin-top: 0.5rem;"></div>

  <!-- Mimic a price + CTA row -->
  <div class="skeleton-row" style="margin-top: 1.5rem; display: flex; gap: 1rem;">
    <div class="skeleton-pill" style="width: 80px;"></div>
    <div class="skeleton-pill" style="flex: 1;"></div>
  </div>
</div>

3. Embed the Suspense boundary in your page component

// app/products/[id]/page.tsx — Next.js 14 App Router
// 'use server' is implicit for page components in the App Router
import { Suspense } from 'react';
import { ProductCard } from '@/components/ProductCard';
import SkeletonCard from '@/components/SkeletonCard';

export default function ProductPage({ params }) {
  // fetchProduct returns a Promise — Suspense suspends until it resolves
  const productPromise = fetchProduct(params.id);

  return (
    <main>
      <Suspense fallback={<SkeletonCard />}>
        {/* This island ships its own JS chunk; hydration is deferred */}
        <ProductCard productPromise={productPromise} />
      </Suspense>
    </main>
  );
}

4. Attach the hydration controller

// Entry point for the ProductCard island's client bundle
import { IslandHydrationController } from '@/lib/IslandHydrationController';

const controller = new IslandHydrationController(
  'island-:r0:',            // boundaryId from the server render
  Date.now()                // initialVersion for stale-response detection
);

// After the island mounts and data resolves:
productData.then(data => controller.reconcileAndHydrate(data, Date.now()));

5. Validate accessibility

Confirm aria-busy transitions correctly:

// Playwright assertion — aria-busy must reach "false" after hydration
await expect(page.locator('[data-hydrate-boundary]')).toHaveAttribute(
  'aria-busy', 'false'
);

Measurement & Validation

Three metrics signal whether the skeleton implementation is working correctly.

Cumulative Layout Shift (CLS) should be exactly 0.0 if contain: layout and aspect-ratio are applied consistently. Measure in Chrome DevTools > Performance > Layout Shift track or with:

// In DevTools console — observe layout shift entries
new PerformanceObserver(list => {
  list.getEntries().forEach(e => {
    if (e.entryType === 'layout-shift' && !e.hadRecentInput) {
      console.warn('Layout shift:', e.value, e.sources);
    }
  });
}).observe({ type: 'layout-shift', buffered: true });

Hydration gap is the delta between first-contentful-paint and island:hydrated. Set a performance mark at the boundary dispatch:

// Inside reconcileAndHydrate, after aria-busy flips:
performance.mark(`island-hydrated-${this.versionStamp}`);
performance.measure(
  'hydration-gap',
  'first-contentful-paint',
  `island-hydrated-${this.versionStamp}`
);
console.table(performance.getEntriesByType('measure'));

Target: hydration gap under 800 ms on a mid-range mobile device at Fast 3G.

Interaction to Next Paint (INP) confirms event replay does not produce long tasks. Each replayed event must complete in under 50 ms. scheduler.yield() between replays ensures this.

Metric Target Verification method
CLS 0.0 PerformanceObserver layout-shift entries
Hydration gap < 800 ms (mobile Fast 3G) performance.measure from FCP to island:hydrated
INP during replay < 200 ms PerformanceLongTaskTiming — no tasks > 50 ms
aria-busy transition Correct Playwright toHaveAttribute assertion

Failure Modes

Skeleton and resolved component dimensions diverge

Symptom: Non-zero CLS score even though the skeleton has explicit dimensions.

Root cause: The server-rendered skeleton uses CSS variables that resolve differently in light vs. dark mode, or the island mounts with additional padding that was not accounted for.

Fix: Audit the skeleton with DevTools “Layout” panel. Add contain: layout style paint to the skeleton wrapper. Validate that the aspect-ratio matches the island’s own aspect-ratio in both themes:

/* Apply the same aspect-ratio token to both skeleton and island */
.skeleton-card,
.product-card {
  aspect-ratio: var(--card-ratio, 3 / 4);
}

Buffered events replay on the wrong element

Symptom: After hydration, a replayed click triggers the wrong handler or fires on a stale DOM reference.

Root cause: The skeleton removal step in reconcileAndHydrate runs synchronously before flushEventQueue, but event.target still points to the removed skeleton node.

Fix: Store the event’s intended role, not the DOM node, and re-query after skeleton removal:

// Instead of storing the raw target:
payload: {
  role: (e.target as HTMLElement).dataset.role ?? '',
  key: (e as KeyboardEvent).key ?? 'click',
}

// In flushEventQueue, resolve the current live element:
const liveTarget = this.boundaryEl.querySelector(
  `[data-role="${event.payload.role}"]`
) ?? this.boundaryEl;
liveTarget.dispatchEvent(new Event(event.type, { bubbles: true }));

Hydration mismatch warning in React

Symptom: React logs Hydration failed because the server rendered HTML didn't match the client. on the boundary wrapper.

Root cause: The serialised state payload in <script type="application/json"> contains values that differ between server and client — most often timestamps or locale-dependent strings.

Fix: Pass only stable, serialisable values in islandConfig. Move dynamic values (current time, locale) into a useEffect that runs client-only after mount:

// Bad — timestamp in serialised state causes mismatch
<script ... dangerouslySetInnerHTML={{ __html: JSON.stringify({ ts: Date.now() }) }} />

// Good — timestamp injected client-side only
const [ts, setTs] = useState<number | null>(null);
useEffect(() => { setTs(Date.now()); }, []);

← Back to Server-Client Boundaries & State Synchronization