Technical

Total Blocking Time (TBT)

A lab metric that measures the total time the main thread is blocked by long tasks, preventing user input responsiveness between First Contentful Paint and Time to Interactive.

Quick Answer

  • What it is: A lab metric that measures the total time the main thread is blocked by long tasks, preventing user input responsiveness between First Contentful Paint and Time to Interactive.
  • Why it matters: TBT is the best lab proxy for First Input Delay (FID) and helps identify JavaScript performance issues before deployment.
  • How to check or improve: Break up long tasks, optimize third-party scripts, and reduce main thread work to achieve TBT under 200ms.

When you'd use this

TBT is the best lab proxy for First Input Delay (FID) and helps identify JavaScript performance issues before deployment.

Example scenario

Hypothetical scenario (not a real company)

A team might use Total Blocking Time (TBT) when Break up long tasks, optimize third-party scripts, and reduce main thread work to achieve TBT under 200ms.

Common mistakes

  • Confusing Total Blocking Time (TBT) with First Input Delay (FID): A Core Web Vitals metric that measures the time from when a user first interacts with a page to the time when the browser is able to begin processing event handlers in response.
  • Confusing Total Blocking Time (TBT) with Time to Interactive (TTI): A performance metric that measures the time from page start until the page is reliably interactive, meaning it displays useful content, responds to user interactions within 50ms, and has registered event handlers.

How to measure or implement

  • Break up long tasks, optimize third-party scripts, and reduce main thread work to achieve TBT under 200ms

Analyze your JavaScript performance

Start here
Updated Jan 20, 2026·4 min read

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:

  1. Identify all tasks longer than 50ms between FCP and TTI
  2. For each long task, subtract 50ms (the threshold)
  3. Sum all the blocking portions
  4. 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.

Put GEO into practice

Generate AI-optimized content that gets cited.

Try Rankwise Free
Newsletter

Stay ahead of AI search

Weekly insights on GEO and content optimization.