My own site felt slow and I had never measured it. When I finally did, the number was 23 on mobile and 57 on desktop.
I had a list of suspects written down months earlier: the 3D career timeline, five webfonts, an animated background blob. Reasonable guesses. All three were wrong, and the thing that actually cost the most was a prop I did not know made a network request.
The server was never the problem
First useful number: the root document responded in 30 ms. I had half assumed a single backend box in Mumbai was punishing anyone loading the site from Berlin. It was not. Every second of the 23 was JavaScript running in the browser.
That is worth saying on its own. My mental model of "slow" was distance, and the measurement said work.
The 1.5 MB
Here is the line:
<Environment preset="city" />
One prop, inside the 3D timeline. What I thought it did: pick a lighting
preset. What it actually does: resolve that preset name to an HDR image on the
pmndrs/drei-assets repository and fetch it from
raw.githubusercontent.com.
Measured on production:
| File | potsdamer_platz_1k.hdr |
| Size | 1,506 KB, served without compression |
| Origin | a third party I do not control |
| When | every page load |
| Next largest asset on the site | 275 KB |
One and a half megabytes, five times heavier than anything else, to add reflections to three torus rings that already had an ambient light, a point light and a spot light on them.
The fix was to stop using a preset and describe the lighting instead:
<Environment frames={1} resolution={256}>
<Lightformer form="rect" intensity={1.2} scale={[12, 12, 1]} … />
<Lightformer form="rect" intensity={1.6} color={activeColor} … />
…
</Environment>
With children instead of a preset, the environment map is rendered to an
off-screen cube camera on the GPU. No network at all. frames={1} bakes it
once rather than every frame.
It also does something the photograph could not: the rim lights carry the currently selected era's colour, so the reflections follow the palette. The replacement is better, not merely cheaper. That is unusual and I do not want to over-generalise from it — but it is a good reminder that a convenience API is often carrying an assumption you would not have made yourself.
Lazy was not lazy
The timeline was already behind dynamic(), which I had filed mentally as
"handled". It is not the same thing. dynamic() defers the download until the
component renders — and this component rendered the instant WebGL was
detected, which is immediately.
So 275 KB of Three.js and 1,742 ms of script execution sat on the critical path for a section roughly 900 px down the page, which most visitors never scroll to.
Gating the mount on an IntersectionObserver, with a viewport of head start,
moved all of it off the load path. The lesson is narrow and useful: lazy
import and lazy mount are different decisions, and only one of them was
made.
The hero was waiting for Mumbai
The whole home page was a client component that fetched the CV after hydration. So the largest element on the page — the line under my name — could not paint until React had hydrated and a round trip had completed.
By this point the two fixes above had already landed, so this is not the starting number — it is what was left. LCP 6.0 s, of which 92% was "render delay": the content was not late because it was big, it was late because it did not exist yet.
The same bug was also most of the layout shift. The hero's bullet list is not in the first paint, so everything below it moved when the data landed — 0.183 of a 0.208 CLS, one element.
Fetching on the server and seeding the client cache with the result fixed both. The interactive half of the page did not change; it just starts with the answer instead of a spinner.
The cheapest fix was one word
gtag.js was loading with Next's afterInteractive strategy, which I had read
as "after the page is ready". It is not — it means during the load window. At
168 KB it was the largest script on the page, and it put two long tasks at 5.4 s
and 5.5 s, competing with hydration.
Changing one word to lazyOnload took mobile first contentful paint from
2.9 s to 1.2 s. Nothing about recording a pageview needs to happen before the
page is usable. It still fires; it fires a second later.
Two animations that were costing content
Two more, both the same shape — decoration that had quietly become a prerequisite for content.
The hero faded in on scroll. The wrapper is opacity: 0 until an
IntersectionObserver fires after hydration. On the first screen there is no
scroll yet, so it was not revealing anything. It was holding the page blank and
calling it an animation.
The typewriter was shrinking the page. It rendered the full name, collapsed it to a single character when typing began, then regrew it. On a phone, where a long name wraps to two lines when complete and one while typing, every section below moved twice.
The fix keeps the effect: render the untyped tail too, transparently, so the element is its final size from the very first paint. Nothing moves.
Where it landed
| Before | After | |
|---|---|---|
| Performance (mobile) | 23 | 72 |
| Performance (desktop) | 57 | 98 |
| Accessibility | 92 | 100 |
| Best Practices | 96 | 100 |
| Total blocking time | 7,290 ms | 180 ms |
| Main-thread work | 24.0 s | 3.8 s |
| Cumulative layout shift | 0.238 | 0.006 |
The part I am least comfortable with
While measuring performance, the accessibility audit found a contrast failure of 1.09:1 — white text on a white button. The "Ask AI" launcher label was invisible. Not subtle, not borderline. Gone.
I had written a contrast test suite for this exact class of bug, and it was green.
The cause is a design mistake I would now call obvious. The admin panel can set the primary colour to any hex. Nothing set the matching text colour. So the moment I picked a near-white primary, the stylesheet's white label stayed white and sat on top of it. A colour control that can produce an unreadable button is a broken control, and no amount of care while using it helps.
The fix is to derive the pairing instead of remembering it: compute black or
white from the chosen colour's luminance. My first attempt used the site's
softer near-black ink, and my own test failed it at mid-tones — #808080 came
out at 4.36:1, under the 4.5 line. With pure black and white the worst case is
provable: the two candidates cross at a luminance of about 0.179, and the ratio
there is 4.58:1. There is now no colour that can be chosen which produces text
below AA, and a test sweeps all 256 greys to keep it that way.
Why my suite missed it is the more useful half. It skips any element whose background it cannot reconstruct — gradients, painted sibling overlays — because an earlier version invented 53 false failures by flattening a translucent panel over itself. Skipping was the right call. But 21 elements on the home page fall into that bucket, and the worst bug on the site was hiding in it.
A test that is green because it looked, and a test that is green because it looked away, are indistinguishable from the outside. Mine now prints every skip on every run, which is the minimum honest behaviour.
The number that is still wrong
Mobile sits at 72 because Lighthouse reports LCP at 6.5 s. I did not believe
it, so I measured the same page in a real browser with 4× CPU throttling and a
simulated 4G connection, watching PerformanceObserver directly.
One LCP candidate, at 1,868 ms, equal to first contentful paint.
The 6.5 s is Lighthouse's simulation model attributing hydration cost to the paint. The remaining real work is that my page component hydrates as a single 940-line unit. That is a genuine cost and it is worth fixing — but it is worth knowing which of the two numbers is describing the user's experience, and it is not the one in the report.
What I would tell myself six months ago
Three of these six things were invisible to reading the code. The HDR is a
string in a prop. afterInteractive reads like it means "later". dynamic()
reads like it means "lazy".
I had a list of suspects for months and never spent the twenty minutes to run the tool. The three I would have fixed were all innocent, and I would have finished the afternoon believing I had done performance work.