Total Blocking Time (TBT) measures how long the main thread is blocked by long tasks between First Contentful Paint and Time to Interactive. It carries 30% of the Lighthouse performance score and is the strongest lab predictor of real-world interactivity problems. This guide walks through a systematic approach to reducing it.
How TBT is calculated
The browser's main thread handles JavaScript execution, style calculation, layout, and painting — all on a single thread. When any task takes longer than 50ms, the excess time is "blocking time" because the browser cannot respond to user input during that period.
Task duration: 120ms
Threshold: 50ms
Blocking time: 70ms (120 - 50)
TBT sums all blocking time from every long task between FCP and TTI. A page with three long tasks of 120ms, 250ms, and 90ms has:
TBT = (120-50) + (250-50) + (90-50) = 70 + 200 + 40 = 310ms
TBT thresholds
| Rating | Value |
|---|---|
| Good | Under 200ms |
| Needs improvement | 200ms – 600ms |
| Poor | Over 600ms |
Step 1: Profile and identify long tasks
Before optimizing, identify what is actually blocking the thread.
Chrome DevTools
- Open DevTools → Performance tab
- Click Record, then reload the page
- Stop recording after the page is interactive
- Look for tasks with red corners (over 50ms)
- Click each long task to see the call stack and source
Long Tasks API
const longTasks = []
new PerformanceObserver(list => {
for (const entry of list.getEntries()) {
longTasks.push({
duration: entry.duration,
blocking: entry.duration - 50,
startTime: entry.startTime,
attribution: entry.attribution?.[0]?.containerSrc || "first-party"
})
}
}).observe({ type: "longtask", buffered: true })
// Log after page settles
setTimeout(() => {
const tbt = longTasks.reduce((sum, t) => sum + t.blocking, 0)
console.table(longTasks)
console.log("Estimated TBT:", tbt, "ms")
}, 10000)
Categorize your long tasks
Sort tasks into buckets:
| Category | Examples | Typical fix |
|---|---|---|
| First-party JS | App init, state hydration, routing | Code split, defer, yield |
| Third-party scripts | Analytics, ads, chat widgets | Defer, facade, Partytown |
| Framework overhead | React hydration, Vue compilation | Partial hydration, SSR |
| Style/layout | Forced reflows, large DOM | Batch reads/writes, reduce DOM |
Focus on the biggest bucket first.
Step 2: Defer third-party scripts
Third-party scripts are often the largest source of blocking time and the easiest to address.
Audit third-party impact
Use Lighthouse's "Third-Party Summary" audit or Chrome DevTools' "Third-party badges" to see how much time each external script consumes.
Defer loading
<!-- Move from <head> to end of <body> with defer -->
<script defer src="https://widget.example.com/chat.js"></script>
<!-- Or load during idle time -->
<script>
if ("requestIdleCallback" in window) {
requestIdleCallback(() => {
const script = document.createElement("script")
script.src = "https://analytics.example.com/tracker.js"
document.body.appendChild(script)
})
} else {
// Fallback: load after 3 seconds
setTimeout(() => {
const script = document.createElement("script")
script.src = "https://analytics.example.com/tracker.js"
document.body.appendChild(script)
}, 3000)
}
</script>
Use facades for heavy embeds
Replace YouTube, Google Maps, and chat widgets with a static preview that loads the real embed only on interaction. This eliminates hundreds of milliseconds of blocking time from the initial load.
Step 3: Break up first-party long tasks
If your own code produces tasks over 50ms, break them into smaller chunks.
Yield between operations
function yieldToMain() {
return new Promise(resolve => setTimeout(resolve, 0))
}
async function initializeApp() {
// Phase 1: critical setup
setupRouter()
await yieldToMain()
// Phase 2: above-fold components
hydrateHeroSection()
await yieldToMain()
// Phase 3: below-fold components
hydrateSidebar()
await yieldToMain()
// Phase 4: non-critical features
initializeAnalytics()
setupPrefetching()
}
Process data in chunks
async function processLargeList(items, batchSize = 50) {
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize)
batch.forEach(processItem)
// Yield after each batch
await new Promise(resolve => setTimeout(resolve, 0))
}
}
Use scheduler.yield() in modern browsers
async function heavyWork() {
for (const task of tasks) {
doWork(task)
if ("scheduler" in globalThis) {
await scheduler.yield()
}
}
}
Step 4: Reduce JavaScript bundle size
Less code means less parsing and execution time.
Route-level code splitting
Only ship the JavaScript required for the current page:
// React/Next.js
const AdminPanel = lazy(() => import("./AdminPanel"))
// Vue
const AdminPanel = defineAsyncComponent(() => import("./AdminPanel.vue"))
Audit bundle composition
Run your bundler's analyzer to find oversized dependencies:
# Webpack
npx webpack-bundle-analyzer stats.json
# Vite
npx vite-bundle-visualizer
# Next.js
ANALYZE=true next build
Look for:
- Libraries included but barely used (import just the functions you need)
- Duplicate dependencies at different versions
- Polyfills shipped to modern browsers
- Development-only code in production bundles
Replace heavy libraries
| Heavy option | Lighter alternative |
|---|---|
| Moment.js (300KB) | date-fns (tree-shakeable) or Temporal API |
| Lodash (70KB full) | Lodash-es (tree-shakeable) or native methods |
| jQuery (90KB) | Native DOM APIs |
Step 5: Optimize forced reflows
JavaScript that reads layout properties (like offsetHeight) and then writes styles forces the browser to recalculate layout synchronously, creating long tasks.
The problem
// Forces layout on every iteration
elements.forEach(el => {
// READ triggers layout
const height = el.offsetHeight
// WRITE invalidates layout, next READ forces recalc
el.style.height = height + 10 + "px"
})
The fix: batch reads and writes
// Read all values first
const heights = elements.map(el => el.offsetHeight)
// Then write all values
elements.forEach((el, i) => {
el.style.height = heights[i] + 10 + "px"
})
Use requestAnimationFrame for visual updates
function updateLayout(changes) {
requestAnimationFrame(() => {
changes.forEach(({ element, styles }) => {
Object.assign(element.style, styles)
})
})
}
Step 6: Optimize framework hydration
Framework hydration is often the single largest long task on server-rendered pages.
Measure hydration time
// Before hydration
performance.mark("hydration-start")
// After hydration completes
performance.mark("hydration-end")
performance.measure("hydration", "hydration-start", "hydration-end")
const hydrationTime = performance.getEntriesByName("hydration")[0].duration
console.log("Hydration took:", hydrationTime, "ms")
Reduce hydration scope
- Use React Server Components to skip hydration for non-interactive parts
- Use Astro's island architecture to hydrate only interactive widgets
- Defer hydration of below-fold components until they scroll into view
Step 7: Set up regression prevention
Lighthouse CI
Fail builds when TBT exceeds your budget:
// lighthouserc.js
module.exports = {
ci: {
assert: {
assertions: {
"total-blocking-time": ["error", { maxNumericValue: 200 }]
}
}
}
}
Bundle size checks
Add a GitHub Action or CI step that compares bundle size against the previous build. Flag any increase over 5KB.
Field monitoring
Track TBT's field counterpart (INP) using CrUX or the web-vitals library:
import { onINP } from "web-vitals"
onINP(metric => {
if (metric.rating === "poor") {
// Alert: real users are experiencing slow interactions
reportToMonitoring(metric)
}
})
Frequently Asked Questions
What is the relationship between TBT and INP?
TBT is a lab metric that measures blocking during page load. INP is a field metric that measures interaction responsiveness throughout the entire page lifecycle. They complement each other: TBT catches load-time issues in CI, INP catches runtime issues in production.
Can CSS cause blocking time?
Indirectly. Large CSS files block rendering (not the main thread), but JavaScript that triggers forced reflows (reading layout properties then writing styles) causes the browser to recalculate styles synchronously on the main thread, which creates blocking time.
Does server-side rendering reduce TBT?
SSR reduces the JavaScript needed to produce the initial visual output but does not eliminate it. Hydration still runs on the client. The net effect depends on how much JavaScript your SSR framework requires for hydration. Partial hydration and streaming SSR typically produce the best results.
How do I handle third-party scripts I cannot control?
Use async or defer attributes, load them during idle time via requestIdleCallback, or use Partytown to run them in a Web Worker. If a third-party script adds more than 100ms of blocking time and you cannot defer it, evaluate whether its business value justifies the performance cost.
What about Web Workers for reducing TBT?
Web Workers run on separate threads and do not contribute to TBT. Move data processing, search indexing, and computational work to Workers. The main thread only handles the final UI update with the results.