Skip to main content
All fixes

Page speed, redirects, Core Web Vitals

Fix main thread blocked: cut Total Blocking Time and INP

Long tasks over 50ms freeze the main thread, spike Total Blocking Time, and tank INP. Profile bottlenecks, break up tasks, and offload to workers to keep the page responsive.

What's happening

The browser's main thread handles HTML parsing, JavaScript execution, style recalc, layout, paint, and event handling. When a single task runs longer than 50ms, the browser can't respond to user input until it completes — that's a long task. Total Blocking Time (TBT) sums the time over 50ms across all tasks during page load, and Lighthouse weights TBT heavily in its Performance score.

Chrome DevTools Performance panel highlights long tasks with red triangles in the Main track. The Bottom-Up tab aggregates by URL and function, showing where the time goes. Lighthouse's "Avoid long main-thread tasks" diagnostic lists each long task with its origin script.

Main-thread blocking is the dominant cause of poor INP. Even after page load, every interaction has to compete with rendering work, third-party scripts, and React reconciliation. A 250ms layout pass triggered by a hover handler turns a 50ms click into a 300ms INP.

Why it matters

Main-thread blocking directly causes poor INP, which fails Core Web Vitals at the 75th percentile and demotes mobile rankings. It also slows TTI (Time to Interactive) and lab TBT, dragging the Lighthouse Performance score down.

User-facing, blocked main thread feels like a frozen page — buttons that don't respond, scroll that stutters, text input that lags. On mid-tier Android phones (Moto G Power class), main-thread CPU is 4-5x slower than a MacBook, so a barely-noticeable 80ms task on dev becomes a 400ms INP failure for real users.

Common causes

  • Large React component trees re-rendering synchronously on state updates.
  • Third-party scripts (analytics, A/B testing, chat widgets) parsing and executing on every interaction.
  • Heavy data transformation (JSON parsing, sorting large arrays) in event handlers.
  • CSS-in-JS runtime style generation on every render.
  • Long initial hydration of server-rendered components.
  • Large bundles requiring extensive parse and compile time on first load.
  • Synchronous layout thrashing inside requestAnimationFrame loops.

Detect this on your site

Run a quick scan with the Speed Test. The tool surfaces this exact issue with the records and context needed to apply the fix below.

Open Speed Test

How to fix it

  1. 1

    Profile in DevTools with CPU throttling

    Open DevTools Performance panel, set CPU throttling to 4x slowdown, and record a session. The Main track shows long tasks as red-flagged blocks. The Bottom-Up tab attributes time to specific scripts and functions — that's where to start optimizing.

  2. 2

    Break up long tasks with scheduler.yield()

    Inside expensive functions, await scheduler.yield() (Chrome 129+) or fall back to await new Promise((r) => setTimeout(r, 0)). Each yield gives the browser a chance to handle queued input. Especially valuable inside long for-loops and recursive functions.

  3. 3

    Use React.startTransition for non-urgent updates

    Wrap state updates that don't need to feel instant in startTransition. React renders them at lower priority, so urgent input handlers stay responsive. Combined with Suspense, you get smooth transitions even under heavy render load.

  4. 4

    Move heavy work to a Web Worker

    Image processing, large JSON parsing, and data computation belong in a Worker. Use Comlink to call worker functions ergonomically with an async API. The main thread stays free for rendering and input.

  5. 5

    Run third-party scripts in Partytown

    Partytown hijacks third-party scripts and runs them in a Web Worker. Google Analytics, GTM, Hotjar, Intercom — all stop blocking the main thread. The main-thread JS budget for your own app effectively doubles.

  6. 6

    Memoize expensive components and selectors

    React.memo on rarely-changing components, useMemo for derived data, useCallback for handlers passed to memoized children. The React Profiler shows which components re-render unnecessarily — fix those first, ignore the rest.

  7. 7

    Reduce initial bundle size

    Smaller bundles parse and compile faster. Code-split routes, lazy-load heavy components, and tree-shake unused imports. Server Components in Next.js App Router shift work entirely off the client.

  8. 8

    Track INP and TBT in CI

    Lighthouse CI catches Performance regressions before merge. Track TBT specifically with a budget (e.g. under 300ms on mobile). Pair with field RUM via web-vitals.js to catch regressions production-only data surfaces.

Example

// Bad: 5000-item sort blocks for 200ms+
button.addEventListener("click", () => {
  const sorted = items.sort((a, b) => a.name.localeCompare(b.name));
  setState(sorted);
});

// Good: yield between chunks, mark as transition
import { startTransition } from "react";

async function chunkedSort(items) {
  const chunkSize = 500;
  const result = [];
  for (let i = 0; i < items.length; i += chunkSize) {
    result.push(...items.slice(i, i + chunkSize));
    await scheduler.yield();
  }
  return result.sort((a, b) => a.name.localeCompare(b.name));
}

button.addEventListener("click", async () => {
  const sorted = await chunkedSort(items);
  startTransition(() => setState(sorted));
});

Yield between chunks plus startTransition to keep input responsive.

Frequently asked

TBT is a lab metric measuring blocking time during page load. INP is a field metric measuring interaction responsiveness across the whole session. TBT correlates with INP but they're not the same — fixing TBT often fixes INP but not always.

No. React rendering must happen on the main thread because it touches the DOM. Workers help with pure computation. For React-heavy bottlenecks use startTransition memoization and code-splitting instead.

requestIdleCallback runs only when the browser is idle which is great for genuinely non-urgent work. For interactive chunking scheduler.yield() (or setTimeout 0) gives finer control. Use rIC for analytics-style background work.

Related fixes