How to Reduce Total Blocking Time (TBT): A Real 25-Second-to-Zero Case Study
We run a lot of Lighthouse audits. Most of them are boring in the best possible way — green scores, small optimizations, done. This one wasn’t boring.
A client-facing site we’d just shipped came back with a First Contentful Paint of 0.7 seconds and a Largest Contentful Paint of 0.7 seconds. Genuinely excellent numbers. Then, two rows down on the same report: a Total Blocking Time of 25,260 milliseconds.
That’s not a typo. That’s twenty-five seconds.
The page was painting almost instantly and then becoming completely unusable for the better part of half a minute. Buttons that didn’t respond. Scroll that stuttered. A visitor would see a beautiful, fully-rendered page and then be unable to actually do anything with it. This article walks through exactly how we found the cause, exactly what the fix looked like in code, and the reproducible method you can use to audit your own site for the same problem.
What Is Total Blocking Time, and Why It Matters
Total Blocking Time measures how long the browser’s main thread was too busy to respond to user input, in the window between First Contentful Paint and Time to Interactive. Specifically, it sums up the “blocking portion” — anything past the first 50 milliseconds — of every long task that runs during that window.
A single JavaScript task that runs for 60ms contributes 10ms to TBT. A task that runs for 5,000ms contributes 4,950ms. TBT doesn’t care how many tasks there are; it cares how long the browser is unable to do anything else, including responding to a click, a scroll, or a keypress.
This matters for two overlapping reasons:
- It’s a strong lab-metric proxy for INP (Interaction to Next Paint), the field metric Google actually uses as a Core Web Vital. A page with catastrophic TBT will almost always have catastrophic real-world INP too — visitors experience it as a frozen, unresponsive page, which is exactly the failure mode INP is designed to catch.
- It’s invisible if you only look at paint metrics. FCP and LCP measure how fast something appears. TBT measures how fast the page becomes usable. A page can nail the first and completely fail the second — which is exactly what happened here.
For the official methodology, see web.dev’s Total Blocking Time explainer — we’re not going to re-litigate Google’s own definition here, just show you what it looks like when it goes catastrophically wrong on a real project.
Why TBT Explodes: The Usual Suspects
In our experience, runaway TBT almost always traces back to one of a small handful of causes:
| Cause | How it blocks the main thread |
|---|---|
| Heavy third-party scripts | Analytics, chat widgets, ad tech, and tag managers often execute large bundles synchronously on load |
| Eager hydration of framework “islands” | Modern frameworks (Astro, Qwik, Next.js) ship partial hydration to avoid this — but a misconfigured “lazy” directive can accidentally hydrate immediately |
| Unoptimized embeds | 3D scenes, video players, interactive maps, and canvas/WebGL widgets frequently pull in multi-megabyte runtimes that parse and execute synchronously |
| Unminified or unsplit JS bundles | Shipping your entire application as one bundle means the browser has to parse and execute all of it before any of it can run |
Our case landed squarely in the third category — and it’s the one most generic “improve your Core Web Vitals” articles don’t cover in depth, because it’s less common than bloated analytics scripts. It’s worth understanding in detail, because if you’re using any interactive 3D, video, or canvas-heavy library, you’re one misconfigured loading directive away from the same result.
Case Study: Finding a 25-Second Main-Thread Deadlock
The Symptom
The Lighthouse report was the first clue, and it was a strange one. Fast paint, catastrophic blocking:
First Contentful Paint 0.7 s ✅
Largest Contentful Paint 0.7 s ✅
Cumulative Layout Shift 0.004 ✅
Total Blocking Time 25,260 ms 🔴
Speed Index 2.0 s ✅

Every metric except one said “fast site.” One metric said “unusable site.” That contradiction is the whole story — it’s exactly why you can’t audit performance from paint metrics alone.
The Investigation
Before touching any code, we ruled things out systematically rather than guessing:
Step 1 — check for third-party scripts. We searched the entire codebase for the usual suspects: Google Tag Manager, analytics snippets, tracking pixels, chat widgets.
grep -r "gtag|GoogleTagManager|analytics|dataLayer|fbq|pixel|hotjar" src/
# No matches
Clean. Nothing there.
Step 2 — check every hydration directive. The site was built on Astro, which uses an islands architecture — components are static HTML by default and only ship JavaScript if you explicitly opt them into hydration with a client:* directive. So we searched for every one of those directives across the project:
grep -r "client:load|client:idle|client:visible|client:only" src/
One result. A single interactive component, hydrated with client:visible.
The Root Cause
client:visible is supposed to be the “lazy” option — it uses an IntersectionObserver to defer hydration until the element actually scrolls into the viewport, so components below the fold don’t cost anything until a visitor scrolls to them.
The problem: this particular component sat inside the page’s hero section — above the fold, visible the instant the page painted. client:visible’s intersection check fired essentially immediately, which made it functionally identical to client:load despite being coded as deferred.
That single hydration triggered a dynamic import of a 3D/WebGL rendering library. The resulting bundle breakdown:
| Chunk | Size (uncompressed) |
|---|---|
| WebGL scene runtime | 2.04 MB |
| Physics engine | 1.99 MB |
| Font/text rendering | 173 KB |
| Framework runtime | 187 KB |
| Supporting modules (audio, mesh, compression) | ~280 KB |
| Total | ~4.9 MB |
Nearly five megabytes of JavaScript, parsed and executed synchronously, immediately after first paint — on top of the actual work of initializing a WebGL context and a physics engine. That is exactly the shape of workload that produces one enormous, uninterruptible long task (or a tight chain of them), which is precisely what Total Blocking Time is built to catch.
The Fix
The instinct here is often to reach for requestIdleCallback or to fiddle with the loading directive’s timing. Neither actually solves this. The task is long because of what’s inside it — a third-party library’s synchronous WebGL/physics initialization — not because of when it starts. You can’t requestIdleCallback your way out of a task that’s inherently multi-second once it begins.
The fix that actually works is removing the heavy work from the automatic page-load path entirely, and gating it behind real user intent:
// Before: hydrates and loads the full WebGL runtime
// automatically the instant the component is visible
export function SceneHero() {
return (
<Card>
<SplineScene scene="..." className="h-full w-full" />
</Card>
);
}
// After: renders a lightweight static poster by default.
// The heavy import only fires after a deliberate click.
import { useState } from "react";
export function SceneHero() {
const [loaded, setLoaded] = useState(false);
return (
<Card>
{loaded ? (
<SplineScene scene="..." className="h-full w-full" />
) : (
<div className="poster">
<p>Interactive 3D Preview</p>
<button onClick={() => setLoaded(true)}>
Launch 3D Scene
</button>
</div>
)}
</Card>
);
}
This works because of how dynamic imports behave: the heavy module is never fetched, parsed, or executed until the component that needs it actually attempts to render. By not rendering <SplineScene> until the user clicks, the entire 4.9MB payload — and the long task it produces — moves out of the automatic page-load path completely. Visitors who never click never pay the cost at all. Visitors who do click experience it after a deliberate interaction, not as a mysterious freeze.
The Result
We re-ran the audit against the live, deployed fix — not a local guess, an actual production Lighthouse pass:
| Metric | Before | After |
|---|---|---|
| Total Blocking Time | 25,260 ms | 0 ms |
| Network requests for the heavy bundle on page load | Immediate | Zero — only fires on click |
| Cumulative Layout Shift | 0.004 | 0 |
| First Contentful Paint | 0.7 s | 0.7 s (unchanged — it was never the problem) |

We also confirmed the mechanism directly, not just the score: logging every network request on page load showed zero requests to the heavy chunks before the click, and exactly the expected request firing immediately after.
How to Audit Your Own Site for TBT
You don’t need special tooling beyond what’s already in your browser:
- Run Lighthouse (Chrome DevTools → Lighthouse tab, or
npx lighthouse <url>) and check the Total Blocking Time and “Minimize main-thread work” audits. - Open the Performance panel in DevTools, record a page load, and look for red-flagged long tasks in the main thread track — anything highlighted past the 50ms mark is contributing to TBT.
- Check what triggered each long task. Click into it in the flame chart to see the call stack — this will point you at the specific script or component responsible.
- If you’re using an islands/partial-hydration framework, audit every lazy-loading directive by hand. “Lazy” and “below the fold” are not the same thing, and a directive that’s lazy in name only will behave exactly like eager loading.
One honest tip most audits won’t give you: check for noise from your own machine before you trust every finding. In our own testing, several flagged issues (unminified-javascript, unused-javascript) traced directly back to a browser extension injected by local antivirus software, not the site itself — confirmed by checking the actual flagged URL. Always verify the source of a flagged script before you spend time “fixing” something that isn’t actually part of your site.
5 Proven Fixes for High Total Blocking Time
| # | Fix | When to use it |
|---|---|---|
| 1 | Gate heavy interactive/third-party widgets behind explicit user intent | Any embed — 3D, video, maps, chat — that isn’t essential to the page’s core content |
| 2 | Code-split and dynamically import anything not needed for first paint | Large component trees, admin-only features, rarely-used modals |
| 3 | Audit every lazy-loading directive if you’re on an islands framework | Astro, Qwik, or any framework offering partial hydration |
| 4 | Move genuinely heavy computation to a Web Worker | Data processing, image manipulation, or parsing that must run on load |
| 5 | Replace or trim bloated dependencies rather than deferring around them | When the fix from #1–#4 still leaves an unavoidably heavy task |
Mistakes That Make TBT Worse
It’s worth being direct about what doesn’t work, because a lot of performance advice sounds correct without actually addressing the mechanism:
requestIdleCallbackis not a fix for a single long task. It changes when a task starts, not how long it runs once it starts. If the task itself takes four seconds, wrapping its start inrequestIdleCallbackstill gives you a four-second task — just scheduled slightly later.- You can’t always minify or defer your way out of a genuinely heavy third-party library. If a WebGL engine needs to parse a scene graph and initialize a physics simulation, that work has to happen synchronously somewhere — the only real lever you have is when and whether it happens, not how fast the library’s own internals run.
- Removing a feature’s automatic behavior is sometimes the actual fix, not a workaround. Gating the 3D scene behind a click isn’t a compromise on the feature — it’s the version of the feature that doesn’t break the page for everyone who never interacts with it.
FAQ
What’s considered a good Total Blocking Time score? Lighthouse scores TBT under 200ms as good, 200–600ms as needs improvement, and above 600ms as poor. Anything in the thousands of milliseconds — let alone 25,260ms — indicates a serious main-thread blocking issue, not a minor optimization opportunity.
Does TBT directly affect my Core Web Vitals or rankings? TBT itself isn’t a Core Web Vital — INP is the field metric Google actually uses for ranking. But TBT is one of the strongest lab-metric predictors of poor INP, so treating a high TBT score as a warning sign for real-world interactivity problems is well justified.
How is TBT different from FID and INP? FID (First Input Delay, now deprecated as a Core Web Vital) measured the delay before the first interaction was processed. INP measures the latency of every interaction throughout the page’s lifecycle and reports the worst one. TBT is a lab-only metric that estimates overall main-thread congestion during load, independent of any specific user interaction.
Can third-party scripts alone cause TBT this high? Yes, easily — but in our case, third-party scripts were completely absent. The cause was first-party code: a single heavy component hydrating earlier than intended. It’s a useful reminder that TBT audits shouldn’t stop at “check your ad tech” — first-party JavaScript, especially framework hydration and heavy embeds, deserves the same scrutiny.
Need a Technical Audit Like This for Your Site?
If your Lighthouse report has a metric that doesn’t add up — fast paint, bad interactivity, or anything in between — that’s usually a sign of exactly this kind of issue: a specific, findable root cause rather than something you can fix by tweaking a config flag. We do this kind of audit as part of every Core Web Vitals engagement, and every fix ships with before-and-after numbers, not just a checklist.
Published by the Runkexpert Engineering Team. Last updated 2026-07-12.