DEV Community

Mejzun
Mejzun

Posted on

PageSpeed 77 to 99 on a Vue site: the boring fixes that worked (and the clever one that made it worse)

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 />
Enter fullscreen mode Exit fullscreen mode

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')())
Enter fullscreen mode Exit fullscreen mode

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

  1. curl -sI -H "Accept-Encoding: gzip" yoursite and look for Content-Encoding. Do this before anything else.
  2. Grep the built CSS for @import.
  3. Look for entrance animations on the LCP element.
  4. 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)