Fix Total Blocking Time for Better INP

Reduce TBT to improve your Lighthouse performance score. TBT measures main thread blocking - the lab proxy for real-world INP.
Harlan WiltonHarlan Wilton7 min read Published

TBT accounts for 30% of your Lighthouse performance score. That's the largest weight of any single metric. A site with great LCP, CLS, and FCP can still score 70 if TBT is in the red.

Good TBT is 200ms or less on mobile. Poor is above 600ms. Desktop thresholds are tighter: 150ms good, 350ms poor. Most sites fall somewhere in the "needs improvement" zone.

TBTTotal Blocking Time
30% weight
Good ≤200msPoor >600ms

What's the Problem?

TBT measures the invisible: time when the page looks ready but can't respond to user input.

The calculation is specific. Lighthouse identifies every task on the main thread between First Contentful Paint and Time to Interactive. For each task exceeding 50ms, it counts only the time above 50ms as blocking time. A 70ms task contributes 20ms to TBT. A 200ms task contributes 150ms.

Task duration: 70ms  → TBT contribution: 20ms  (70 - 50)
Task duration: 200ms → TBT contribution: 150ms (200 - 50)
Task duration: 45ms  → TBT contribution: 0ms   (under threshold)

Why this matters for real users: TBT is a lab metric. You won't see it in field data. But it strongly correlates with INP (Interaction to Next Paint), the Core Web Vital that measures real interaction responsiveness. Fix TBT in the lab, and INP improves in the field.

The 50ms threshold exists because human perception research shows responses under 100ms feel instant. At 50ms task length, the browser has 50ms remaining in that 100ms window to process user input and update the screen.

How TBT Relates to INP

INP measures actual user interactions in the field. TBT measures potential for blocking during controlled lab conditions.

They target the same underlying issue: main thread availability. But they measure differently:

AspectTBT (Lab)INP (Field)
When measuredPage load to TTIEntire session
Data sourceLighthouse simulationReal user monitoring
InteractionsNone (just task timing)Clicks, taps, keypresses
Threshold (good)≤200ms≤200ms

A site can have good TBT and bad INP if the blocking happens after initial load (lazy-loaded features, SPA navigation, dynamic content).

A site can have bad TBT and good INP if users don't interact during the blocking period, or if the blocking tasks are CPU-throttled in Lighthouse more than real devices experience.

Fix both. TBT catches load-time issues. INP catches runtime issues.

How to Identify

Lighthouse Report

TBT appears in the Metrics section with a score from 0-100. Expand the "Diagnostics" section to find:

  • Avoid long main-thread tasks - Lists the longest tasks by duration
  • Reduce JavaScript execution time - Shows script-by-script execution time
  • Minimize main-thread work - Breaks down time by category (script, style, layout)

Chrome DevTools Performance Panel

  1. Open DevTools → Performance tab
  2. Enable CPU throttling (4x slowdown) to match Lighthouse conditions
  3. Record a page load
  4. Look at the "Total Blocking Time" in the summary
  5. Examine the Main thread for tasks with red corners (>50ms)

WebPageTest

Run a test with "Capture Blocking Time" enabled. The waterfall view highlights long tasks, and the filmstrip shows exactly when the page was unresponsive.

The Fix

1. Break Up Long Tasks

The most direct fix: no task should exceed 50ms.

// Before: 300ms blocking task
function processAllData(items) {
  return items.map(transform).filter(validate).sort(compare)
}

// After: yields every 50ms of work
async function processAllData(items) {
  const results = []
  const startTime = performance.now()

  for (const item of items) {
    results.push(transform(item))

    // Yield if we've been working for 40ms
    if (performance.now() - startTime > 40) {
      await scheduler.yield()
      startTime = performance.now()
    }
  }

  return results.filter(validate).sort(compare)
}

For simpler cases, chunk your work:

async function processInChunks(items, chunkSize = 100) {
  for (let i = 0; i < items.length; i += chunkSize) {
    const chunk = items.slice(i, i + chunkSize)
    processChunk(chunk)
    await new Promise(resolve => setTimeout(resolve, 0))
  }
}

2. Defer Non-Critical JavaScript

Scripts that don't affect initial render should load after the page is interactive.

<!-- Before: blocks parsing -->
<script src="analytics.js"></script>
<script src="chat-widget.js"></script>

<!-- After: loads after HTML parsing -->
<script src="analytics.js" defer></script>
<script src="chat-widget.js" defer></script>

For third-party scripts you don't control:

// Load after page is idle
function loadWhenIdle(src) {
  if ('requestIdleCallback' in window) {
    requestIdleCallback(() => {
      const script = document.createElement('script')
      script.src = src
      document.body.appendChild(script)
    })
  }
  else {
    setTimeout(() => {
      const script = document.createElement('script')
      script.src = src
      document.body.appendChild(script)
    }, 2000)
  }
}

loadWhenIdle('https://cdn.chat-widget.com/widget.js')

3. Remove Unused Code

Every kilobyte of JavaScript adds parse and compile time, even if the code never runs.

Find unused code with Coverage tool:

  1. DevTools → More tools → Coverage
  2. Click record, load page, interact
  3. Red bars indicate unused bytes

Tree-shake aggressively:

// Before: imports entire library
import _ from 'lodash'
const result = _.debounce(fn, 300)

// After: imports single function
import debounce from 'lodash/debounce'
const result = debounce(fn, 300)

Audit your dependencies:

npx depcheck # Find unused dependencies
npx bundlephobia <package-name> # Check bundle size impact

Replace heavy libraries with lighter alternatives:

  • moment.js (300KB) → date-fns (tree-shakeable) or dayjs (2KB)
  • lodash (70KB) → native methods or individual imports
  • axios (13KB) → fetch API

4. Optimize Third-Party Scripts

Third-party scripts often contribute 50%+ of TBT. You don't control their code, but you control when and how they load.

Audit third-party impact:

// Log third-party task durations
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 50) {
      console.log(`Long task: ${entry.duration}ms`, entry.attribution)
    }
  }
})
observer.observe({ entryTypes: ['longtask'] })

Strategies:

  • Load below the fold scripts on scroll, not on page load
  • Use loading="lazy" for iframe embeds
  • Self-host critical third-party scripts to control delivery
  • Use resource hints (preconnect) for scripts you can't delay
<!-- Preconnect to third-party origins -->
<link rel="preconnect" href="https://www.googletagmanager.com">
<link rel="preconnect" href="https://www.google-analytics.com">

5. Web Workers for Heavy Computation

Move CPU-intensive work off the main thread entirely.

// main.js
const worker = new Worker('data-processor.js')

function analyzeData(data) {
  return new Promise((resolve) => {
    worker.postMessage(data)
    worker.onmessage = e => resolve(e.data)
  })
}

// data-processor.js (runs on separate thread)
self.onmessage = (e) => {
  const result = heavyComputation(e.data)
  self.postMessage(result)
}

Candidates for Web Workers:

  • JSON parsing of large payloads
  • Image/video processing
  • Data sorting and filtering
  • Cryptographic operations
  • Search indexing

Framework-Specific Solutions

Next.jsUse React Server Components (App Router) to eliminate client-side JavaScript:
// app/products/page.js - Server Component by default
// No JavaScript sent to client for this component
export default async function ProductsPage() {
  const products = await fetchProducts()
  return <ProductList products={products} />
}
For client components, use Suspense boundaries:
import { Suspense } from 'react'

export default function Page() {
  return (
    <>
      <Header />
      <Suspense fallback={<ProductsSkeleton />}>
        <Products />
      </Suspense>
    </>
  )
}
NuxtUse <ClientOnly> to defer non-critical hydration:
<template>
  <main>
    <CriticalContent />
    <ClientOnly>
      <NonCriticalWidget />
      <template #fallback>
        <WidgetPlaceholder />
      </template>
    </ClientOnly>
  </main>
</template>
Lazy-load components with the Lazy prefix:
<template>
  <!-- Only loads when component enters viewport -->
  <LazyHeavyChart v-if="chartVisible" :data="chartData" />
</template>
ReactUse React.lazy() for route-level code splitting:
import { lazy, Suspense } from 'react'

const AdminDashboard = lazy(() => import('./AdminDashboard'))

function App() {
  return (
    <Suspense fallback={<Loading />}>
      <Routes>
        <Route path="/admin" element={<AdminDashboard />} />
      </Routes>
    </Suspense>
  )
}

Verify the Fix

  1. Run Lighthouse multiple times. TBT varies between runs due to CPU timing. Take the median of 3-5 runs.
  2. Test with CPU throttling. DevTools → Performance → CPU: 4x slowdown matches Lighthouse mobile simulation.
  3. Check the task breakdown. After optimization, no single task should exceed 50ms in the Performance panel.

Expected improvement: Reducing a single 600ms task to six 100ms tasks can drop TBT from 550ms to 300ms (each 100ms task contributes 50ms instead of one task contributing 550ms).

Common Mistakes

Focusing only on your code. Third-party scripts often dominate TBT. Profile before optimizing.

Over-deferring critical scripts. Deferring everything delays interactivity. Interactive elements need their JavaScript loaded and executed.

Testing only on fast devices. TBT measures blocking on throttled CPU. A task that runs in 40ms on your M1 Mac runs in 160ms on a throttled mobile simulation.

Ignoring hydration. SSR frameworks still need to hydrate. That hydration JavaScript contributes to TBT just like any other script.

Often appears alongside:

Test Your Entire Site

TBT varies dramatically across pages. A fast homepage with a slow checkout flow still frustrates users. Unlighthouse audits your entire site and ranks pages by TBT impact.