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.


Long Tasks Over the First Two Seconds Two main-thread strips. The React app shows three long tasks totalling four hundred and ten milliseconds of blocking time, clustered just after load. The Qwik app shows one short task at load and a small task at the first interaction, totalling forty milliseconds. Only work above fifty milliseconds counts toward total blocking time, which is why the strips look more different than the totals suggest. Long tasks, first two seconds, same page React 210 ms hydrate 120 ms 80 ms TBT ≈ 410 ms — input during this window queues behind the tasks Qwik chunk fetch + resume at first click TBT ≈ 40 ms — but the second task lands inside the interaction, so measure INP too TBT alone flatters resumability. Pair it with an interaction measurement or the comparison is incomplete.

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.

Sanity Checks for a Blocking-Time Comparison Three checks. The sum of long tasks minus fifty milliseconds each should reconstruct the reported total blocking time. The largest task should be attributable to a named function in the flame chart, not to anonymous script. Repeating the run with the interaction removed should leave the resumable app's number nearly unchanged while barely moving the hydrating app's. Three checks before publishing the number 1 · reconstruct TBT by hand sum each long task minus 50 ms; a large disagreement means the trace window was wrong 2 · name the largest task if it is anonymous script from a third party, you are benchmarking your tag manager 3 · re-run without the interaction the resumable number should barely move; if it collapses, the interaction was inflating it

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.


Frequently Asked Questions

Why does Qwik report near-zero Total Blocking Time on load?

Qwik resumes rather than hydrates. It serialises the application's reactive state and event wiring into the HTML on the server, so on load the client executes almost no JavaScript — there is no framework bootstrap and no component re-render pass. React, by contrast, must run hydrateRoot to reconcile the server markup and attach listeners across the tree, and that work accumulates as long tasks, which is what TBT sums.

Does Qwik's TBT advantage mean it is always faster for users?

For load-time responsiveness, yes — a lower TBT means the main thread is free sooner. But resumability moves cost to the first interaction, where Qwik lazily fetches and deserialises the code and state for the triggered handler. If that first interaction is on the critical path, some of the load-time advantage is repaid there, so INP matters as much as TBT for a complete picture.

How do I measure TBT without Lighthouse?

Observe longtask PerformanceObserver entries during load and sum the portion of each task exceeding 50ms that falls between First Contentful Paint and Time to Interactive. That is the definition of Total Blocking Time. A scripted probe that records these entries and computes the sum gives you a framework-agnostic TBT you can run in CI.

← Back to Islands Performance Benchmarks by Framework