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 into q:state attributes 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.

Qwik Resumable Architecture Lifecycle Diagram showing the five-stage lifecycle: SSR render, QRL embedding into HTML, zero-JS delivery to browser, event interception by the 1 KB runtime, and on-demand chunk fetch and resume. SERVER SSR Render component$ → HTML QRL Embedding q:onClick → /chunk-abc.js State → q:state attrs signals, store, subscriptions HTML only 0 JS on load CLIENT ~1 KB Runtime global event interceptor User Click event bubbles to q:container QRL Resolve read attr → fetch /chunk-abc.js (~3 KB) Resume restore q:state → run handler → update DOM no re-initialization no reconciliation

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:

  1. Renders the component tree to static HTML during SSR.
  2. Serializes every captured variable (signals, store references) into q:state DOM attributes.
  3. Replaces the event handlers with QRL pointers (q:onClick="/chunk-abc.js#handler").
  4. 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

Where Each Model Spends Its Milliseconds Two stacked cost profiles over the same session. The hydrating app spends a large block of time immediately after load and nothing afterwards. The resumable app spends almost nothing at load, then a small chunk fetch and deserialisation at the first interaction with each distinct island, tapering as the session continues. The crossover point is marked around the fourth distinct interaction. Cost profile across one session hydrating 420 ms at load — all islands interactions are free — code is already resident resumable 12 ms listener shim chunk fetch + deserialise, per first touch crossover ≈ 4th distinct island touched Resumability wins when sessions are short, entry pages are content-heavy, and most visitors touch one or two widgets Hydration wins when sessions are long and dense — an editor or console where the same code is exercised continuously

The practical consequence of the crossover point above is that resumability is a traffic-shape decision as much as a technical one. Look at the distribution of session depth in your analytics before choosing: if the median visitor touches one widget and leaves, the model that executes nothing until that touch is strictly better. If the median session is fifteen minutes inside a single dense view, the fixed cost of hydration amortises and the per-interaction chunk fetches become the slower path. Neither number is knowable from a framework benchmark — it comes from your own field data, which is why the framework benchmark methodology treats interaction profile as an input to the harness rather than a detail.

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

  1. Open Chrome DevTools → Network tab. Enable Disable cache and throttle to Slow 4G.
  2. Load the page. The only JS request should be the ~1 KB Qwik runtime (look for qwik.js or q-manifest bootstrapper). No component JS should appear.
  3. Open the Performance tab and begin recording. Interact with a component$ element (click a button, hover a onMouseOver$ handler).
  4. 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.
  5. In the Elements panel, inspect the root q:container element. Verify q:version, q:render, and q:base attributes 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.


Why a Closure Fails to Serialise — and the Rewrite That Fixes It Two panels. The rejected version shows a closure capturing a database client, a logger instance and a callback prop; the serializer marks all three as unreachable and the build fails. The accepted version shows the same closure capturing a record identifier and a route string, resolving the client inside the function body at execution time, so only plain values cross the boundary. The serializer only follows what a closure captures REJECTED captured: db client — open socket captured: logger instance — has methods captured: onDone callback — a function The optimiser cannot write any of these into the HTML, so the build fails at the boundary. symptom: "cannot serialize" at build ACCEPTED captured: recordId — a string captured: endpoint — a string resolved inside the body at run time Both values are plain text in the payload; the client rebuilds everything else on demand. payload cost: about 40 bytes

Frequently Asked Questions

What is QRL serialization in Qwik?

QRL (Qwik Resource Locator) is a URL-like pointer that the Qwik compiler embeds as a DOM attribute during SSR. When the user interacts with an element, the ~1 KB Qwik runtime resolves the QRL, fetches the corresponding JavaScript chunk, and resumes execution exactly where the server left off — with no re-initialization.

Does Qwik still perform hydration?

No. Qwik replaces hydration with resumability. The server serializes all component state and event-handler references into HTML attributes. The client reads that snapshot and resumes from it rather than re-running component initialization, eliminating the hydration tax entirely.

When should I use useResource$ vs useTask$?

Use useResource$ for async data fetching that should stream alongside HTML and render progressively. Use useTask$ for reactive side effects (like updating the DOM or syncing derived state) that must run synchronously in response to tracked signal or store mutations.


← Back to Framework-Specific Islands & Streaming SSR