Anthony Wentzel

Seven rounds to a fast mobile PageSpeed score

Our Next.js site scored 70 on mobile PageSpeed Insights. Getting to the 90s took seven rounds of small fixes to improve LCP, each found by measuring.

From 2026 · 3 min read · Published August 9, 2026

The Pineapples site was built quickly with v0, and it looked great. It also scored 70 on mobile PageSpeed, with a Largest Contentful Paint of 8.1 seconds. In July 2026 we spent a focused stretch on it. Here's what actually moved the number and helped us improve LCP.

Round 1: find the LCP element before you improve LCP

The first surprise: the LCP element was a decorative pineapple watermark behind the hero, rendered at 8% opacity. Nobody would call it content, but the browser did.

Three changes got it painting with the first render:

  1. Scope fonts to where they're used. The root layout imported a font family used only on our pitch decks, preloading three weights (about 121KB) on every page. Now the decks load it, and the rest of the site uses a single non-preloaded weight.
  2. Stop preloading fonts that aren't the LCP. Two display fonts (about 75KB across five files) were preloaded but set to display: swap. Preloading them just crowded the critical window.
  3. Inline the watermark. An 800px, 65KB PNG became a 480px, 12KB WebP (indistinguishable at 8% opacity), passed from the server component as a data URI so it ships in the HTML instead of waiting on a request.

Lighthouse mobile: 70 to 92.

Rounds 2 and 3: less JavaScript before paint

On slow reference devices the headline became the LCP candidate, and a late font swap re-painted it. We trimmed that font to the one weight the site uses and preloaded just that file.

We also split the seven below-the-fold sections into a lazily loaded chunk with next/dynamic, keeping server rendering on so the HTML and SEO didn't change. Initial JavaScript on the home page dropped from 351KB to 260KB gzipped.

Rounds 4 to 6: stay out of the way

  • Removed the hero entrance animation. A static-first hero paints sooner, and motion during load competes with it.
  • Used content-visibility to skip layout for offscreen sections, and inlined critical CSS.
  • Deferred Google Tag Manager until after load, and fixed contrast, focus, and tap targets along the way (accessibility went from 88 to 100).

Round 7: when the score won't hold still

Three fresh PageSpeed runs on the same deploy scored 95, 91, and 76. The variance was the clue.

On a busy main thread, our lazy-hydration helper used requestAnimationFrame and requestIdleCallback heuristics that could fire before first paint, pulling the below-fold chunks into the exact window we were trying to protect. Link prefetches for above-the-fold links were also firing before paint.

The fix was to wait for the browser to actually report first paint:

components/shared/LazyHydrate.tsx
// Gate 1: the browser must have actually painted content.
const painted = performance
  .getEntriesByType("paint")
  .some((e) => e.name === "first-contentful-paint")
if (painted) {
  afterLoad()
} else {
  po = new PerformanceObserver((list) => {
    if (list.getEntries().some((e) => e.name === "first-contentful-paint")) {
      po?.disconnect()
      afterLoad() // then the load event, then a settle delay
    }
  })
  po.observe({ type: "paint", buffered: true }) 
}
 
// Hard fallback: never leave the tree un-hydrated.
const hardTimer = setTimeout(finish, 6000)

Until the gate opens, the component renders an empty dangerouslySetInnerHTML wrapper, which leaves the server-rendered HTML in place without hydrating it or downloading its chunk.

Plus prefetch={false} on the above-the-fold links. Navigation still fetches on tap.

What I'd do first next time

Find out what the LCP element actually is before optimizing anything. Ours was a decoration, and fixing it was worth more than everything after it combined.


Related: Zero of 129 pages indexed: a Next.js canonical URL bug · Build a monitor, not a dashboard: the Cloudflare Zaraz false negative