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.


Four Techniques, Ranked by What They Actually Remove Four horizontal bars showing typical byte savings on a representative page. Projecting props to the fields the component reads removes the most. Deduplicating repeated references comes second. Moving derived values to computation at use time is third. Trimming precision on numbers and dates is smallest but free. Each is annotated with the risk it carries. Typical savings on a 120 KB serialised payload project props −54 KB · send three fields, not the record risk: none deduplicate refs −28 KB · one copy + ids risk: identity comparisons must use ids drop derived values −16 KB risk: recompute cost moves to the client trim precision −7 KB risk: rounding must not change displayed values Do the first one before the others — most payloads are dominated by fields nothing reads.

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; }

Holding the Gain With a Build-Time Budget A pipeline: the build renders a fixed set of routes, extracts the serialised state container from each document, compares its size to a committed budget file, and fails the job with a per-route diff when a route exceeds its budget. The final step writes the new sizes back so the budget can be updated deliberately. A gain you do not measure is a gain you will lose render fixed routes same fixtures every run extract state container measure bytes, not gzip compare to budget committed JSON file fail with a per-route diff listing route · +38 KB Make raising the budget an explicit commit A reviewer seeing "+38 KB serialised state" in the diff asks a question. A silently growing payload does not. Budgets that update themselves automatically are decoration.

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.

← Back to Qwik Resumable Architecture