Back to Performance
Performance
medium
mid

What is Total Blocking Time, and how do you reduce main thread blocking?

TBT = sum of (long task duration − 50ms) between FCP and TTI. Measures how long the main thread is blocked, blocking input. Drivers: large JS bundles, heavy parse/execute, long-running event handlers, sync work in effects. Fixes: code split, defer/lazy JS, move heavy work to workers, break long tasks into chunks (yielding to the event loop), debounce work in handlers.

5 min read·~20 min to think through

TBT quantifies how much the main thread is blocked during page load. It's a lab metric (Lighthouse); INP is its production counterpart.

Definition

For each task longer than 50ms, the blocking time is duration - 50ms. TBT sums those between FCP and TTI (Time to Interactive).

ts
Task 30ms → blocking 0
Task 100ms → blocking 50ms
Task 250ms → blocking 200ms

Target: TBT < 200ms (good in Lighthouse).

Why it matters

While the main thread runs a long task, the browser can't:

  • Respond to clicks/scrolls/typing.
  • Run frame callbacks (animations).
  • Process other events.

Users see a frozen page. Long tasks during load → INP regressions later.

Drivers

1. Large JS bundles

Parse + compile + execute all happen on the main thread. A 1MB bundle on mobile can block for seconds.

2. Heavy synchronous work on mount

Effects that do giant JSON parse, sort, or compute synchronously.

3. Third-party scripts

Analytics, ad networks, A/B test SDKs — often poorly optimized and block the main thread.

4. Hydration in SSR apps

Hydrating a large React tree is one big task.

5. Long-running event handlers

A click that fires a 500ms compute blocks the next click.

Fixes

1. Ship less JS

The easiest one. Audit your bundle:

  • Code split per route (React.lazy / dynamic import).
  • Tree shake unused exports.
  • Replace heavy deps (Moment → date-fns; Lodash → lodash/fp specific imports).
  • Skip polyfills for modern browsers (module/nomodule).

2. Defer non-critical JS

  • defer / async on script tags.
  • Lazy-load non-critical components (modal contents, analytics tracker, chat widget).
  • Move third-party scripts to load after load event.

3. Move heavy work to a worker

JSON parsing, sorting big arrays, image processing, search index building — all good fits.

js
const worker = new Worker("aggregate.js");
worker.postMessage({ data, filters });
worker.onmessage = (e) => setResults(e.data);

The main thread is unblocked while the worker computes.

4. Break long tasks into chunks

The "yield to the event loop" pattern:

js
async function processChunks(items) {
  for (let i = 0; i < items.length; i += 100) {
    process(items.slice(i, i + 100));
    await new Promise((r) => setTimeout(r, 0));   // yield
    // or: scheduler.yield() / scheduler.postTask if available
  }
}

The Scheduler API (scheduler.postTask, scheduler.yield) is the modern, prioritized version.

5. Avoid layout thrashing

Forced sync layout in a loop is a long task. Batch reads then writes.

6. Concurrent React features

useTransition marks state updates as non-urgent — React can interrupt and yield.

jsx
const [isPending, startTransition] = useTransition();
const onFilter = (q) => startTransition(() => setQuery(q));

7. Lazy-hydrate SSR

Hydrate above-the-fold first; defer the rest. React Server Components / Astro Islands automate this.

Measure it

  • Lighthouse / PageSpeed Insights for synthetic TBT.
  • PerformanceObserver for long tasks in production:
js
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    sendBeacon("/longtask", { duration: entry.duration, name: entry.name });
  }
}).observe({ type: "longtask", buffered: true });
  • web-vitals for INP — the production-relevant cousin of TBT.

TBT vs INP

  • TBT: lab/Lighthouse, sums blocking during load.
  • INP: field/RUM, measures the worst interaction latency.
  • Long tasks hurt both — fix once, benefit twice.

Interview framing

"TBT sums how long the main thread is blocked during load — formally, the duration above 50ms of each long task between FCP and TTI. Target < 200ms. The drivers are large JS bundles, heavy synchronous work (parse/sort/compute) on mount, third-party scripts, and hydration. The fixes, in order: ship less JS (code split, tree shake, replace heavy deps); defer non-critical (async/lazy, late third-party); move heavy work to workers; break long tasks with scheduler.yield or async chunks; use useTransition for non-urgent updates; lazy-hydrate. Measure long tasks in production with PerformanceObserver and track INP at p75 — TBT in the lab, INP in the field."

Follow-up questions

  • Difference between TBT and INP?
  • When does moving work to a worker help vs not?
  • How does useTransition affect main-thread work?
  • What's the smallest task that counts as 'long'?

Common mistakes

  • Heavy synchronous compute in useEffect on mount.
  • Importing huge libraries (Moment, full Lodash) for one helper.
  • Loading third-party scripts synchronously.
  • Single big hydration task with no lazy strategy.

Performance considerations

  • Main-thread time is the metric. Less JS, deferred JS, off-main work, and yielding are the four levers.

Edge cases

  • Long tasks during scroll → janky scroll.
  • Long tasks during animation → dropped frames.
  • Workers don't help if the bottleneck is DOM work — workers can't touch the DOM.

Real-world examples

  • Code-splitting per route in Next.js / Remix.
  • PartyTown / web workers for third-party scripts.
  • React Server Components for smaller client bundles.

Senior engineer discussion

Seniors audit the bundle and the long-task waterfall, off-main work where the DOM isn't involved, lazy-hydrate, and use useTransition deliberately. They measure long tasks in production, not just synthetic.

Related questions