Total Blocking Time (TBT) — main-thread blocking and how to reduce it
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.
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).
Task 30ms → blocking 0
Task 100ms → blocking 50ms
Task 250ms → blocking 200msTarget: 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/asyncon script tags.- Lazy-load non-critical components (modal contents, analytics tracker, chat widget).
- Move third-party scripts to load after
loadevent.
3. Move heavy work to a worker
JSON parsing, sorting big arrays, image processing, search index building — all good fits.
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:
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.
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:
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.