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.
Frequently Asked Questions
Why is my Qwik HTML payload so large?
Qwik serializes the state captured by every $ closure into the HTML so it can resume without re-running code. If a closure captures a large object, an entire props record, or a store field it does not use, all of that ends up in the serialized payload. Large payloads almost always trace back to over-captured lexical scope or reactive state that should have been marked noSerialize.
What does noSerialize do in Qwik?
noSerialize marks a value so Qwik never writes it into the serialized state. The value exists on the client only after the code that recreates it runs, and reads as undefined on resume until then. It is the right tool for WebSockets, class instances, chart handles, and other non-reactive objects that must not be transmitted in HTML.
When should I use useSerializer$?
Use useSerializer$ when you need a rich object on the client but only want to transmit a compact form of it. You define how to serialize the object to a small representation and how to reconstruct it on the client, so the HTML carries only the minimal data rather than the full object graph.
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