From 95 to 99 on Mobile Speed: What I Did to a Next.js Site

A Next.js site's mobile speed score went from 95 to 99. The before and after numbers, each change, why the first 14.6 KB matters, and what didn't improve.

Phát LâmPublished Updated 7 min readIntermediate
From 95 to 99 on Mobile Speed: What I Did to a Next.js Site
On this page
  1. The four metrics in plain words
  2. Before and after
  3. Investigation: how the LCP score is calculated
  4. What I did
  5. Why 14.6 KB matters
  6. What I couldn't improve further
  7. Common mistakes when tuning speed
  8. What to do next

My phatdev.net home page already scored 95 for mobile speed. Many people would stop there. I think going from 95 to 99 is the part worth doing, because to get there you have to understand where the score comes from, not just "make it faster". This post covers what I changed, the numbers before and after, and the things that didn't improve.

The four metrics in plain words

LCP is how long until the biggest thing on the first screen (usually an image or a large heading) appears. To a visitor, that's the moment "the page has loaded". Good is 2.5 seconds or less.

INP is how quickly the page responds after you click or type. Good is 200 milliseconds or less. It can only be measured with real users.

CLS is whether the page "jumps", for example an image loads and pushes the text down so you tap the wrong thing. Good is 0.1 or less. My site scored 0.

TBT is the total time the browser is stuck and can't respond after the page starts to appear. It's a lab metric standing in for INP, because the test tool has no real person to click. Good is under 200 milliseconds.

The three official Google metrics are LCP, INP and CLS, based on real-user data. The 0 to 100 score PageSpeed shows is something else: it runs a test on a simulated device and adds up the metrics with weights.

The limits of this post: I measured with Lighthouse 12.8.2 run from my own server, simulating a phone's network and CPU. It is not Google's PageSpeed Insights service and not real-user data, so results can differ by a few points from PSI.

Before and after

Same measuring method, the /vi page, mobile mode, on the live site:

Before After
Score 95 99 (all 3 runs 99)
LCP 2.8 s 1.81 to 1.92 s
TBT 100 to 110 ms 83 to 109 ms
CLS 0 0
Data downloaded about 436 KB about 194 KB

The /en page went from 95 or 96 to 99, and a project page reached 100. Across the main pages, 18 of 18 mobile runs scored 99 or higher, desktop 100, and Accessibility, Best Practices and SEO were all 100.

Two honest notes. The "before" size was measured on the old version's internal port, while "after" was measured on the live site. And TBT barely moved, within the noise between runs. The score rose mostly thanks to LCP and fewer bytes to download.

A semicircular gauge made of coloured segments with the needle pointing to the green end, three short balanced bars underneath
The performance score is a weighted sum of several metrics, not a single measurement.

Investigation: how the LCP score is calculated

The test tool simulates a mid-range phone on a slow network, then estimates LCP by adding up the load time of everything needed before LCP can appear. That means every extra byte and every extra network round trip in front of LCP drags the score down. Reading the measurement data, I found the culprits:

  1. The avatar image weighed about 150 KB but displayed at 72 px.
  2. Two surplus font files: the latin-ext set, and the Vietnamese font also being preloaded on the English page.
  3. An animation library (framer-motion, about 37 KB) used only for fade-in effects.
  4. next/link (about 9 KB) in places where a plain <a> tag was enough.
  5. The home page HTML was about 19 KB after gzip, more than the server may send in its first burst. I explain this below.

What I did

The avatar now goes through Next.js's image optimizer, producing 96, 128 and 256 px versions of just 3 to 7 KB instead of 150 KB. I used a plain <img> tag pointing at the optimizer instead of the next/image component, to avoid shipping extra JavaScript for one small picture.

For fonts, I reordered the declarations so the Vietnamese page doesn't download the latin-ext set, and only preload the Vietnamese font on /vi. Each font file is a network call, and text has to wait for it.

I rewrote the fade-in-on-scroll effect with CSS plus a single IntersectionObserver, so the home page no longer loads framer-motion. On the project page there was a nice detail: the page-open effect has to start at opacity 0.01, not 0, because a fully transparent element stops the test tool from finding the LCP.

Four screenshots went from PNG at about 0.8 to 1.1 MB down to WebP at about 90 KB, with width and height set and lazy loading. I kept PNG only for the social sharing image. Last came less work to paint: content-visibility: auto for blocks below the first screen, replacing a filter: blur effect on the loading screen with a gradient, turning over a hundred static style={{...}} blocks into CSS classes, and putting the blocks under the hero into Suspense.

The final change had the biggest effect on simulated LCP: letting nginx compress the HTML with brotli. The reason is in the next part.

A transparent box holding small teal packets that fit exactly, a few rose packets waiting outside, and a press compressing a large block into a smaller one on the way in
Smaller compressed HTML fits the first TCP window and saves one round trip.

Why 14.6 KB matters

When a browser opens a connection, the server isn't allowed to send everything at once. It starts with a small "window": per RFC 6928, 10 packets of about 1,460 bytes each, roughly 14.6 KB. If the HTML is bigger than that, the rest has to wait one more round trip across the network. On slow mobile networks a round trip can cost hundreds of milliseconds.

My home page HTML was 19 KB after gzip, so it definitely went over. Next.js compresses with gzip by default. I turned that off with compress: false and let nginx (brotli already enabled) do it. Brotli compresses text better than gzip: the /vi HTML went from 19 KB to 12.2 KB, under 14.6 KB, so it fits in the first burst and saves a round trip.

The catch: something in front must really compress, such as nginx with brotli or a CDN. Without that you've just turned compression off entirely.

// next.config.mjs
const nextConfig = {
  compress: false, // let the reverse proxy (nginx + brotli) compress the HTML
};
export default nextConfig;

What I couldn't improve further

About 115 ms of simulated time remains in the startup of React and Next.js themselves, and I couldn't cut it with the site's own code. TBT swings between 30 and 110 ms depending on how busy the machine was during the run, so now and then a run comes out at 98.

Common mistakes when tuning speed

The most common one is trusting a single run. Measure at least three times and look at the spread. Second, optimizing something that loads after LCP: shrinking bytes that arrive later usually doesn't change the score. Some people also optimize for the test tool and forget real users; real data and INP are the final yardstick. Finally, two traps I stepped into myself: turning off app-side compression without a compressing layer in front, and leaving opacity at 0 on the LCP element.

What to do next

If you want to try this on your own site, start with one thing: open a measurement, find the heaviest item sitting before LCP, and shrink it. Measure three times before, change one thing, measure three times after. Don't change five things at once, or you won't know which one worked.

The indexing and structured-data side of the same site is in Making Sure Google Sees Both Languages of Your Next.js Site.

Core Web VitalsNext.jsTechnical SEOcore-web-vitalslcppagespeednextjsperformancebrotli

References

  1. web.dev: Web Vitals
  2. web.dev: Total Blocking Time
  3. Chrome for Developers: Lighthouse performance scoring
  4. RFC 6928: Increasing TCP's Initial Window
  5. Next.js docs: compress option