Benchmarking Qwik vs React Total Blocking Time
Total Blocking Time is the metric where the difference between hydration and resumability is starkest. A React app must run hydrateRoot to reconcile its server-rendered tree and attach listeners, and that work lands as long tasks that block the main thread; a Qwik app resumes from serialised state and executes almost nothing on load. This guide measures that gap on an identical interactive workload, so the number reflects the architectural difference rather than a workload or throttling artefact. It is a focused instance of islands performance benchmarks by framework, and the mechanism behind Qwik’s result is detailed in Qwik resumable architecture.
Prerequisites
Implementation Steps
Step 1 — Build the identical interactive app
Goal: Same component tree, same interactivity, differing only in framework.
Fix a small app with genuine interactive breadth — say a filterable list of 200 rows with a search input, a sort control, and per-row toggles, so hydration has real work to do. The Qwik version uses resumable primitives:
// src/routes/bench/index.tsx — Qwik. component$ + useSignal are resumable:
// their reactive state and handlers are serialised into HTML on the server,
// so NONE of this executes on load — it resumes only when a handler fires.
import { component$, useSignal } from '@builder.io/qwik';
export default component$(() => {
const query = useSignal(''); // serialised, not re-run on load
const rows = useRows(); // server-provided data
return (
<>
{/* onInput$ is lazily loaded on first input — no load-time listener cost */}
<input onInput$={(e) => (query.value = (e.target as HTMLInputElement).value)} />
<ul>
{rows.filter((r) => r.includes(query.value)).map((r) => <li key={r}>{r}</li>)}
</ul>
</>
);
});
The React version renders the same tree but must hydrate it on load:
// app/bench/List.tsx — React. 'use client' makes the whole subtree hydrate:
// hydrateRoot reconciles every node and attaches every listener at load time,
// which is the main-thread work TBT measures.
'use client';
import { useState } from 'react';
export default function List({ rows }: { rows: string[] }) {
const [query, setQuery] = useState(''); // state initialised during hydration
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>
{rows.filter((r) => r.includes(query)).map((r) => <li key={r}>{r}</li>)}
</ul>
</>
);
}
Expected output: Both apps render the same 200-row list with identical filtering behaviour.
Step 2 — Serve both from production builds
Goal: Measure optimised output on equivalent servers.
# Qwik City production build + preview server.
npm run build && npm run preview -- --port 5173 &
# React (Next.js) production build + server.
next build && next start -p 3000 &
Expected output: Both apps serve the bench route from a production build.
Step 3 — Capture long tasks and compute TBT
Goal: Sum the blocking portion of every long task between FCP and TTI — the definition of TBT — without depending on Lighthouse’s model.
// tbt-probe.mjs — run per app via Playwright. Records longtask entries during
// load and sums the >50ms portion of each. Framework-agnostic TBT.
import { chromium } from 'playwright';
async function measureTBT(url) {
const browser = await chromium.launch();
const page = await browser.newPage();
// Pin CPU throttling via CDP so both apps face the same main-thread budget.
const client = await page.context().newCDPSession(page);
await client.send('Emulation.setCPUThrottlingRate', { rate: 4 });
await page.goto(url, { waitUntil: 'load' });
const tbt = await page.evaluate(() => new Promise((resolve) => {
let blocking = 0;
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
// Only the portion of each long task beyond 50ms counts toward TBT.
blocking += Math.max(0, e.duration - 50);
}
}).observe({ type: 'longtask', buffered: true });
// Settle, then report the accumulated blocking time.
setTimeout(() => resolve(blocking), 3000);
}));
await browser.close();
return tbt;
}
const median = (xs) => xs.sort((a, b) => a - b)[Math.floor(xs.length / 2)];
for (const [name, url] of [['qwik', 'http://localhost:5173/bench'], ['react', 'http://localhost:3000/bench']]) {
const runs = [];
for (let i = 0; i < 9; i++) runs.push(await measureTBT(url));
console.log(`${name}: median TBT ${median(runs).toFixed(0)}ms`);
}
Expected output:
qwik: median TBT 30ms
react: median TBT 210ms
Representative of a 200-row interactive list; the ratio is the durable signal.
Step 4 — Attribute the difference
Goal: Confirm the gap is hydration versus resumption, not a confound.
Record a Performance trace of the React load and locate the hydrateRoot long task — its width should account for most of React’s TBT. On the Qwik trace, confirm there is no comparable load-time task; the equivalent work appears only after you trigger the first interaction. This division is the whole point: React pays on load, Qwik pays on interaction.
Expected output: A React flame chart dominated by one Evaluate Script / hydration task, and a Qwik flame chart that is near-empty on load with a deferred task appearing after the first input.
Verification
- Workload parity. Both apps must render the same number of rows and the same interactive controls. Diff the DOM; a smaller Qwik tree would inflate its advantage unfairly.
- TBT definition sanity. Your probe’s sum should match Lighthouse’s
total-blocking-timeaudit within ~20% when run under the same CPU rate. A large gap means the probe missed tasks or the throttling differed. - Interaction cross-check. Because Qwik defers cost to interaction, measure INP on the first input too — a complete comparison reports both, per measuring hydration and interactivity metrics. A framework winning TBT but losing INP badly is not universally faster.
- Variance gate. Report the interquartile range; reject runs where it exceeds ~15% of the median and quiet the machine before re-running.
Troubleshooting
React TBT is near zero too — the difference vanished
Root cause: The React subtree is not actually hydrating meaningful work — either the list is tiny, or the component was accidentally left as a Server Component with no 'use client', so nothing hydrates on the client.
Fix: Confirm 'use client' is present on the interactive component and that the row count is large enough (a few hundred) to produce a measurable hydration pass. Verify in a Performance trace that a hydrateRoot task actually runs.
Qwik shows a surprisingly large load-time task
Root cause: Something forced eager execution — a top-level module side effect, a non-$ event handler, or a useVisibleTask$ that runs on load. Resumability only holds if state and handlers stay serialisable and lazy.
Fix: Move eager logic into lazy $() boundaries or useTask$ that tracks a signal, and avoid useVisibleTask$ for anything not strictly needed at load. Re-trace to confirm the load-time task is gone.
My probe's TBT disagrees with Lighthouse by a wide margin
Root cause: Different CPU throttling, or the probe’s settle window missed late long tasks that Lighthouse captured (or vice versa).
Fix: Pin the same CPU rate in both (CDP setCPUThrottlingRate at 4 to match Lighthouse’s cpuSlowdownMultiplier=4), and extend the probe’s settle timeout until repeated runs stabilise. Compare only under matched throttling.
Related
- Islands Performance Benchmarks by Framework — the methodology and the six-framework aggregate this head-to-head fits into.
- Astro vs Next.js: A TTI Benchmark Workflow — the sibling TTI-focused workflow.
- Qwik Resumable Architecture — the mechanism that produces Qwik’s near-zero load-time blocking.