Desktop 99, mobile 84: debugging a slow Lighthouse score
- Performance
- Lighthouse
- Web
My portfolio scored 99 on desktop and 84 on mobile. Same site, same code, the same deploy. I sat with that for a bit, because it's a strange result, and strange results usually mean you're about to learn something.
The gap was the clue
Lighthouse doesn't grade desktop and mobile the same way. Mobile runs with the brakes on: roughly a 4× slower CPU and a throttled, slow-4G connection. Desktop runs almost free. Once that landed, the gap stopped looking like two problems and started looking like one. If desktop is happy, the site itself is fine. The mobile score was just surfacing something that gets expensive when the device is slow, the kind of cost a fast laptop quietly hides from you.
The metrics agreed. Total Blocking Time was 0 ms and layout shift was basically nothing, so it wasn't janky scripts or things jumping around the page. All the damage lived in First Contentful Paint and Largest Contentful Paint, both stuck at 3.2 seconds. That last detail is the giveaway. When those two numbers are identical, it usually means the page sits blank and then everything pops in at once.
My hero was hiding from the browser
The report told me the Largest Contentful Paint element was the little two-letter logo in my navbar. Which is silly on its face, the logo is tiny. So I went digging, and the answer was right there in the hero. Every part of it, headline included, started fully transparent and faded in.
That's the trap. As far as paint timing goes, something at zero opacity hasn't been painted yet. So my big headline, the obvious candidate for the largest paint, didn't count until its fade finished. With nothing else on screen, Lighthouse picked the only text it could actually see, the logo. On my laptop the fade was done before I noticed, so it never mattered. On a throttled phone it dragged the first real paint out to those 3.2 seconds.
The fix was almost embarrassingly small. Keep the animation, just stop hiding things. I swapped the opacity fade for a plain transform:
/* Before: invisible until the animation runs */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
/* After: painted right away, it just settles into place */
@keyframes riseUp {
from { transform: translateY(18px); }
to { transform: translateY(0); }
}
Same little entrance, except the headline is on screen from the first frame instead of three seconds in. If there's one thing to take from all this, it's that fading in your most important above-the-fold content works against you. Paint it first. Animate the rest if you like.
Fonts were holding the door
Fonts were next. I pull a few families from Google Fonts, and that request was blocking the render. The browser was politely waiting on a third party before it would draw a single pixel.
Two changes. First, let the fonts load without blocking anything, and fall back to system fonts until they arrive:
<link rel="preload" as="style" href="…fonts…" />
<link rel="stylesheet" href="…fonts…" media="print" onload="this.media='all'" />
<noscript><link rel="stylesheet" href="…fonts…" /></noscript>
The media="print" bit is a small hack. The browser grabs the stylesheet at low priority, then the onload flips it to all. Text shows up immediately in a fallback font and quietly swaps once the real one lands.
Second, my site has two modes, an engineer side and a poet side, and they use different typefaces. I was shipping both sets to everyone on first load, which is wasteful when most people never flip the switch. Now the default mode's fonts load up front and the rest only arrive if someone actually toggles. Three families on first paint instead of five.
Clearing the rest of the path
The big rocks were gone. Two smaller things were still loitering on the critical path.
The command palette, that search-and-ask box behind a keyboard shortcut, was downloading on every page load even though it starts closed. So now it loads the first time someone opens it, and not a moment sooner.
The last blocker was my own CSS. It's small, under 9 KB, so instead of making the browser fetch it as a separate file, I paste the whole thing into the prerendered HTML at build time and drop the link. The page arrives already dressed, no extra trip.
One warning if you copy this. You cannot defer your own CSS the way you defer fonts. Fonts degrade gracefully because there's a fallback. Your stylesheet has none, so loading it late gives you a flash of unstyled, naked HTML. Inlining is the safe way to get the same speed.
The things I left alone
Not every suggestion is worth taking, and knowing what to ignore is half the job.
Lighthouse wanted me to cut unused JavaScript by splitting my writing pages out of the main bundle. Reasonable on paper. But the site is prerendered to static HTML for SEO, and my current setup doesn't split lazy routes cleanly during that prerender step. Get it wrong and crawlers start receiving blank pages. I'm not trading real SEO for a few kilobytes, so it stays put for now.
I also passed on a tool that surgically inlines only the above-the-fold CSS. When your whole stylesheet is under 9 KB, inlining all of it is simpler and just as fast. Fit the fix to the size of the problem.
What it came down to
None of this was clever, and I mean that kindly. It was mostly clearing junk out of the gap between asking for the page and seeing the first pixel. Don't hide what you want shown. Don't let fonts or CSS stand in the doorway. Don't ship code nobody asked for yet. A good laptop papers over all of it. A cheap phone on patchy wifi does not, and that phone is what a lot of people are actually holding.
So if your desktop number is great and your mobile number isn't, don't go hunting for two separate bugs. Find the one thing on the critical path that gets slow when the hardware does. More often than not it's hiding in plain sight. In my case, literally, at zero opacity.