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

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.



← Back to Framework-Specific Islands & Streaming SSR