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.