I run NXTO, a free game backlog tracker (Vue 3 + Express + SQLite on one small server). The landing page scored 86 on mobile PageSpeed and I wanted 100. Here is what I changed, in the order I tried it, including the change that made things worse.
Attempt 1: make Google Fonts async (86 โ 77)
The report said render-blocking requests would cost 840 ms, and the biggest one was the Google Fonts stylesheet. The textbook fix is media="print" onload="this.media='all'".
It made things worse: CLS went from 0.001 to 0.131 and FCP did not improve. The fonts arrived late, text re-flowed, and Lighthouse punished the shift. I reverted it.
Lesson: deferring a font stylesheet does not remove the font from the critical path, it just moves the layout shift to after paint.
Fix 1: self-host the fonts and preload the two that matter
I downloaded the woff2 files, inlined the @font-face rules in index.html, and preloaded only the Latin subsets of the two families used above the fold:
<link rel="preload" href="/fonts/inter-latin.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/fonts/space-grotesk-latin.woff2" as="font" type="font/woff2" crossorigin />
Other scripts (Cyrillic, Greek, Vietnamese) are separate files with unicode-range, so the browser only downloads them when a page needs them. The site has 12 languages, so this matters. One more family (a monospace font used in a few components) is gone from the landing page; it falls back to the system monospace.
Fix 2: the hero was hiding behind an animation
The landing headline used a fade-up animation: opacity: 0 to 1, 0.8 s long, with a stagger delay of up to 0.85 s per element. Lighthouse cannot report LCP for something that is invisible, so the headline was literally waiting for its animation. I shortened it to 0.35 s, removed the stagger, and start from opacity: .01 with a 12 px offset.
Fix 3: an @import hiding in a component
Round two of the report showed a second Google Fonts request: family=Cookie. It came from a scoped <style> block in a "Buy me a coffee" button, via @import url(...). Vite had bundled it into the main CSS, so my main stylesheet was blocked on a third-party request. I replaced that font with a system cursive stack.
Search your built CSS for @import and googleapis. It takes ten seconds.
Fix 4: no compression, at all
This was the big one. The audit said "text compression not applied", and curl -I -H "Accept-Encoding: gzip" confirmed it: neither the HTML nor the 76 KB CSS had a Content-Encoding header.
I had a Caddy config with encode gzip zstd, but in production Caddy was not in front of the app. The Express server was serving raw bytes. The fix was one line:
app.use(require('compression')())
Results (mobile, Moto G Power emulation, slow 4G)
| Before | After | |
|---|---|---|
| Performance | 86 | 99 |
| FCP | 3.0 s | 1.4 s |
| LCP | 3.2 s | 2.0 s |
| CLS | 0.001 | 0.023 |
| Accessibility | 95 | 100 |
Accessibility went from 95 to 100 after I raised the contrast of the gray footer text from gray-500/600 to gray-400.
What I would check first next time
-
curl -sI -H "Accept-Encoding: gzip" yoursiteand look forContent-Encoding. Do this before anything else. - Grep the built CSS for
@import. - Look for entrance animations on the LCP element.
- Self-host fonts before trying to defer them.
If you have a Vue SPA with a server-rendered shell, I would love to hear what else you found. The full list of changes is in my git history; ask and I will share the diff.
Top comments (0)