I wanted a perfect mobile PageSpeed score for my side project, a free game backlog tracker. It started at 86. Within an hour, it was 77. By the end of the afternoon, it was 99. Here is the part of that story that taught me something.

The advice everyone gives

My report listed “render-blocking requests” with an estimated saving of 840 ms. The biggest blocker was the Google Fonts stylesheet. Every performance article gives the same answer: load it asynchronously.


<link rel="stylesheet" href="..." media="print"> 


I applied it, rebuilt, ran the test again. The score fell from 86 to 77. First Contentful Paint did not improve. Cumulative Layout Shift went from 0.001 to 0.131.


The fonts now arrived after the first paint, text rendered in a fallback font, then jumped when the real font loaded. I had not removed the cost; I had moved it from “wait” to “shift”, and the metric that punishes shifting is worth more points than I expected.

I stopped guessing and read the report properly

Instead of trying a third trick, I read what the tool listed under diagnostics, line by line, and checked each with curl.


1. Nothing was compressed. The report said text compression was missing. I did not believe it, because my reverse proxy config has encode gzip zstd. So I tested:

curl -sI -H "Accept-Encoding: gzip" https://example.com/ 

No Content-Encoding header. Not on the HTML, not on the 76 KB stylesheet.


In production, the proxy was not actually in front of the Node server. One line of Express middleware fixed it:

app.use(require('compression')()) 


2. A hidden @import. A second font request appeared in the waterfall that I had never added to the page. It came from a scoped style block inside a “Buy me a coffee” button component, where someone (me, months ago) had written @import url('https://fonts.googleapis.com/...'). The bundler had merged that into the main stylesheet, so my entire CSS was waiting on a third-party server. I replaced that font with a system stack.


3. The headline was waiting for its animation. My hero used a fade-in with opacity: 0 at the start, 0.8 seconds long, with a stagger delay of up to 0.85 s. The browser cannot count an invisible element as the largest paint, so the largest contentful paint was literally behind an animation. I shortened it to 0.35 s with no stagger and a nearly-visible starting state.

Self-hosting the fonts, the safe way

With the real problems fixed, I went back to fonts and did it properly:

  • Downloaded the woff2 files and inlined the @font-face rules in the HTML.
  • Preloaded only the Latin subsets of the two families used above the fold.
  • Left other scripts (Cyrillic, Greek, Vietnamese) as separate files with unicode-range, because the site has 12 languages and those only load when needed.

The numbers

BeforeAfter
Performance8699
First Contentful Paint3.0 s1.4 s
Largest Contentful Paint3.2 s2.0 s
Layout shift0.0010.023

Accessibility also went from 95 to 100, after I raised the contrast of the footer text.

What I would tell my past self

  1. Verify the basics with curl before trusting a config file. The most valuable fix was the one that needed no cleverness: compression was simply off.
  2. Search your build output for @import. Third-party requests hide in places you stopped looking.
  3. Be suspicious of generic advice. “Load fonts async” is correct for some sites and harmful for others. Measure after every single change, one at a time.
  4. Do not animate your biggest element in from invisible. It looks nice and costs you LCP.


The score is a proxy. What I actually care about is that the page now appears in about a second and a half on a mid-range phone on a slow connection, and that I can explain why.