Total Blocking Time (TBT) is the unsung hero of web performance metrics—a lab metric that reveals how much your JavaScript is blocking user interactions. While users never directly experience TBT (it's measured in controlled environments, not real-world usage), it's the most reliable predictor of First Input Delay (FID) and overall interactivity issues. Think of TBT as your early warning system for JavaScript performance problems.
What is Total Blocking Time?
TBT measures the sum of all time periods between First Contentful Paint (FCP) and Time to Interactive (TTI) where the main thread is blocked for long enough to prevent input responsiveness. Specifically, it adds up the blocking portion of all tasks that exceed 50ms.
Here's how TBT calculation works:
- Identify all tasks longer than 50ms between FCP and TTI
- For each long task, subtract 50ms (the threshold)
- Sum all the blocking portions
- Report total as TBT
Example calculation:
Task 1: 120ms → Blocking time: 70ms (120 - 50)
Task 2: 30ms → Blocking time: 0ms (under threshold)
Task 3: 250ms → Blocking time: 200ms (250 - 50)
Task 4: 90ms → Blocking time: 40ms (90 - 50)
TBT = 70 + 0 + 200 + 40 = 310ms
Lighthouse TBT thresholds:
- Good: Under 200ms
- Needs Improvement: 200ms to 600ms
- Poor: Over 600ms
Why TBT Matters: The FID Connection
Lab Metric for Field Reality
TBT's primary value is its strong correlation with FID:
- Correlation coefficient: 0.90+ with FID
- Predictive power: Sites with good TBT have 93% chance of good FID
- Early detection: Catch issues before they impact real users
This correlation exists because both metrics measure the same underlying issue—main thread blocking—but from different perspectives:
- FID: Measures actual delay when users interact (field data)
- TBT: Measures potential delays during page load (lab data)
Impact on User Experience
High TBT manifests as:
Rage Clicking: Users repeatedly click buttons that don't respond immediately
Dead Clicks: Input events lost because JavaScript is processing
Delayed Feedback: Visual updates lag behind user actions
Perceived Freezing: Page appears frozen or broken
Research shows that reducing TBT from 600ms to 200ms results in:
- 50% reduction in rage clicks
- 23% improvement in task completion
- 15% increase in user satisfaction scores
Development Workflow Benefits
TBT enables performance testing in CI/CD:
// Lighthouse CI configuration
module.exports = {
ci: {
assert: {
assertions: {
"total-blocking-time": ["error", { maxNumericValue: 200 }],
"first-contentful-paint": ["warn", { maxNumericValue: 2000 }],
"largest-contentful-paint": ["error", { maxNumericValue: 2500 }]
}
}
}
}
How TBT Works: Understanding Long Tasks
The 50ms Threshold
Why 50ms? This threshold comes from human perception research:
- 16ms: One frame at 60fps (smooth animations)
- 50ms: Perceptible delay threshold
- 100ms: Noticeable lag begins
- 300ms: Frustration point
Tasks under 50ms allow the browser to respond to input within a frame or two, maintaining perceived responsiveness.
Main Thread Blocking Culprits
JavaScript Execution
// Bad: Synchronous processing blocks for entire duration
function processData(largeArray) {
return largeArray
.map(complexTransform) // 100ms
.filter(expensiveFilter) // 80ms
.reduce(heavyCalculation) // 120ms
// Total blocking: 300ms
}
// Good: Chunked processing with yielding
async function processDataChunked(largeArray) {
const chunks = chunkArray(largeArray, 100)
const results = []
for (const chunk of chunks) {
results.push(...chunk.map(complexTransform))
await yieldToMain() // Let browser handle input
}
return results
}
function yieldToMain() {
return new Promise(resolve => {
setTimeout(resolve, 0)
})
}
Style and Layout Calculations
// Bad: Forced synchronous layout
elements.forEach(el => {
el.style.left = el.offsetLeft + 10 + "px" // Forces layout each iteration
})
// Good: Batch reads and writes
const positions = elements.map(el => el.offsetLeft)
elements.forEach((el, i) => {
el.style.left = positions[i] + 10 + "px"
})
Third-Party Scripts
// Common blocking patterns in third-party code:
// - Synchronous XHR requests
// - document.write() usage
// - Large initialization routines
// - Polling loops
// - Heavy DOM manipulation
TBT Timeline Visualization
FCP TTI
├──────┬────────┬──────────┬──┬──────────┬────────┬──┤
│ 30ms │ 120ms │ 45ms │ │ 250ms │ 90ms │ │
│ │████70ms│ │ │████200ms │███40ms │ │
└────────┘ └────────┘└────────┘
Long Task Long Task Long Task
TBT = 70ms + 200ms + 40ms = 310ms
Best Practices for Reducing TBT
1. Code Splitting and Lazy Loading
Route-Based Code Splitting
// React with lazy loading
import { lazy, Suspense } from "react"
// Only load code for current route
const Home = lazy(() => import("./routes/Home"))
const Dashboard = lazy(() => import("./routes/Dashboard"))
const Settings = lazy(() => import("./routes/Settings"))
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
)
}
Component-Level Code Splitting
// Load heavy components on demand
const HeavyChart = lazy(
() => import(/* webpackChunkName: "charts" */ "./HeavyChart")
)
function Analytics() {
const [showChart, setShowChart] = useState(false)
return (
<div>
<button onClick={() => setShowChart(true)}>Show Analytics</button>
{showChart && (
<Suspense fallback={<div>Loading chart...</div>}>
<HeavyChart />
</Suspense>
)}
</div>
)
}
2. Optimize Third-Party Scripts
Facade Pattern for Embeds
// Instead of loading YouTube immediately
// <iframe src="https://youtube.com/embed/..."></iframe>
// Use a facade that loads on interaction
function YouTubeFacade({ videoId, title }) {
const [activated, setActivated] = useState(false)
if (!activated) {
return (
<div
className="youtube-facade"
onClick={() => setActivated(true)}
style={{
backgroundImage: `url(https://i.ytimg.com/vi/${videoId}/hqdefault.jpg)`
}}
>
<button className="play-button">Play Video</button>
</div>
)
}
return <iframe src={`https://youtube.com/embed/${videoId}?autoplay=1`} />
}
Partytown for Third-Party Scripts
<!-- Move third-party scripts to web worker -->
<script
type="text/partytown"
src="https://www.googletagmanager.com/gtag/js"
></script>
<script type="text/partytown">
gtag('config', 'GA_MEASUREMENT_ID');
</script>
3. Use Web Workers for Heavy Processing
Offload Computation to Workers
// main.js
const worker = new Worker("processor.worker.js")
async function processLargeDataset(data) {
return new Promise((resolve, reject) => {
worker.postMessage({ cmd: "process", data })
worker.onmessage = e => resolve(e.data)
worker.onerror = reject
})
}
// processor.worker.js
self.addEventListener("message", e => {
if (e.data.cmd === "process") {
// Heavy processing doesn't block main thread
const result = performComplexCalculation(e.data.data)
self.postMessage(result)
}
})
Comlink for Easier Worker Usage
import * as Comlink from "comlink"
// processor.worker.js
const api = {
async processData(data) {
return heavyProcessing(data)
},
async analyzeImage(imageData) {
return imageAnalysis(imageData)
}
}
Comlink.expose(api)
// main.js
import * as Comlink from "comlink"
const processor = Comlink.wrap(
new Worker(new URL("./processor.worker.js", import.meta.url))
)
// Use like a regular async function
const result = await processor.processData(data)
4. Implement Task Scheduling
Use scheduler.postTask() API
// Modern task scheduling with priorities
if ("scheduler" in window) {
// High priority: User-facing updates
scheduler.postTask(() => updateUI(), { priority: "user-blocking" })
// Normal priority: Default tasks
scheduler.postTask(() => processData(), { priority: "user-visible" })
// Low priority: Background work
scheduler.postTask(() => analyticsTrack(), { priority: "background" })
}
RequestIdleCallback for Non-Critical Work
function scheduleNonCriticalWork(work) {
if ("requestIdleCallback" in window) {
requestIdleCallback(work, { timeout: 2000 })
} else {
setTimeout(work, 1)
}
}
// Schedule analytics, prefetching, etc.
scheduleNonCriticalWork(() => {
sendAnalytics()
prefetchNextPage()
cleanupCache()
})
5. Optimize Bundle Size
Tree Shaking
// Bad: Importing entire library
import _ from "lodash"
const result = _.debounce(fn, 300)
// Good: Import only what you need
import debounce from "lodash/debounce"
const result = debounce(fn, 300)
// Better: Use native or smaller alternatives
function debounce(fn, delay) {
let timeoutId
return (...args) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => fn(...args), delay)
}
}
Dynamic Imports for Features
// Load features on demand
button.addEventListener("click", async () => {
const { exportToPDF } = await import("./pdf-exporter")
exportToPDF(document)
})
// Webpack magic comments for better control
const module = await import(
/* webpackChunkName: "pdf-utils" */
/* webpackPrefetch: true */
"./pdf-exporter"
)
Common TBT Issues and Solutions
Issue 1: Framework Hydration
Problem: SSR hydration blocks the main thread
Solution: Progressive or partial hydration
// Astro's island architecture
---
import InteractiveComponent from './InteractiveComponent';
---
<!-- Only hydrate when visible -->
<InteractiveComponent client:visible />
<!-- Or on idle -->
<InteractiveComponent client:idle />
<!-- Or on interaction -->
<InteractiveComponent client:load />
Issue 2: Large JSON Processing
Problem: Parsing and processing large JSON blocks the thread
Solution: Streaming JSON parser
// Use streaming JSON parsing
import { parser } from "stream-json"
import { streamArray } from "stream-json/streamers/StreamArray"
const pipeline = chain([
fs.createReadStream("large.json"),
parser(),
streamArray()
])
pipeline.on("data", ({ key, value }) => {
// Process items one by one
processItem(value)
})
Issue 3: Animation Jank
Problem: JavaScript animations blocking the thread
Solution: Use CSS animations or Web Animations API
// Bad: JavaScript animation loop
function animate() {
element.style.left = calculatePosition();
requestAnimationFrame(animate);
}
// Good: Web Animations API
element.animate([
{ transform: 'translateX(0)' },
{ transform: 'translateX(100px)' }
], {
duration: 300,
easing: 'ease-in-out'
});
// Best: CSS animations (run on compositor)
.element {
animation: slide 300ms ease-in-out;
}
@keyframes slide {
from { transform: translateX(0); }
to { transform: translateX(100px); }
}
Tools for Measuring TBT
Lighthouse
# CLI measurement
lighthouse https://example.com --only-categories=performance --output=json
# Extract TBT from results
cat lighthouse-report.json | jq '.audits["total-blocking-time"].numericValue'
Chrome DevTools
// Performance observer for long tasks
const observer = new PerformanceObserver(list => {
let tbt = 0
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
tbt += entry.duration - 50
console.log(
`Long task: ${entry.duration}ms, blocking: ${entry.duration - 50}ms`
)
}
}
console.log(`Current TBT: ${tbt}ms`)
})
observer.observe({ entryTypes: ["longtask"] })
WebPageTest
WebPageTest provides detailed TBT breakdowns:
- Main thread timeline
- Task attribution
- Third-party impact
- Frame-by-frame analysis
Custom TBT Monitoring
class TBTMonitor {
constructor() {
this.tbt = 0
this.fcpTime = 0
this.monitoring = false
}
start() {
// Detect FCP
new PerformanceObserver(list => {
const fcp = list.getEntriesByName("first-contentful-paint")[0]
if (fcp) {
this.fcpTime = fcp.startTime
this.startMonitoring()
}
}).observe({ entryTypes: ["paint"] })
}
startMonitoring() {
this.monitoring = true
new PerformanceObserver(list => {
if (!this.monitoring) return
for (const entry of list.getEntries()) {
// Only count tasks after FCP
if (entry.startTime > this.fcpTime && entry.duration > 50) {
this.tbt += entry.duration - 50
}
}
}).observe({ entryTypes: ["longtask"] })
}
stop() {
this.monitoring = false
return this.tbt
}
}
Frequently Asked Questions
Why doesn't TBT appear in field data?
TBT is a lab-only metric because it requires knowing when Time to Interactive (TTI) occurs, which varies based on user behavior. Field data uses FID and INP instead, which measure actual user interactions.
How does TBT relate to Lighthouse score?
TBT has the highest weight (30%) in Lighthouse's performance score calculation. A high TBT can significantly impact your overall score, even if other metrics are good.
Can Service Workers affect TBT?
Service Workers run on a separate thread and don't directly affect TBT. However, poorly implemented Service Workers that trigger excessive main thread callbacks can indirectly increase TBT.
What's a good TBT target for complex apps?
While <200ms is ideal, complex applications should:
- Target <300ms for critical user journeys
- Keep individual tasks under 100ms
- Ensure TBT doesn't exceed 600ms on slower devices
- Focus on progressive loading to minimize initial TBT
Does TBT matter after page load?
TBT specifically measures blocking time between FCP and TTI. For ongoing interactivity issues after page load, look at INP (Interaction to Next Paint) which measures responsiveness throughout the page lifecycle.