Guideintermediate

Reducing Blocking Time: A Step-by-Step TBT Optimization Guide

A hands-on guide to reducing Total Blocking Time with task chunking, script deferral, third-party audits, and main thread optimization techniques.

Rankwise Team·Updated Mar 12, 2026·5 min read

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

RatingValue
GoodUnder 200ms
Needs improvement200ms – 600ms
PoorOver 600ms

Step 1: Profile and identify long tasks

Before optimizing, identify what is actually blocking the thread.

Chrome DevTools

  1. Open DevTools → Performance tab
  2. Click Record, then reload the page
  3. Stop recording after the page is interactive
  4. Look for tasks with red corners (over 50ms)
  5. 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:

CategoryExamplesTypical fix
First-party JSApp init, state hydration, routingCode split, defer, yield
Third-party scriptsAnalytics, ads, chat widgetsDefer, facade, Partytown
Framework overheadReact hydration, Vue compilationPartial hydration, SSR
Style/layoutForced reflows, large DOMBatch 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 optionLighter 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.

Part of the SEO Fundamentals topic

Newsletter

Stay ahead of AI search

Weekly insights on GEO and content optimization.