RUNKEXPERT.
technical-seo core-web-vitals performance case-study

How to Reduce Total Blocking Time (TBT): A Real 25-Second-to-Zero Case Study

R Runkexpert Engineering Team ·
Runkexpert case study graphic showing Total Blocking Time reduced from 25,260 milliseconds to 0 milliseconds

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:

  1. 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.
  2. 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:

CauseHow it blocks the main thread
Heavy third-party scriptsAnalytics, 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 embeds3D scenes, video players, interactive maps, and canvas/WebGL widgets frequently pull in multi-megabyte runtimes that parse and execute synchronously
Unminified or unsplit JS bundlesShipping 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   ✅

Stat card showing a Total Blocking Time of 25,260 milliseconds alongside a fast 0.7 second First Contentful Paint and Largest Contentful Paint, labeled "Main thread deadlocked"

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:

ChunkSize (uncompressed)
WebGL scene runtime2.04 MB
Physics engine1.99 MB
Font/text rendering173 KB
Framework runtime187 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:

MetricBeforeAfter
Total Blocking Time25,260 ms0 ms
Network requests for the heavy bundle on page loadImmediateZero — only fires on click
Cumulative Layout Shift0.0040
First Contentful Paint0.7 s0.7 s (unchanged — it was never the problem)

Stat card showing Total Blocking Time reduced to 0 milliseconds after the fix, labeled "Main thread free"

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:

  1. Run Lighthouse (Chrome DevTools → Lighthouse tab, or npx lighthouse <url>) and check the Total Blocking Time and “Minimize main-thread work” audits.
  2. 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.
  3. 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.
  4. 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

#FixWhen to use it
1Gate heavy interactive/third-party widgets behind explicit user intentAny embed — 3D, video, maps, chat — that isn’t essential to the page’s core content
2Code-split and dynamically import anything not needed for first paintLarge component trees, admin-only features, rarely-used modals
3Audit every lazy-loading directive if you’re on an islands frameworkAstro, Qwik, or any framework offering partial hydration
4Move genuinely heavy computation to a Web WorkerData processing, image manipulation, or parsing that must run on load
5Replace or trim bloated dependencies rather than deferring around themWhen 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:

  • requestIdleCallback is 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 in requestIdleCallback still 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.

Get a free technical audit →


Published by the Runkexpert Engineering Team. Last updated 2026-07-12.