React SSR
This example renders React components to HTML strings on workers using renderToString. If your server does SSR, this is probably the example closest to your real workload — parse JSON input, normalize data, render a component, return HTML.
How it works
Section titled “How it works”The host generates JSON payload strings. The host path calls renderUserCardHost directly (parse + normalize + renderToString). The worker path sends the same payloads through createPool. Byte totals are compared once to verify the host and worker produce identical output, then mitata benchmarks both paths.
Three files:
bench_react_ssr.ts— the benchmark itselfrender_user_card.tsx— the SSR component and worker taskutils.ts— input payloads and normalization helpers
Example payload and result
Section titled “Example payload and result”Input:
{ "id": "u42", "name": "Ari Lane", "handle": "@ari", "bio": "Building fast UIs.", "plan": "pro", "location": "Austin, TX", "joinedAt": "2026-01-18", "tags": ["react", "ssr", "workers"], "stats": { "posts": 42, "followers": 1200, "following": 180, "likes": 9800 }, "alerts": { "unread": 3, "lastLogin": "2026-01-18" }}Minimal usage:
using pool = createPool({ threads: 4 })({ renderUserCard });const html = await pool.call.renderUserCard(payloadJson);Result:
<article class="user-card" data-user-id="u42">...</article>Deno setup (TSX + npm)
Section titled “Deno setup (TSX + npm)”If you run this with Deno and see Uncaught SyntaxError: Unexpected token '<', set a root deno.json so Deno transpiles TSX and resolves npm packages:
{ "nodeModulesDir": "auto", "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "react" }}Optional benchmark
Section titled “Optional benchmark”bun src/bench_react_ssr.tsdeno run -A src/bench_react_ssr.tsnpx tsx src/bench_react_ssr.tsExpected output (4 cores / 8 threads, Bun 1.4, 2,000 requests per iteration):
byte parity check: host=3,811,213 worker=3,811,213 OK match
React SSR benchmark (mitata)workload: parse + normalize + render to HTMLrequests per iteration: 2,000threads: 4
benchmark avg (min … max)host (2,000 req) 288.22 ms/iter (264.75 ms … 332.57 ms)knitting (4 threads, 2,000 req) 149.34 ms/iter (131.43 ms … 180.24 ms)
summary knitting (4 threads, 2,000 req) 1.94x faster than host (2,000 req)Rendering is CPU-bound, so workers can run it in parallel. The speedup depends on your component, payloads, available cores, and other work on the host.
import { createPool, isMain } from "knitting";import { bench, boxplot, run, summary } from "mitata";import { renderUserCard, renderUserCardHost } from "./render_user_card.tsx";import { buildUserPayloads } from "./utils.ts";
// Sized from measurement, not from core count. One worker is the one setting// that loses: the transport is not free, and a single lane cannot outrun the// host it is competing with. Several workers also let the pool use its default// shared-submit work stealing, which a one-worker pool never engages.const THREADS = 4;const REQUESTS = 2_000;
async function main() { const payloads = buildUserPayloads(REQUESTS); using pool = createPool({ threads: THREADS })({ renderUserCard }); let sink = 0;
const hostBytes = runHost(payloads); const workerBytes = await runWorkers(pool.call.renderUserCard, payloads); console.log( `byte parity check: host=${hostBytes.toLocaleString()} ` + `worker=${workerBytes.toLocaleString()} ` + (hostBytes === workerBytes ? "OK match" : "MISMATCH"), ); if (hostBytes !== workerBytes) { throw new Error("Host and worker HTML byte totals differ."); }
console.log("\nReact SSR benchmark (mitata)"); console.log("workload: parse + normalize + render to HTML"); console.log("requests per iteration:", REQUESTS.toLocaleString()); console.log("threads:", THREADS, "\n");
boxplot(() => { summary(() => { bench(`host (${REQUESTS.toLocaleString()} req)`, () => { sink = runHost(payloads); });
bench( `knitting (${THREADS} threads, ${REQUESTS.toLocaleString()} req)`, async () => { sink = await runWorkers(pool.call.renderUserCard, payloads); }, ); }); });
await run(); console.log("last html bytes:", sink.toLocaleString());}
function runHost(payloads: string[]): number { let htmlBytes = 0; for (let i = 0; i < payloads.length; i++) { htmlBytes += renderUserCardHost(payloads[i]!).length; } return htmlBytes;}
async function runWorkers( callRender: (payload: string) => Promise<string>, payloads: string[],): Promise<number> { const jobs: Promise<string>[] = new Array(payloads.length); for (let i = 0; i < payloads.length; i++) { jobs[i] = callRender(payloads[i]!); }
const results = await Promise.all(jobs); let htmlBytes = 0; for (let i = 0; i < results.length; i++) htmlBytes += results[i]!.length; return htmlBytes;}
if (isMain) { main().catch((error) => { console.error(error); process.exitCode = 1; });}import React from "react";import { renderToString } from "react-dom/server";import { task } from "knitting";import { clamp, engagementScore, formatJoinDate, initials, levelForScore, type NormalizedUser, normalizeUser,} from "./utils.ts";
function Stat({ label, value }: { label: string; value: number }) { return ( <div className="stat"> <span className="stat-value">{value.toLocaleString()}</span> <span className="stat-label">{label}</span> </div> );}
function Badge({ plan }: { plan: "free" | "pro" }) { const text = plan === "pro" ? "PRO" : "FREE"; return <span className={`badge badge-${plan}`}>{text}</span>;}
function UserCard({ user }: { user: NormalizedUser }) { const score = engagementScore(user.stats); const level = levelForScore(score); const joined = formatJoinDate(user.joinedAt); const profileCompleteness = (user.bio ? 30 : 0) + (user.location ? 20 : 0) + (user.tags.length ? 20 : 0) + (user.handle ? 10 : 0) + (user.stats.posts ? 20 : 0); const completeness = clamp(profileCompleteness, 10, 100); const topTags = user.tags.slice(0, 6); const achievementBadges = [ user.stats.followers >= 1000 ? "1k+ followers" : "", user.stats.likes >= 5000 ? "5k+ likes" : "", user.stats.posts >= 50 ? "50+ posts" : "", ].filter(Boolean);
return ( <article className="user-card" data-user-id={user.id}> <header className="user-header"> <div className="avatar" aria-hidden="true"> {initials(user.name)} </div> <div className="meta"> <div className="title-row"> <h2>{user.name}</h2> <Badge plan={user.plan} /> </div> <div className="handle">{user.handle}</div> <div className="subline"> <span>📍 {user.location}</span> <span>• Joined {joined}</span> </div> <div className="level"> <span className="pill">Level: {level}</span> <span className="pill subtle">Score {score.toLocaleString()}</span> </div> </div> <div className="alerts"> <span className="pill">{user.alerts.unread} unread</span> <span className="pill subtle"> Last login {user.alerts.lastLogin} </span> </div> </header>
<section className="bio"> <h3>Bio</h3> {user.bio ? <p>{user.bio}</p> : <p className="muted">No bio yet.</p>} </section>
<section className="profile-complete"> <h3>Profile completeness</h3> <div className="progress"> <div className="progress-bar" style={{ width: `${completeness}%` }} aria-label={`Profile completeness ${completeness}%`} /> </div> <div className="muted">{completeness}% complete</div> </section>
<section className="stats-grid" aria-label="User stats"> <Stat label="Posts" value={user.stats.posts} /> <Stat label="Followers" value={user.stats.followers} /> <Stat label="Following" value={user.stats.following} /> <Stat label="Likes" value={user.stats.likes} /> </section>
<section className="achievements"> <h3>Highlights</h3> <ul> {achievementBadges.length > 0 ? ( achievementBadges.map((badge) => ( <li key={badge} className="tag"> {badge} </li> )) ) : <li className="tag muted">Getting started</li>} </ul> </section>
<section className="tags"> <h3>Interests</h3> <ul> {topTags.length > 0 ? ( topTags.map((tag) => ( <li key={tag} className="tag"> {tag} </li> )) ) : <li className="tag muted">No tags</li>} </ul> </section>
<footer className="actions"> <button type="button">Follow</button> <button type="button">Message</button> <button type="button" className="ghost"> View profile </button> </footer> </article> );}
export function renderUserCardHost(payloadJson: string): string { const parsed = JSON.parse(payloadJson) as unknown; const user = normalizeUser(parsed); const html = renderToString(<UserCard user={user} />); return html;}
export const renderUserCard = task({ f: renderUserCardHost,});export type UserStats = { posts: number; followers: number; following: number; likes: number;};
export type UserAlerts = { unread: number; lastLogin: string;};
export type UserPayload = { id?: string; name?: string; handle?: string; bio?: string; plan?: "free" | "pro"; location?: string; joinedAt?: string; tags?: string[]; stats?: Partial<UserStats>; alerts?: Partial<UserAlerts>;};
export type NormalizedUser = Required<UserPayload> & { stats: UserStats; alerts: UserAlerts;};
const TAGS = [ "react", "ssr", "typescript", "performance", "parallel", "workers", "ui", "web",];const LOCATIONS = ["Austin, TX", "Seattle, WA", "Brooklyn, NY", "Denver, CO"];
function pickFrom<T>(arr: T[], index: number): T { return arr[index % arr.length]!;}
function toNumber(value: unknown, fallback: number): number { return Number.isFinite(value) ? Number(value) : fallback;}
function toStringArray(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.filter((item): item is string => typeof item === "string");}
export function makeUserPayloadJson(i: number): string { const short = i.toString(36);
return JSON.stringify({ id: `u${short}`, name: `User ${short.toUpperCase()}`, handle: `@${short}`, bio: `Building fast UIs. Coffee + TypeScript. (${short})`, plan: i % 7 === 0 ? "pro" : "free", location: pickFrom(LOCATIONS, i), joinedAt: `202${(i % 4) + 2}-0${(i % 8) + 1}-1${i % 9}`, tags: [ pickFrom(TAGS, i), pickFrom(TAGS, i + 1), pickFrom(TAGS, i + 2), pickFrom(TAGS, i + 3), ], stats: { posts: (i % 120) + 1, followers: (i * 13) % 50_000, following: (i * 7) % 5_000, likes: (i * 31) % 250_000, }, alerts: { unread: i % 25, lastLogin: `2026-0${(i % 8) + 1}-0${(i % 9) + 1}`, }, });}
export function buildUserPayloads(count: number): string[] { const payloads = new Array<string>(count); for (let i = 0; i < count; i++) payloads[i] = makeUserPayloadJson(i); return payloads;}
export function normalizeUser(payload: unknown): NormalizedUser { const obj = (payload ?? {}) as Record<string, unknown>;
const id = typeof obj.id === "string" ? obj.id : "unknown"; const name = typeof obj.name === "string" ? obj.name : "Anonymous"; const handle = typeof obj.handle === "string" ? obj.handle : `@${id}`; const bio = typeof obj.bio === "string" ? obj.bio : ""; const plan = obj.plan === "pro" ? "pro" : "free"; const location = typeof obj.location === "string" ? obj.location : "Unknown"; const joinedAt = typeof obj.joinedAt === "string" ? obj.joinedAt : "2024-05-01";
const statsRaw = (obj.stats ?? {}) as Record<string, unknown>; const alertsRaw = (obj.alerts ?? {}) as Record<string, unknown>;
const stats: UserStats = { posts: toNumber(statsRaw.posts, 0), followers: toNumber(statsRaw.followers, 0), following: toNumber(statsRaw.following, 0), likes: toNumber(statsRaw.likes, 0), };
const alerts: UserAlerts = { unread: toNumber(alertsRaw.unread, 0), lastLogin: typeof alertsRaw.lastLogin === "string" ? alertsRaw.lastLogin : "2026-01-18", };
const tags = toStringArray(obj.tags);
return { id, name, handle, bio, plan, location, joinedAt, tags, stats, alerts, };}
export function formatJoinDate(value: string): string { const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return date.toLocaleDateString("en-US", { month: "short", year: "numeric" });}
export function initials(name: string): string { const parts = name.trim().split(/\s+/); const first = parts[0]?.[0] ?? "U"; const second = parts.length > 1 ? parts[1]?.[0] ?? "" : ""; return (first + second).toUpperCase();}
export function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value));}
export function engagementScore(stats: UserStats): number { return Math.round( stats.posts * 2 + stats.likes * 0.05 + stats.followers * 0.4 + stats.following * 0.1, );}
export function levelForScore(score: number): string { if (score >= 5000) return "Legend"; if (score >= 2500) return "Elite"; if (score >= 1000) return "Rising"; if (score >= 300) return "Active"; return "New";}Sizing the pool
Section titled “Sizing the pool”This example used to run one worker plus an inliner, and with that configuration it was slower than rendering on the host. Two things were working against it:
- A single worker still pays the full transport cost. A single lane pays the full cost of moving a payload out and HTML back, and gets one core’s worth of rendering in exchange. Multi-worker pools also enable shared-submit work stealing by default, which a one-worker pool never engages.
- The inliner takes back the thread you were trying to free. It renders on the host between dispatches, so the event loop you moved SSR off is busy again — and it is the same thread awaiting every result.
Dropping the inliner and moving to four workers made the same benchmark
roughly 1.8-1.9x faster than the host, and cut per-iteration
allocation by about two thirds. Choose threads from your own measurements:
the right value depends on your component, your payloads, and what else the
host is doing. Start at 2-4, measure, and only
add the inliner if it earns its place on your workload.
Why SSR is a great fit for workers
Section titled “Why SSR is a great fit for workers”renderToString is synchronous and CPU-bound — it walks your component tree and builds an HTML string. For an individual request, it may be fast, but under load it blocks the event loop and everything else waits. Moving SSR to a worker pool means your main thread stays free to accept requests and handle I/O while rendering happens in parallel. The Hono server example shows what this looks like in a real HTTP server context.