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.
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.
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:
| Aspect | TBT (Lab) | INP (Field) |
|---|---|---|
| When measured | Page load to TTI | Entire session |
| Data source | Lighthouse simulation | Real user monitoring |
| Interactions | None (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.
TBT appears in the Metrics section with a score from 0-100. Expand the "Diagnostics" section to find:
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 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))
}
}
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')
Every kilobyte of JavaScript adds parse and compile time, even if the code never runs.
Find unused code with Coverage tool:
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:
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:
loading="lazy" for iframe embedspreconnect) 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">
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:
// 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} />
}
Suspense boundaries:import { Suspense } from 'react'
export default function Page() {
return (
<>
<Header />
<Suspense fallback={<ProductsSkeleton />}>
<Products />
</Suspense>
</>
)
}
<ClientOnly> to defer non-critical hydration:<template>
<main>
<CriticalContent />
<ClientOnly>
<NonCriticalWidget />
<template #fallback>
<WidgetPlaceholder />
</template>
</ClientOnly>
</main>
</template>
Lazy prefix:<template>
<!-- Only loads when component enters viewport -->
<LazyHeavyChart v-if="chartVisible" :data="chartData" />
</template>
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>
)
}
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).
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:
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.