Astro vs Qwik: Partial Hydration Trade-offs
Astro and Qwik both promise to stop shipping JavaScript the user never needs, and both are frequently described as “islands” frameworks. But they solve the load-time problem with fundamentally different machinery. Astro renders a static shell and hydrates marked islands by re-executing their component code in the browser. Qwik renders the whole application, serialises its reactive graph into the HTML, and then does almost nothing on load — it resumes execution only when the user interacts. The trade-off is a genuine engineering fork: Astro pays in island JavaScript that scales with how much you hydrate; Qwik pays in serialised state that scales with how much the page holds. This guide makes both costs measurable and gives you a basis to choose.
Concept Definition & Scope
Astro implements the mainstream reading of Islands Architecture described in the core islands architecture and hydration models guide. Every .astro component is server-only by default. An interactive component becomes an island by receiving a client:* directive — client:load, client:idle, client:visible, or client:only — which tells Astro to ship that component’s bundle and re-run its render on the client at the chosen moment. This is partial hydration in its most explicit form: the boundary is a directive in the template, and the cost is the island’s JavaScript.
Qwik replaces hydration with resumability. Instead of re-executing components on the client to reattach behaviour, Qwik serialises the entire reactive graph — signals, props, and event-handler references — into the rendered HTML on the server. On load, only a roughly one-kilobyte loader runs; it installs a single global event listener and otherwise does nothing. When the user interacts, Qwik looks up the serialised handler reference, fetches just that chunk, and resumes — with no bootstrap re-render. The consequence is that Qwik’s load-time JavaScript stays flat regardless of application size, whereas its HTML grows with the serialised state it must carry.
The scope of this comparison is the hydration model itself and its performance envelope, not routing or data APIs. Where the two frameworks meet operationally — porting an app from one to the other — is covered in migrating an Astro project to Qwik City.
Technical Mechanics
Astro: directive-scheduled islands
An Astro page is a server template. The client:* directive is the only thing that turns a component into a client-activated island, and the choice of directive controls when the bundle downloads and re-executes.
---
// Counter.astro's host page — this frontmatter runs on the server only.
import Counter from '../components/Counter.jsx'; // a React island
import PriceTable from '../components/PriceTable.astro'; // stays server-only
const rows = await fetchPricing(); // server fetch; result serialised as props
---
<main>
</main>
// components/Counter.jsx — a standard React component.
// Under Astro this code is re-executed on the client when the directive fires.
// Its full render + React runtime are the JS cost of this one island.
import { useState } from 'react';
export default function Counter({ start = 0, label = 'Add' }) {
const [n, setN] = useState(start); // re-initialised on client during hydration
return <button onClick={() => setN(n + 1)}>{label}: {n}</button>;
}
Qwik: resumable closures
Qwik components are authored with component$ and wrap reactive logic in $(). The $ is a lazy-loading boundary the Qwik optimiser understands: at build time it splits every $-wrapped function into its own chunk, and at runtime it serialises the reference to that chunk into the HTML rather than the code.
// counter.tsx — Qwik. component$ and $() mark resumable boundaries.
import { component$, useSignal, $ } from '@builder.io/qwik';
export const Counter = component$<{ start?: number; label?: string }>(
({ start = 0, label = 'Add' }) => {
// useSignal state is serialised into the HTML. On resume, Qwik restores
// this value from the payload — it does NOT re-run this component body.
const count = useSignal(start);
// The $ wraps the click handler as a separately loadable chunk.
// Its code is absent from the initial payload; only a reference ships.
const inc = $(() => { count.value++; });
// No hydration re-render: the handler chunk is fetched only on first click.
return <button onClick$={inc}>{label}: {count.value}</button>;
}
);
The two snippets render the same button. The difference is entirely in what reaches the browser at load: Astro ships React plus Counter when the directive fires; Qwik ships neither until the click, at the cost of embedding count’s value and the handler reference in the HTML.
How Qwik’s optimiser splits code
The $ is not decoration — it is an instruction to the Qwik optimiser (a build-time transform) to extract the wrapped function into its own lazily-loadable symbol and rewrite the call site to a serialisable reference (a QRL, or Qwik URL). At runtime the HTML contains attributes like on:click="./chunk-x.js#inc_a8f" rather than an inlined handler. The single loader installs one delegated listener on the document; when an event fires, it reads the QRL from the target’s attribute, imports that chunk, and invokes the handler. This is what lets load-time JavaScript stay flat: the number of handlers on the page changes the HTML, not the bootstrap.
The cost is symmetrical. Every $ boundary that captures a variable forces that variable into the serialised state so the resumed handler can read it. Capturing a signal is cheap and idiomatic; capturing a large object copies it into the HTML. Astro has no equivalent build-time closure extraction — it ships whole component modules — so Astro’s authoring model is more familiar but its granularity is the island, not the individual handler.
DX and ecosystem trade-offs
Astro’s developer experience leans on ubiquity: the island is a component you already know from React, Svelte, Solid, or Vue, dropped into an .astro template with a directive. Teams reuse existing component libraries and mental models, and the boundary is visible at a glance in the template. The tax is discipline — nothing stops a developer from over-using client:load, and the framework will not warn you when an island’s runtime dwarfs its interactive value.
Qwik’s developer experience is less familiar but more automatic. Resumability defers work by default, so there is no directive to tune and no obvious way to accidentally ship a large load-time bundle. The tax is the $ mental model: you must think in serialisable boundaries, understand what a closure captures, and accept a smaller (though growing) ecosystem where not every React library has a native equivalent. Qwik’s React interop can host third-party components, but doing so reintroduces React’s runtime for those subtrees and forfeits resumability there — a deliberate escape hatch, not a default.
Comparison
| Dimension | Astro (client:*) |
Qwik (resumability) |
|---|---|---|
| JS executed at load | Zero for static; full bundle per hydrated island | ~1 KB loader; no component code until interaction |
| Scaling factor | Grows with number/size of hydrated islands | Grows with size of serialised state (HTML) |
| Serialization cost | Small per-island JSON props | Whole reactive graph inlined into HTML |
| Framework freedom | React, Svelte, Solid, Vue, Preact per island | Single Qwik component model (component$/$) |
| DX / mental model | Familiar re-hydration; explicit directives | Novel resumability; $ boundaries require care |
| Ecosystem maturity | Broad — reuse existing framework components | Smaller, growing; interop for some React libraries |
The decisive trade-off is where cost accumulates. Astro’s cost is a function of interactivity: a page with one small island is almost free, but many client:load islands can rival a small SPA. Qwik’s cost is a function of state: a deeply stateful page inflates the HTML and server serialisation time, but adding more interactive widgets barely moves load-time JavaScript. For scaling behaviour on state-heavy Qwik pages specifically, see optimising Qwik resumability for large datasets.
Scaling with page complexity
The models diverge most sharply as a page grows, and it helps to trace the two axes separately. Add more interactive widgets to an Astro page and each one contributes its component code — and, if it is the first island of its framework, that framework’s runtime — to the load-time budget; ten independent client:idle islands schedule ten hydration tasks that the main thread must work through, even if interleaved with idle frames. Add the same ten widgets to a Qwik page and load-time JavaScript is essentially unchanged: the loader is already present, and each widget only adds handler references and a little serialised state to the HTML. Qwik’s load curve is close to flat against interactive-widget count; Astro’s rises with it.
Now hold interactivity fixed and grow state and content instead — a long dashboard with hundreds of rows, each carrying signals. Here Astro is largely unaffected at load, because static rows ship no JavaScript, while Qwik’s serialised payload grows with every signal it must embed, inflating HTML size and server serialisation time. The rule of thumb that falls out: Astro scales badly with breadth of interactivity and well with volume of static content; Qwik scales well with breadth of interactivity and badly with volume of serialised state. A content site with a handful of widgets leans Astro; a highly interactive application with bounded state leans Qwik; a dashboard that is both large and deeply stateful stresses both and warrants a measured prototype before committing.
Step-by-Step Integration
- Classify each interactive region. For Astro, decide the directive per island:
client:loadonly above the fold,client:idlefor secondary widgets,client:visiblefor below-the-fold. For Qwik, this classification largely disappears — resumability defers everything by default. - Bound Astro islands; bound Qwik state. In Astro, consolidate adjacent controls into one island to avoid per-island runtime duplication. In Qwik, keep serialised state lean — store derived values with computed signals rather than persisting redundant data.
- Serialise props at the seam. Astro JSON-encodes island props inline; keep them small and plain. Qwik serialises signals and props automatically but charges you HTML bytes for it, so avoid stashing large objects in signals.
- Wire data fetching. Astro fetches in the server frontmatter and passes props. Qwik City uses
routeLoader$to fetch on the server and expose data to resumable components without a client waterfall. - Prototype the heaviest route in both and compare against a stated budget before committing the stack.
Measurement & Validation
Measure the two models on their respective cost axes: executable JS for Astro, serialised HTML size and resume latency for Qwik.
// Load-time JS: run in DevTools console after a cold load of each build.
const js = performance.getEntriesByType('resource')
.filter(r => r.name.endsWith('.js'))
.reduce((kb, r) => kb + (r.transferSize || 0) / 1024, 0);
console.log(`transferred JS ≈ ${js.toFixed(1)} KB`);
// Qwik serialised state size: measure the payload it inlines into HTML.
const state = document.querySelector('script[type="qwik/json"]');
if (state) console.log(`Qwik serialised state ≈ ${(state.textContent.length / 1024).toFixed(1)} KB`);
In the DevTools Performance panel at 4× CPU throttle, confirm the Astro build produces Evaluate Script events only when islands hydrate, and confirm the Qwik build produces almost none at load. Then interact: Qwik’s first click triggers a small chunk fetch — measure that resume latency, since it is Qwik’s equivalent of hydration cost, deferred to interaction. Compare Total Blocking Time and the interactive audit across builds. Deeper per-framework directives are documented in Astro islands and client directives.
Failure Modes
1. Astro client:load everywhere. Slapping client:load on every interactive component reproduces SPA load cost — every island’s runtime evaluates before paint. Symptom: Total Blocking Time climbs as you add islands. Fix: reserve client:load for above-the-fold interactivity; default to client:idle or client:visible.
2. Qwik state bloat. Persisting large arrays or duplicated objects in signals inflates the serialised HTML and slows server serialisation, eroding resumability’s benefit. Symptom: heavy HTML documents and slow Time to First Byte. Fix: keep signals minimal and derive with computed values; page or window large datasets on the server via routeLoader$.
3. Closure capture surprises in Qwik. Because $ boundaries serialise captured variables, capturing a non-serialisable value (a class instance, a DOM node, a function) throws at build or runtime. Fix: capture only serialisable primitives and signals inside $(), and reconstruct rich objects lazily inside the handler.
Related
- Migrating an Astro Project to Qwik City — the practical port that turns this comparison into a migration plan.
- Islands Architecture vs React Server Components — the sibling comparison between selective activation and server-tree serialisation.
- Qwik Resumable Architecture — the resumability model in depth, including serialisation internals.
- Astro Islands and Client Directives — a directive-by-directive reference for Astro’s hydration scheduling.
- Understanding Partial Hydration — the mechanism underpinning Astro’s island activation.