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.

How a Qwik $ Closure Becomes Serialized State A $ closure captures three variables from lexical scope. Two small reactive values flow into the serialized qwik state block embedded in HTML. A large socket object marked noSerialize is excluded from serialization and is instead recreated on the client after resume. onClick$ closure captures lexical scope count (signal) · 8 B userId (string) · 24 B socket (noSerialize) excluded — recreated on client Serialized state in HTML <script type="qwik/json"> count, userId only socket NOT present Client recreates socket after resume useVisibleTask$(() => new WebSocket(...))

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

  1. 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.

  2. 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 noSerialize value that is read before its recreating task runs will be undefined; guard those reads.

  3. 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 noSerialize or handled by useSerializer$.

  4. Budget in CI. Add an assertion that fails the build if the qwik/json block 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.


← Back to Qwik Resumable Architecture