Qwik Resumable Architecture: Zero-Hydration Islands & Streaming SSR
Performance engineers targeting sub-100 ms Interaction to Next Paint (INP) scores hit a hard ceiling with traditional SSR frameworks: interactivity is blocked until a monolithic JavaScript bundle downloads, parses, and re-executes the entire component tree in the browser. Qwik removes that ceiling by replacing hydration with resumability — a model where the server serializes all execution state into the HTML payload and the client picks up exactly where the server stopped, downloading JavaScript only when a user actually interacts with a specific element. This page explains exactly how that model works, how to wire it into a real project, and how to avoid the pitfalls that quietly break it. It sits within the broader Framework-Specific Islands & Streaming SSR strategies for eliminating unnecessary JavaScript execution.
Concept Definition & Scope
Resumability is a property of a runtime, not a build tool. A resumable runtime can pause a program on the server, serialize its complete execution state (call stack, heap references, reactive subscriptions) into an inert format, transmit that format over the network, and then reconstruct and continue executing the program on the client — without repeating any of the work the server already did.
Qwik achieves this through three mechanisms working together:
- QRL (Qwik Resource Locator): A URL-like pointer that identifies a serialized function chunk. The compiler replaces every
$-marked closure with a QRL embedded as a DOM attribute during SSR. - The Qwik runtime (~1 KB): A global event interceptor attached to
document. On any user interaction it reads the QRL from the target element, fetches the chunk, and resumes execution in the preserved context. - State serialization into the DOM: Signals (
useSignal) and stores (useStore) are serialized intoq:stateattributes on the server so that the client runtime can restore the reactive graph without calling any initialization code.
What is in scope: component-level lazy execution, streaming async data boundaries, cross-island state sharing, fine-grained bundle splitting, QRL-driven event delegation.
What is out of scope: full-page client-side routing (handled separately by QwikCity), server actions/mutations, and build-pipeline configuration beyond what is needed to understand resumability.
Relationship to partial hydration: Partial hydration scopes JavaScript execution to individual islands but still re-runs component initialization inside each island. Resumability eliminates that re-run entirely — the component never initializes on the client; it resumes from serialized state.
How Qwik’s Runtime Actually Executes Resumability
The diagram below traces the full lifecycle from SSR render to client interaction, showing where QRL pointers are embedded and how the runtime resolves them on demand.
The $ Suffix as a Compiler Directive
The $ suffix is not a naming convention — it is a signal to the Qwik optimizer to extract the marked closure into an independent network chunk. When the optimizer encounters component$, it:
- Renders the component tree to static HTML during SSR.
- Serializes every captured variable (signals, store references) into
q:stateDOM attributes. - Replaces the event handlers with QRL pointers (
q:onClick="/chunk-abc.js#handler"). - Strips the component’s JavaScript from the initial page payload entirely.
// component$: the $ tells the optimizer this is a lazy chunk boundary.
// The server renders <button q:onClick="/q-abc123.js#clickHandler">…</button>
// and zero JS for this component lands in the initial HTML response.
import { component$, useSignal } from '@builder.io/qwik';
export const InteractiveCounter = component$(() => {
// useSignal serializes `count` into a q:state attribute on the DOM node.
// The client runtime reads q:state to restore value without re-running this line.
const count = useSignal(0);
return (
// onClick$ is a separate chunk: /q-onclick-abc.js (~1.2 KB).
// It only downloads when the button is actually clicked.
<button onClick$={() => count.value++}>
Count: {count.value}
</button>
);
});
Each event handler attached with $ becomes its own chunk. A component with three handlers produces three independent chunks — none of which download until the corresponding interaction occurs. Compare this with Astro Islands and Client Directives, where the entire island component (framework runtime included) downloads when the hydration trigger fires.
Comparison: Resumability vs Alternative Approaches
| Dimension | Qwik Resumability | Astro Islands (client:*) |
Next.js App Router + Suspense | Full CSR (React SPA) |
|---|---|---|---|---|
| Initial JS payload | ~1 KB runtime only | Island JS + framework runtime | Route JS + RSC payload | Full bundle |
| TTI for static content | Instant (no JS needed) | Instant (islands lazy) | Fast (streaming) | Delayed until bundle parses |
| JS on interaction | Handler chunk only (~1–5 KB) | Full island component | Pre-loaded route chunk | Already in memory |
| State restoration | DOM q:state attributes |
Re-runs component init | Client Component re-render | Client state |
| Streaming support | useResource$ async boundaries |
No native streaming | <Suspense> + RSC stream |
No |
| DX complexity | $ rules require learning |
Familiar per-framework | Familiar React | Familiar React |
| Scalability at page complexity | Scales linearly (more handlers = more small chunks) | Scales by island count | Scales by route segment | Degrades with bundle size |
Step-by-Step Integration Pattern
Step 1: Scaffold a QwikCity Project
# QwikCity is Qwik's meta-framework (routing, loaders, middleware).
npm create qwik@latest my-app
cd my-app
npm install
Step 2: Author a Resumable Island with Fine-Grained Boundaries
// src/components/product-card/product-card.tsx
// Each $ boundary becomes an independent network chunk at build time.
import { component$, useSignal, useTask$ } from '@builder.io/qwik';
interface Props {
productId: string;
initialPrice: number;
}
export const ProductCard = component$<Props>(({ productId, initialPrice }) => {
// initialPrice is serialized into q:state; the client reads it without fetch.
const price = useSignal(initialPrice);
const added = useSignal(false);
// useTask$ runs reactively when `added` changes.
// The $ means this side-effect is also its own lazy chunk.
useTask$(({ track }) => {
track(() => added.value);
if (added.value) {
// Analytics call is isolated here — it only executes when added flips true.
console.log(`[analytics] product ${productId} added to cart`);
}
});
return (
<div class="product-card">
<p>Price: ${price.value}</p>
{/* addToCart$ handler: independent ~2 KB chunk, downloaded on first click only */}
<button
onClick$={async () => {
const res = await fetch(`/api/cart`, {
method: 'POST',
body: JSON.stringify({ productId }),
});
if (res.ok) added.value = true;
}}
>
{added.value ? 'Added!' : 'Add to Cart'}
</button>
</div>
);
});
Step 3: Stream Async Data with useResource$
useResource$ creates an explicit async execution boundary. The server begins rendering and streams HTML immediately, inserting a placeholder that resolves as data arrives — preventing the streaming SSR waterfall where the entire page blocks on the slowest data source.
// src/components/live-price/live-price.tsx
import { component$, useResource$, Resource } from '@builder.io/qwik';
interface PriceData { usd: number; lastUpdated: string; }
export const LivePrice = component$<{ ticker: string }>(({ ticker }) => {
// useResource$ defers the fetch to the async streaming phase.
// The server emits the surrounding HTML immediately; the Resource placeholder
// fills in as the fetch resolves — no hydration needed to attach the result.
const priceResource = useResource$<PriceData>(async ({ track, cleanup }) => {
track(() => ticker); // Re-fetches reactively if ticker prop changes
const controller = new AbortController();
cleanup(() => controller.abort()); // Cancel in-flight request on unmount
const res = await fetch(`/api/price/${ticker}`, {
signal: controller.signal,
cache: 'no-store',
});
if (!res.ok) throw new Error(`Price fetch failed: ${res.status}`);
return res.json() as Promise<PriceData>;
});
return (
<div class="live-price">
<Resource
value={priceResource}
onPending={() => (
// Skeleton shown while the stream is in flight; no JS download required.
<span aria-busy="true" aria-label="Loading price">—</span>
)}
onResolved={(data) => (
<span>${data.usd.toFixed(2)} <small>({data.lastUpdated})</small></span>
)}
onRejected={(err) => (
<span role="alert" class="error">{err.message}</span>
)}
/>
</div>
);
});
Step 4: Share State Across Islands with useStore
When you need two islands to react to the same state — for example, a cart badge updating after a product card fires an add-to-cart — lift the store to a shared context rather than prop-drilling through the server boundary.
// src/context/cart-context.tsx
import { createContextId, useStore, useContextProvider, useContext } from '@builder.io/qwik';
interface CartStore { itemCount: number; totalUsd: number; }
export const CartContext = createContextId<CartStore>('cart');
// Provide once at the root layout:
export const CartProvider = component$(() => {
// useStore serializes the whole object graph into q:state on the root element.
// Any island that calls useContext(CartContext) restores from that snapshot —
// no initialization code runs, no HTTP request fired.
const cart = useStore<CartStore>({ itemCount: 0, totalUsd: 0 });
useContextProvider(CartContext, cart);
return <Slot />;
});
// Consume in any island:
export const CartBadge = component$(() => {
const cart = useContext(CartContext);
// This component resumes from serialized cart state; it never re-initializes.
return <span class="badge">{cart.itemCount}</span>;
});
Step 5: Validate the Chunk Graph at Build Time
# Build with stats to audit QRL chunk sizes.
npm run build -- --stats
# Expected: individual handler chunks 1–5 KB each.
# A chunk >20 KB usually means a third-party dep leaked into the boundary;
# wrap it in its own component$ to isolate it.
Measurement & Validation
DevTools Verification Workflow
- Open Chrome DevTools → Network tab. Enable Disable cache and throttle to Slow 4G.
- Load the page. The only JS request should be the ~1 KB Qwik runtime (look for
qwik.jsorq-manifestbootstrapper). No component JS should appear. - Open the Performance tab and begin recording. Interact with a
component$element (click a button, hover aonMouseOver$handler). - Stop recording. Examine the Network waterfall inside the trace: a JS chunk should appear after the pointer events fire, not before. The chunk URL should correspond to the QRL pointer you can read in the DOM.
- In the Elements panel, inspect the root
q:containerelement. Verifyq:version,q:render, andq:baseattributes are present — these confirm the Qwik runtime bootstrapped from serialized state rather than re-running SSR.
Performance Marks for CI Integration
// Instrument resumable interactions to measure real-world latency:
// src/components/measured-island/measured-island.tsx
import { component$, $ } from '@builder.io/qwik';
export const MeasuredIsland = component$(() => {
const handleClick = $(async () => {
// Mark the moment the handler chunk landed and began executing.
performance.mark('qwik-resume-start');
await doWork();
performance.mark('qwik-resume-end');
performance.measure('qwik-chunk-resume', 'qwik-resume-start', 'qwik-resume-end');
// Read in CI: PerformanceObserver entries where entryType === 'measure'
});
return <button onClick$={handleClick}>Measure Me</button>;
});
In a Playwright test, assert that qwik-chunk-resume duration stays under your INP budget:
// tests/perf.spec.ts
const resumeDuration = await page.evaluate(() => {
const [entry] = performance.getEntriesByName('qwik-chunk-resume');
return entry?.duration ?? Infinity;
});
expect(resumeDuration).toBeLessThan(50); // 50 ms INP target
Failure Modes
Failure 1: Omitting $ Forces Eager Loading
If you capture a closure without the $ suffix, the optimizer cannot extract it as a lazy chunk. The function is inlined into the parent component’s bundle and loads eagerly.
// ❌ Wrong: onClick is an inline function — optimizer cannot split it.
// The entire component module loads on page paint.
export const EagerButton = component$(() => {
return <button onClick={() => console.log('clicked')}>Click</button>;
});
// ✅ Correct: onClick$ signals a separate chunk boundary.
export const LazyButton = component$(() => {
return <button onClick$={() => console.log('clicked')}>Click</button>;
});
Detection: Run qwik build --stats and look for suspiciously large chunks (>20 KB) that contain multiple handler names — a sign they were not split.
Failure 2: Serializing Non-Serializable Objects in useStore
useStore can only serialize plain JSON-compatible values. Assigning Date, Map, Set, a DOM node, or a class instance causes a runtime QRL serialization error that surfaces as a blank island or a console exception.
// ❌ Wrong: Date cannot be serialized into q:state.
const store = useStore({ updatedAt: new Date() });
// ✅ Correct: Store the ISO string; reconstruct Date locally when needed.
const store = useStore({ updatedAt: new Date().toISOString() });
// Then: const date = new Date(store.updatedAt); — only inside the event handler.
Failure 3: Misaligned useResource$ Boundaries Causing Layout Shift
If a useResource$ boundary wraps a large layout section, the onPending skeleton and the resolved content have different dimensions, producing a Cumulative Layout Shift (CLS) spike.
// ❌ Wrong: the entire card block is inside the async boundary.
// The skeleton is 40 px tall; the resolved content is 240 px — massive CLS.
<Resource value={r} onPending={() => <div style="height:40px" />} onResolved={...} />
// ✅ Correct: isolate only the dynamic portion (price label) inside the boundary.
// The card shell renders synchronously; only the <LivePrice> slot streams in.
<div class="card">
<h2>{product.name}</h2>
{/* Only this span is async — it has a fixed-height placeholder. */}
<Resource
value={priceResource}
onPending={() => <span style="display:inline-block;width:60px;height:1em" aria-hidden="true" />}
onResolved={(p) => <span>${p.usd}</span>}
/>
</div>
For a deeper look at state management patterns across frameworks, including how cross-boundary prop passing differs in resumable versus hydration-based models, see the state synchronization guides.
Related
- Astro Islands and Client Directives — Astro’s
client:*directive model compared: island-level hydration triggers versus Qwik’s handler-level lazy loading. - Next.js App Router Streaming Patterns — How React Suspense boundaries stream HTML and differ from Qwik’s inert-until-triggered streaming model.
- SvelteKit Component Islands — SvelteKit’s approach to isolating interactive components within server-rendered pages.
- Understanding Partial Hydration — The foundational concept that Qwik’s resumability supersedes, and why the distinction matters for TTI budgets.
- Optimizing Qwik Resumability for Large Datasets — Profiling and tuning QRL chunk graphs when
useStoreserializes deep object trees.