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:
- Spatial reservation — occupy exactly the same footprint the resolved component will use, so the browser layout engine never reflows.
- State transport — carry a serialised snapshot of initial data so the island can mount without an extra round-trip.
- 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.
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()); }, []);
Related
- Cross-Boundary Prop Passing — how to transfer server state snapshots into islands without a second request, a prerequisite for eliminating skeleton-phase data gaps.
- Event Delegation in Partially Hydrated Apps — broader patterns for routing events across island boundaries, which the buffering technique here builds on.
- Optimistic Updates Without Full Hydration — complement to skeleton strategies when you want the UI to assume success before the server responds.
- Next.js App Router Streaming Patterns — framework-specific Suspense boundary configuration, including how
loading.tsxmaps to the skeleton fallback pattern described here. - Understanding Partial Hydration — the foundational model that explains why islands need explicit skeleton handoffs in the first place.