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


Hydration versus Resumption on the Load Timeline Two horizontal timelines sharing an axis from First Contentful Paint to Time to Interactive. The React lane shows a wide blocking task labelled hydrateRoot that accumulates Total Blocking Time. The Qwik lane shows almost no blocking on load, with a small marker noting cost deferred to first interaction. LOAD TIMELINE — FCP → TTI FCP TTI (later for React) React (hydrated) hydrateRoot — long task TBT accumulates here ≈ 210 ms Qwik (resumable) near-zero blocking on load — TBT ≈ 30 ms cost deferred to first interaction (deserialise on demand)

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

  1. 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.
  2. TBT definition sanity. Your probe’s sum should match Lighthouse’s total-blocking-time audit within ~20% when run under the same CPU rate. A large gap means the probe missed tasks or the throttling differed.
  3. 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.
  4. 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.


← Back to Islands Performance Benchmarks by Framework