This site has a Three.js portrait in the hero and WebGL halftone type on every page title. In local Lighthouse runs (desktop profile) Total Blocking Time sat between 200 and 500ms and the layout shifted by 0.15–0.25 on first load. After the changes below, TBT is around 60–80ms, CLS is close to zero and LCP is 0.9–1.4s. None of the effects were removed.
Find where the time goes
TBT adds up the part of every main-thread task over 50ms between first paint and interactive. Here, that time came from setup work: initialising Three.js, sampling every halftone title on the page (including ones a full scroll away), and canvases sized at the device’s full pixel ratio.
1. Ship Three.js only to the browser, after hydration
The portrait is imported with next/dynamic and ssr: false. Three.js is no longer in the main bundle and its setup runs after hydration rather than during it. The hero keeps its space with CSS, so nothing moves when the canvas arrives, and if WebGL fails it swaps in a plain image.
// Three.js only ships to the browser, after hydration.
const PortraitField = dynamic(() => import('./PortraitField'), { ssr: false })
2. Do expensive setup only near the viewport
Each halftone title reads its glyphs back from a canvas and blurs them, which takes real main-thread time. Every title used to do this on load. Now an IntersectionObserver with a 50% root margin gates both the build and the render loop. Titles far down the page, like the footer wordmark, wait until you scroll towards them, and nothing renders while it is off-screen or the tab is hidden.
const io = new IntersectionObserver(
([entry]) => {
near = entry.isIntersecting
if (near && dirty) rebuild()
},
{ rootMargin: '50% 0px' }
)
3. Cap the pixel ratio
A 3x phone screen means nine times the fragments of a 1x screen, and halftone dots don’t get noticeably sharper past 2x. The canvases cap the device pixel ratio at 2, and at 1.5 on devices with four cores or fewer, or with Data Saver turned on.
4. Fix the layout shift at the source
Most of the CLS came from the web fonts. The condensed display face is much narrower than its fallback, so headlines rewrapped when it loaded. On a first visit, while the preloader is up anyway, a small inline script now hides the page until the three fonts have loaded, capped at two seconds. Repeat visits and reduced-motion users skip the wait entirely.
5. Put budgets in CI
Numbers drift without a check. Every pull request runs Lighthouse CI with budgets: LCP under 2s and CLS under 0.05 fail the build, accessibility and SEO under 0.95 fail it too, and TBT over 200ms raises a warning (lab runs have no INP, so TBT stands in for it).
What mattered most
- Most of the cost was work done too early, not work that was too slow. Deferring it was enough.
- Gate WebGL on visibility: build near the viewport and stop rendering off-screen.
- Fix CLS where it starts (fonts), instead of reserving space around every heading.
- Measure with budgets in CI, so a regression shows up on the pull request that caused it.
Originally published on my site. I'm Muhammad Tanveer, a full-stack developer in Faisalabad, Pakistan: muhammad-tanveer-dev.vercel.app.
Top comments (0)