SSR + compression
This example adds Brotli compression to the React SSR example. It compares rendering and compression on the worker with rendering on the worker and compression on the host. Keeping both steps together also reduces the amount of data returned to the host.
How it works
Section titled “How it works”The host generates JSON payload strings, and the benchmark measures three placements of the same work:
- host — render and compress on the main thread.
- worker: render only, host compresses — the worker returns HTML, the host runs Brotli.
- worker: render + compress — both steps on the worker, so only the compressed bytes come back.
All three must produce identical compressed byte totals; the benchmark checks that before measuring.
bench_react_ssr_compress.ts— the benchmarkrender_user_card_compressed.tsx— the SSR + compression task
Payloads and the card component come from the React SSR example, so the only new code here is the compression step.
Example input and output
Section titled “Example input and output”Input is the same JSON payload shape as the plain React SSR example. The worker call is:
const compressed = await pool.call.renderUserCardCompressed(payloadJson);Output is a Brotli-compressed Buffer, not an HTML string. That is the point of this example:
do the expensive render and compression work in one place, then return the smaller payload.
Compression uses built-in node:zlib — no extra packages.
Optional benchmark
Section titled “Optional benchmark”bun src/bench_react_ssr_compress.tsdeno run -A src/bench_react_ssr_compress.tsnpx tsx src/bench_react_ssr_compress.tsExpected output (4 cores / 8 threads, Bun 1.4, 100 requests per iteration):
byte parity check: host=52,551 worker=52,551 split=52,551 OK match
React SSR + compression benchmark (mitata)workload: parse + normalize + render + brotlirequests per iteration: 100threads: 4 + inliner
benchmark avg (min … max)host: render + compress 294.18 ms/iterworker: render only, host compresses 284.79 ms/iterworker: render + compress 111.20 ms/iter
summary worker: render + compress 2.65x faster than host: render + compress 2.56x faster than worker: render only, host compressesThe middle row is the interesting one: moving only the render to a worker is barely faster than doing everything on the host. You pay to ship the full HTML back, and every Brotli call still lands on the thread you were trying to keep free. Keeping compression in the worker frees the host from that work and reduces the return payload.
Brotli is also far more expensive than renderToString alone, which changes the
pool shape: the plain SSR example is faster without an inliner, while this
one is faster with one. The host spends most of its time waiting here, so
giving it a share of the work pays. That is a workload property, not a rule —
measure it on yours.
import { task } from "knitting";import { brotliCompressSync } from "node:zlib";import { renderUserCardHost } from "../react_ssr/render_user_card.tsx";
export const compressHtml = (html: string): Uint8Array => brotliCompressSync(html);
/** * Render the card to HTML and Brotli-compress it. Exported so the benchmark can * run exactly the same work on the host instead of keeping a second copy of it. */export function renderAndCompressHost(payloadJson: string): Uint8Array { return compressHtml(renderUserCardHost(payloadJson));}
export const renderUserCardCompressed = task({ f: renderAndCompressHost,});import { createPool, isMain } from "knitting";import { bench, boxplot, run, summary } from "mitata";import { renderUserCard } from "../react_ssr/render_user_card.tsx";import { buildUserPayloads } from "../react_ssr/utils.ts";import { compressHtml, renderAndCompressHost, renderUserCardCompressed,} from "./render_user_card_compressed.tsx";
// Brotli is expensive enough that the host lane is worth having here: unlike the// plain SSR example, the inliner earns its place on this workload.const THREADS = 4;const REQUESTS = 100;
async function main() { const payloads = buildUserPayloads(REQUESTS); using pool = createPool({ threads: THREADS, inliner: { batchSize: 8 }, })({ renderUserCardCompressed, renderUserCard }); let sink = 0;
const onWorker = pool.call.renderUserCardCompressed; const renderOnly = pool.call.renderUserCard;
const hostBytes = runHost(payloads); const workerBytes = await runWorkerCompress(onWorker, payloads); const splitBytes = await runHostCompress(renderOnly, payloads); const parity = hostBytes === workerBytes && hostBytes === splitBytes; console.log( `byte parity check: host=${hostBytes.toLocaleString()} ` + `worker=${workerBytes.toLocaleString()} ` + `split=${splitBytes.toLocaleString()} ` + (parity ? "OK match" : "MISMATCH"), ); if (!parity) throw new Error("Compressed byte totals differ.");
console.log("\nReact SSR + compression benchmark (mitata)"); console.log("workload: parse + normalize + render + brotli"); console.log("requests per iteration:", REQUESTS.toLocaleString()); console.log("threads:", THREADS, "+ inliner\n");
boxplot(() => { summary(() => { bench("host: render + compress", () => { sink = runHost(payloads); });
bench("worker: render only, host compresses", async () => { sink = await runHostCompress(renderOnly, payloads); });
bench("worker: render + compress", async () => { sink = await runWorkerCompress(onWorker, payloads); }); }); });
await run(); console.log("last compressed bytes:", sink.toLocaleString());}
function runHost(payloads: string[]): number { let compressedBytes = 0; for (let i = 0; i < payloads.length; i++) { compressedBytes += renderAndCompressHost(payloads[i]!).byteLength; } return compressedBytes;}
// Both steps on the worker: only the compressed bytes cross the boundary.async function runWorkerCompress( callCompressed: (payload: string) => Promise<Uint8Array>, payloads: string[],): Promise<number> { const jobs = payloads.map(callCompressed); const results = await Promise.all(jobs);
let total = 0; for (let i = 0; i < results.length; i++) total += results[i]!.byteLength; return total;}
// Render on the worker, compress on the host: the full HTML crosses back, and// every brotli call lands on the thread you were trying to keep free.async function runHostCompress( callRender: (payload: string) => Promise<string>, payloads: string[],): Promise<number> { const jobs = payloads.map(callRender); const results = await Promise.all(jobs);
let total = 0; for (let i = 0; i < results.length; i++) { total += compressHtml(results[i]!).byteLength; } return total;}
if (isMain) { main().catch((error) => { console.error(error); process.exitCode = 1; });}Compression placement matters
Section titled “Compression placement matters”Where you compress affects both throughput and data transfer. If you compress on the worker, you send a small compressed buffer back to the host. If you compress on the host, you first transfer the full uncompressed HTML string across the thread boundary, then compress it. For a server, measure rendering and compression together on the worker to see whether that reduces request latency and transfer overhead.