TBT is the lab-data counterpart to Interaction to Next Paint (INP). It measures how long the main thread is blocked by long tasks during page load, a direct signal of how unresponsive your page feels.
TL;DR
- What: Sum of the blocking portion (anything over 50 ms) of every long task between FCP and TTI.
- Target: Under 200 ms.
- Top three fixes: code-split heavy bundles, defer non-critical scripts, replace synchronous third-party scripts.
What TBT measures
Any JavaScript task on the main thread that runs longer than 50 ms is a "long task." TBT sums the part of each long task that exceeds 50 ms.
Example: three tasks of 80 ms, 120 ms, 40 ms.
- 80 ms task → 30 ms over the threshold → +30 ms TBT
- 120 ms task → 70 ms over → +70 ms TBT
- 40 ms task → under threshold → +0 ms
- Total TBT: 100 ms
While the main thread is blocked, user input (clicks, scrolls, typing) is queued. That's why TBT is the strongest predictor of "the page feels janky."
What's a good TBT score?
| Range | Verdict |
|---|---|
| ≤ 200 ms | Good |
| 200–600 ms | Needs improvement |
| > 600 ms | Poor |
Most common causes
- Large JavaScript bundles parsed and executed on load
- Hydration in SSR/SSG frameworks running expensive React/Vue trees synchronously
- Third-party scripts (analytics, chat, A/B testing, ads) executing on the main thread
- Unnecessary work on page load, initializing modules the user doesn't need yet
Five concrete fixes
1. Code-split by route
In Next.js, Astro, Remix, SvelteKit etc., this is mostly automatic. In a custom app:
// Lazy-load heavy components instead of importing top-level
const HeavyChart = lazy(() => import('./HeavyChart'));
2. Defer non-critical scripts
<!-- WRONG: blocks the parser -->
<script src="analytics.js"></script>
<!-- RIGHT: defers until after document parse -->
<script src="analytics.js" defer></script>
<!-- Even better: load after user idle -->
<script>
window.addEventListener('load', () => {
requestIdleCallback(() => {
const s = document.createElement('script');
s.src = 'analytics.js';
document.head.appendChild(s);
});
});
</script>
3. Break up long tasks
If a task must run, split it into chunks that yield to the browser:
async function processLargeArray(items) {
for (const item of items) {
processItem(item);
// yield to the browser every 5ms
if (performance.now() % 5 === 0) {
await new Promise(r => setTimeout(r, 0));
}
}
}
Or use the newer scheduler.yield() API:
async function processLargeArray(items) {
for (const item of items) {
processItem(item);
if (scheduler?.yield) await scheduler.yield();
}
}
4. Move work to a Web Worker
CPU-heavy logic (parsing, image manipulation, encryption) belongs off the main thread:
const worker = new Worker('/parser.js');
worker.postMessage(rawData);
worker.onmessage = (e) => render(e.data);
5. Audit and trim third-party scripts
Use Lighthouse's "Reduce the impact of third-party code" diagnostic. Common offenders: Google Tag Manager loading 5+ tags, full Intercom/Drift widgets on first load, A/B test SDKs blocking render. Audit each and ask: does this need to load before the user interacts?
Verification
- Re-run Lighthouse. Target TBT < 200 ms.
- Check the "Avoid long main-thread tasks" audit alongside TBT.
- Use Chrome DevTools → Performance panel → record a page load → look at the main thread row. Bars longer than 50 ms (yellow/red flags) are your targets.
Related audits
- Largest Contentful Paint (LCP), load speed
- Reduce unused JavaScript, wasted bytes
- Render-blocking resources, CSS/JS in the critical path
Audit your URL at https://lighthouse-md.com.
Audit your page now
Paste your URL, get scores plus a CLAUDE.md plan for Claude Code.