Back to notes

From 44 to 83 on Lighthouse: fixing this portfolio's scroll jank

This portfolio had a classic problem: it stuttered while scrolling. Lighthouse mobile scored 44 for performance, with a Total Blocking Time of 1,890 ms — nearly two seconds of blocked main thread. This note documents the four causes found and what each fix bought, all measured as the median of three Lighthouse CI runs.

Cause 1: block hydration mid-scroll

Four heavy interactive sections (dashboard, reading modes, timeline, and case studies) mounted all at once, behind a single IntersectionObserver with an 1800px rootMargin. The result: one giant synchronous render fired while the user was still almost two screens away — right in the middle of a scroll gesture.

The fix: each section got its own observer with a 400px rootMargin, and the heavy mount is now scheduled through requestIdleCallback, letting the in-flight scroll frame finish before React commits.

Cause 2: WebGL burning the main thread

The hero background (a WebGL shader) called getBoundingClientRect() on every frame — a forced reflow 60 times per second — and its requestAnimationFrame loop never paused when the hero left the viewport. The fix: size reads via ResizeObserver (cached), loop paused through an IntersectionObserver, and a 30 fps cap — it is a subtle background, it does not need more.

Cause 3: the monitoring SDK was half the bundle

The biggest surprise came from measuring the bundle: the client error-monitoring SDK accounted for 45% of the initial chunk (415 KB → 228 KB without it). Session Replay alone cost ~50–60 KB plus continuous DOM recording on the main thread.

I removed Replay first (TBT −510 ms, measured) and then moved monitoring entirely to the server: runtime errors are still captured where it matters, and field performance was already covered by Web Vitals RUM. Worth recording what did not work: the SDK's tree-shaking flags are ignored by Turbopack, and lazy-importing duplicated the bundle instead of shrinking it. The only real lever was removing the dependency from the critical path.

Cause 4: the title animation held the LCP back

The hero heading uses a "scramble" effect that keeps the real text at color: transparent while animating. On emulated mobile, the page's largest element only settled when the animation ended — pushing simulated LCP to ~4.6s. The fix: skip the animation on small viewports and coarse pointers, the same policy already applied to WebGL.

Outcome

Metric Before After
Performance (mobile) 44 83
Total Blocking Time 1,890 ms ~80 ms
Observed LCP — 1.3 s

Two lessons stuck. First, measure with the median of multiple runs: a single Lighthouse run on CI swings up to 10 points, and decisions made on single runs almost made me discard optimizations that worked. Second, performance cost is rarely where it seems — nobody suspected error monitoring was the heaviest item on the page.