Reducing Qwik Serialization Payload Size
Qwik’s resumability removes the hydration tax by serialising execution state into the HTML instead of re-running components on load — but that state is not free. Every $ closure captures its lexical scope, and whatever it captures gets written into the page. Left unchecked, a few careless closures can bloat the serialized payload with entire props objects, unused store fields, and non-reactive handles that never needed to travel at all. This guide, a companion to Qwik Resumable Architecture, shows how to measure that payload and shrink it with noSerialize, useSerializer$, and disciplined lexical scope.
Prerequisites
What Ends Up in the Serialized Payload
The fix starts with a clear model of what Qwik writes into the HTML and why. The diagram traces a $ closure’s captured scope into the serialized state block.
Implementation Steps
Step 1 — Measure the current serialized state
Goal: Get a byte number for the payload before changing anything.
Qwik writes its serialized state into a qwik/json script block. Size it directly from the rendered HTML.
# Extract the serialized state block and count its bytes. Run against your SSR
# output so you measure real serialized state, not the dev-mode representation.
curl -s http://localhost:3000/dashboard \
| grep -o '<script type="qwik/json">.*</script>' \
| wc -c
Expected output: A byte count — for example 48213. Record this as your baseline. Anything in the tens-of-kilobytes range for a simple page signals over-captured state worth cutting.
Step 2 — Exclude non-reactive values with noSerialize
Goal: Stop Qwik from serialising objects that should never travel in HTML — sockets, class instances, chart handles.
// ~/components/LiveFeed.tsx
import { component$, useStore, useVisibleTask$, noSerialize } from "@builder.io/qwik";
import type { NoSerialize } from "@builder.io/qwik";
export const LiveFeed = component$(() => {
const store = useStore<{ socket: NoSerialize<WebSocket> | undefined; messages: string[] }>({
socket: undefined,
messages: [],
});
// useVisibleTask$ runs on the client. We create the socket here and wrap it in
// noSerialize so Qwik NEVER writes the WebSocket into the serialized payload —
// it cannot be serialized anyway, and it must not bloat the HTML.
useVisibleTask$(() => {
store.socket = noSerialize(new WebSocket("wss://example.com/feed"));
store.socket!.onmessage = (e) => store.messages.push(e.data);
});
return <ul>{store.messages.map((m, i) => <li key={i}>{m}</li>)}</ul>;
});
Expected output: Re-run Step 1. The payload no longer contains the socket object graph. A noSerialize value reads as undefined on resume until the client task recreates it — that is expected and correct.
Step 3 — Tighten lexical scope in $ closures
Goal: Capture the minimum. A $ closure serialises everything it references, so referencing a whole object drags the whole object into the payload.
// WRONG — the closure references `props`, so Qwik serializes the ENTIRE props
// object (including fields the handler never touches) into the HTML.
export const BuyButton = component$((props: { product: Product; theme: Theme; user: User }) => {
return (
<button onClick$={() => addToCart(props.product.id)}>Buy</button>
);
});
// RIGHT — destructure the single primitive the handler needs BEFORE the closure.
// Now only `productId` (a short string) is captured and serialized.
export const BuyButton = component$((props: { product: Product; theme: Theme; user: User }) => {
const productId = props.product.id; // captured value is now a small string
return (
<button onClick$={() => addToCart(productId)}>Buy</button>
);
});
Expected output: Re-measure. Removing whole-object captures is usually the single largest reduction, because one over-captured props or store can serialise kilobytes of unused fields.
Step 4 — Transmit a compact form with useSerializer$
Goal: When the client genuinely needs a rich object, send a small representation and rebuild it, rather than serialising the whole graph.
// ~/components/PriceTable.tsx
import { component$, useSerializer$ } from "@builder.io/qwik";
export const PriceTable = component$((props: { rawRates: number[] }) => {
// useSerializer$ stores a COMPACT representation (here, the raw number array)
// and reconstructs the heavy formatter object on the client on demand — so the
// HTML carries the array, not the Intl.NumberFormat instances.
const rates = useSerializer$({
// deserialize: rebuild the rich object from the compact data on the client
deserialize: (data: number[]) =>
data.map((n) => new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(n)),
// serialize: reduce to the minimal transmittable form
serialize: () => props.rawRates,
// initial compact value
initial: props.rawRates,
});
return <ul>{rates.value.map((r, i) => <li key={i}>{r}</li>)}</ul>;
});
Expected output: The serialized payload contains the numeric array only; the formatter objects are reconstructed on the client and never appear in the HTML.
Verification
-
Payload shrank. Re-run the Step 1 measurement and compare against your baseline. Each change —
noSerialize, tighter scope,useSerializer$— should move the number down. A page that started in the tens of kilobytes of state should drop substantially once whole-object captures are removed. -
Resume still works. Load the page, then interact with each element whose closure you edited. Interactivity must work on first click with no console error. A
noSerializevalue that is read before its recreating task runs will beundefined; guard those reads. -
No serialization warnings. Load with the console open. Qwik warns when it encounters a value it cannot serialise; a clean console confirms every non-serialisable object is wrapped in
noSerializeor handled byuseSerializer$. -
Budget in CI. Add an assertion that fails the build if the
qwik/jsonblock exceeds a byte budget, so payload regressions are caught the way large-dataset resumability budgets are enforced.
# CI guard — fail if serialized state exceeds 20 KB on a key route.
BYTES=$(curl -s "$BASE/dashboard" | grep -o '<script type="qwik/json">.*</script>' | wc -c)
[ "$BYTES" -le 20480 ] || { echo "qwik/json too large: ${BYTES}B"; exit 1; }
Troubleshooting
The payload is still large after adding noSerialize
Root cause: noSerialize only excludes the wrapped value. If a $ closure elsewhere captures a large reactive object (a whole useStore or props record), that object is still serialised regardless.
Fix: Audit each $ closure and destructure the specific primitives it uses before the closure body, as in Step 3. The biggest wins come from removing whole-object captures, not from wrapping more values in noSerialize.
A noSerialize value is undefined when the user interacts
Root cause: noSerialize values are not transmitted, so on resume they are undefined until the client code that recreates them runs. If a handler reads the value before that code executes, it sees undefined.
Fix: Recreate the value in a useVisibleTask$ (or on first interaction) and guard reads. For a socket, check if (!store.socket) return; before using it, and recreate it if the task has not yet run.
Console warns that a value cannot be serialized
Root cause: A closure or store captured a non-serialisable object — a class instance, function, Map, or DOM node — that Qwik cannot write to the payload.
Fix: Wrap genuinely non-reactive handles in noSerialize, or replace the object with a compact representation via useSerializer$ and reconstruct it on the client. Never store DOM nodes or live connections directly in reactive state.
useSerializer$ output is empty or stale on the client
Root cause: The serialize function returns a value that does not capture the current state, or deserialize does not rebuild it correctly, so the reconstructed object is empty.
Fix: Ensure serialize returns the minimal data needed to fully reconstruct the object, and that deserialize is a pure function of that data. Test the round trip: deserialize(serialize()) should reproduce a usable object.
Related
- Qwik Resumable Architecture — the parent guide on QRL serialization and how resumability turns serialized state into interactivity.
- Optimizing Qwik Resumability for Large Datasets — complementary techniques when the data itself, not the closures, is the bulk of the payload.
- Cross-Boundary Prop Passing — serialising data across the server-client boundary without transmitting more than necessary.
← Back to Qwik Resumable Architecture