Eliminating Total Blocking Time in Next.js
TBT is the Lighthouse metric that correlates most strongly with perceived interactivity. Most Next.js applications have fixable TBT regressions hidden…
A systematic approach to diagnosing and eliminating Total Blocking Time in Next.js static applications — covering chunk analysis, third-party script deferral, React hydration strategies, and the interplay between TBT and Long Animation Frames.
Contents
Total Blocking Time measures the total duration of time between First Contentful Paint and Time to Interactive during which the main thread is blocked for more than 50 milliseconds. Each task that exceeds 50ms contributes its excess duration to TBT. A page with a 200ms long task contributes 150ms to TBT.
TBT is the Lighthouse metric that most directly corresponds to what users experience as "the page froze." It is also the metric most commonly tanked by JavaScript frameworks — because JavaScript is parsed and executed on the main thread, and large bundles create exactly the kind of long tasks that TBT measures.
Finding Your TBT Culprits
Before optimizing, diagnose. Open Chrome DevTools → Performance → record a fresh page load. Look at the flame chart for long tasks (the red triangles above tasks in the timeline). Each red triangle is a long task — a task that exceeded 50ms. The largest ones are your TBT culprits.
Common culprits in Next.js applications:
- Main chunk parse + execution — the JavaScript that must run before the page is interactive
- Third-party scripts — analytics, A/B testing tools, chat widgets loaded synchronously
- React hydration — the process of attaching event handlers to server-rendered HTML
- Large dynamic imports that load eagerly — code-split chunks that are still loaded before interactivity
Step 1 — Measure the Baseline
Run Lighthouse from the command line against your production build, not your dev server:
npm run build && npx serve dist &
npx lighthouse http://localhost:3000 --output json --output-path lh-baseline.json
# TBT is at lh-baseline.json → audits.total-blocking-time.numericValue
Record this number. Every optimization step should be measured against it. Performance work without measurement produces confidence, not results.
Step 2 — Analyze Your Bundle
ANALYZE=true npm run build
The bundle analyzer opens an interactive treemap. You are looking for:
- Duplicate packages (two versions of lodash, two versions of react-query)
- Packages that should be client-only appearing in the main chunk
- Large dependencies that are only used in rarely-visited routes
In a typical Next.js application with a rich admin interface, the most common finding is that admin-only code (data tables, rich text editors, chart libraries) is included in the main chunk because of a transitive import somewhere in the component tree.
Step 3 — Lazy-Load Everything Below the Fold
In Next.js, dynamic imports with { ssr: false } create code-split chunks that load on the client after the initial HTML is parsed. Combined with an IntersectionObserver trigger, you can defer the chunk download until the section approaches the viewport:
// LazySection.tsx — defers both download and render until near-viewport
import { useEffect, useRef, useState } from 'react'
interface Props {
children: React.ReactNode
minHeight?: string
rootMargin?: string
}
export function LazySection({ children, minHeight = '400px', rootMargin = '200px' }: Props) {
const ref = useRef<HTMLDivElement>(null)
const [visible, setVisible] = useState(false)
useEffect(() => {
const el = ref.current
if (!el) return
const observer = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) { setVisible(true); observer.disconnect() } },
{ rootMargin },
)
observer.observe(el)
return () => observer.disconnect()
}, [rootMargin])
return (
<div ref={ref} style={{ minHeight: visible ? undefined : minHeight }}>
{visible ? children : null}
</div>
)
}
The minHeight prop prevents layout shift when the component mounts — the placeholder occupies the same space the content will occupy.
Step 4 — Defer Third-Party Scripts
Google Analytics, Hotjar, Intercom, and similar tools routinely add 200–800ms to TBT. They are not optional in many organizations, but their execution timing is negotiable. Use Next.js's Script component with the correct strategy:
import Script from 'next/script'
// afterInteractive — loads after hydration completes (TBT-safe)
<Script
src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXX"
strategy="afterInteractive"
/>
// lazyOnload — loads during idle time (most deferred, use for non-critical)
<Script
src="https://cdn.example.com/chat-widget.js"
strategy="lazyOnload"
/>
Never use the default beforeInteractive strategy unless the script is truly required before the page renders — it is rare that this is actually true, despite what third-party vendors claim.
Step 5 — Audit React Hydration Cost
Hydration is the process by which React attaches event handlers to server-rendered HTML. In Next.js static export, the entire page hydrates client-side. Hydration cost scales with component tree size — more components mean more reconciliation work on the main thread.
The optimization levers are:
- Flatten component trees — deeply nested components each add reconciliation overhead
- Avoid
keyprop instability — unstable keys force React to unmount and remount subtrees - Memo expensive subtrees —
React.memoprevents re-render when props are stable - Minimize initial DOM — the fewer nodes that need hydration, the less work React does
Profile hydration specifically by looking for the "Hydration" entry in the Performance timeline. It appears as a blue task starting immediately after the main chunk execution. Its duration is your baseline hydration cost.
Step 6 — Reduce Main Chunk Parse Time
JavaScript parse time is proportional to bundle size. The fastest code is code that is never sent to the browser. Concrete reductions:
- Replace moment.js with date-fns (tree-shakeable, 70% smaller)
- Replace lodash with individual lodash functions or native equivalents
- Replace heavy chart libraries with lighter alternatives for simple charts
- Audit whether your Three.js / R3F usage requires the full library or a subset
For Three.js specifically, use the three/tsl module system in Three.js r155+ to import only the specific loaders, geometries, and materials you use. The default three.js export is 600KB minified. A typical application needs 20% of it.
What to Expect
In a typical Next.js application with Three.js, GSAP, and Framer Motion, applying all six steps produces TBT reduction in the 60–80% range — moving from a failing score to a passing one. The largest single win is usually lazy-loading below-fold sections, which moves Three.js and other heavy libraries out of the main thread initialization path entirely.
A Lighthouse Performance score of 44 can realistically reach 65–75 through these techniques alone, without any architectural changes. Performance scores above 80 typically require more radical changes — eliminating heavyweight libraries, switching to CSS animations, or adopting edge rendering. But 65–75 puts you solidly in the "passing" range for most real-world contexts, and gets there through targeted optimization rather than architectural risk.