Knowledge Mark G
Hire Me

PageSpeed 100 on Mobile With Next.js: What Actually Worked (and What Didn't)

A Next.js site on Windows IIS went from the low 80s to PageSpeed 100 on mobile. The Cloudflare rules, the code changes, the real numbers, and the fixes that did nothing.

Kausar RazaBy Published on · 9 min read · 0 views
Share:

Web Development

PageSpeed Insights 100 in all four categories on mobile for a Next.js site, with LCP 1.2 s, TBT 10 ms and CLS 0
  • Performance
  • Web Development
  • Core Web Vitals
  • Cloudflare

Need help with this?

I build and fix business websites - fast, mobile-first and built to bring enquiries.

Get in touch

On 12 November 2026 I ran this site's home page through PageSpeed Insights and got 100 on mobile in all four categories: Performance, Accessibility, Best Practices and SEO. A month earlier the same page scored in the low 80s in local Lighthouse runs and the mobile LCP was somewhere around five seconds.

PageSpeed Insights mobile report for knowledgemarkg.com: Performance, Accessibility, Best Practices and SEO all 100, FCP 1.2 s, LCP 1.2 s, TBT 10 ms, CLS 0
Mobile, 12 Nov 2026, Lighthouse 13.6.0 (emulated Moto G Power, slow 4G).

I want to be upfront about what that screenshot is and isn't. It's a lab test, one run, on one day. The "Discover what your real users are experiencing" panel says No Data, because the site doesn't have enough Chrome traffic yet for Google to report field data. AdSense is switched off while I wait for approval, and ads will cost points when they come back. So this isn't a claim that the site is perfect. It's a record of what changed between "low 80s" and "100", including the things I tried that made no difference, because those took up most of the time.

The stack, so you know whether any of this applies to you: a Next.js 16 frontend (App Router, standalone output) and an ASP.NET Core API, both on a Windows shared host running Plesk and IIS, with Cloudflare in front. The database is SQL Server on separate shared hosting, so every query crosses the internet. None of it is exotic, and none of it is a fast Vercel-style edge platform.

The server: Next.js standalone on IIS, and why it barely matters

The frontend runs as a Node process behind IIS. I wrote up the setup in deploying Next.js on IIS and Plesk, and it's not a fast box. When I fetch a page that Cloudflare doesn't have, the first byte arrives in roughly 250 to 300 ms, and that's with Next.js answering from its own ISR cache rather than rendering anything.

The point of the next section is that visitors almost never wait for that server. The single biggest change in this whole story wasn't in my code at all.

Cloudflare: cache the HTML, not just the files

Out of the box, Cloudflare caches images, CSS and JavaScript, and sends every HTML request straight to your server. Headers show it as cf-cache-status: DYNAMIC. On 17 October I added a Cache Rule called "Cache HTML pages" that makes HTML eligible for caching on the main host, leaves out /admin, /api and account pages, and takes the TTL from the s-maxage that Next.js already sends. The full rule and how to check it is in cf-cache-status DYNAMIC: why Cloudflare isn't caching your HTML.

This is the home page today:

$ for i in 1 2 3; do curl -s -o /dev/null -H "Accept-Encoding: br" \
    -w "%{time_starttransfer} %{size_download}\n" https://knowledgemarkg.com/; done
0.043855 60290
0.038666 60289
0.042771 60290

About 40 ms to the first byte and 60 KB over the wire. The raw HTML is 527 KB, which sounds awful until you see what Brotli does to it. Those are curl runs from my own PC, not a benchmark, but the gap between 40 ms and 300 ms is far too large to be noise.

Three other Cloudflare settings matter here:

  • A compression rule that prefers Brotli (then zstd, then gzip). I compared them on this site in Brotli vs zstd vs gzip.
  • 404 and 410 set to "No store" in the same cache rule. A 404 fetched a few minutes before a post went live was served for about ten minutes afterwards, which is how I found out it was needed (does Cloudflare cache 404s).
  • Purge Everything after every deploy. Cached HTML points at the previous build's JavaScript file names. Skip the purge and you get unstyled pages, as covered in ChunkLoadError after a deploy.

Pages that quietly opted out of caching

Once the rule was live, most pages came back HIT, but /tools, /services and /portfolio stayed BYPASS, with a time to first byte of around half a second against 0.04 s for their neighbours. All three read searchParams on the server for a filter or a tab. In the App Router that alone makes the page dynamic, so Next.js sends Cache-Control: private, no-store and Cloudflare obeys it.

// Before: reading the query string in a page makes Next.js render it per request
export default async function ToolsPage({ searchParams }) {
  const { q } = await searchParams;          // -> Cache-Control: private, no-store
  ...
}

// After: the page is static, and the filter reads the URL in the browser
export default function ToolsPage() {
  return <ToolsBrowser tools={tools} />;      // "use client" inside, useSearchParams()
}

Moving the query-string logic into a client component with useSearchParams() made those pages static again. I left /blog dynamic on purpose, since its search and paging really do change per request. If a page you expected to be cached isn't, run curl -I on it and look at Cache-Control before you touch Cloudflare.

Third-party scripts: load them after the visitor does something

This is where Total Blocking Time went from "fine" to 10 ms. Google Analytics' gtag script is about 176 KB, and Microsoft Clarity adds more. I'd been loading GA with Next's lazyOnload strategy, which on a throttled phone still landed inside the Lighthouse trace.

Now neither loads until the visitor scrolls, taps, clicks or presses a key. If they do nothing, a fallback starts them 15 seconds after the page goes idle (it used to be 4 seconds, which was still inside the trace). Visitors in the EEA, UK and Switzerland see a consent banner first, and nothing loads until they accept.

// Simplified. Load GA and Clarity on the first real interaction, or 15 s after the page goes idle.
const events = ["pointerdown", "keydown", "scroll", "touchstart"];
const start = () => { events.forEach(e => removeEventListener(e, start)); loadTags(); };
events.forEach(e => addEventListener(e, start, { once: true, passive: true }));
addEventListener("load", () => requestIdleCallback(() => setTimeout(start, 15000)));

The trade-off is real: someone who arrives and leaves within 15 seconds without touching the page isn't counted. For a site this size I'd rather have the speed. I also lazy-loaded a lead-capture popup (about 23 KB) so it only downloads after 30 seconds, and turned AdSense off until the account is approved. The approach to finding what to cut is in reduce unused JavaScript in Next.js.

CSS: one stylesheet per section of the site

Tailwind generates CSS for every class it finds in the source it scans. With the whole site in one build, every page shipped the admin panel's styles, the tool pages' styles and everything else: 266 KB raw, 34 KB with Brotli, all render-blocking.

On 28 October I split the app into route groups, each with its own root layout, and a small prebuild script that gives each group a stylesheet generated only from the files that group imports. Pages now get 134 to 199 KB raw, 21 to 28 KB compressed. The home page's single stylesheet is 23 KB over the wire. In local runs that took about 0.15 s off First Contentful Paint.

The obvious alternative was Next's experimental inlineCss. I tried it first. It put the stylesheet into the HTML twice (once as a <style> tag and again in the React Server Components payload), the home page went from 520 KB to 1.38 MB, and FCP got worse.

The LCP fixes nobody warns you about

Three small things, each worth more than any big refactor I tried.

A fade-in animation was hiding the LCP element. The hero headline faded in from opacity: 0. Chrome doesn't count an invisible element as a Largest Contentful Paint candidate, so the LCP went to the small logo in the header and Lighthouse reported 1 to 3 seconds of "render delay" on mobile. I changed the entrance to a slide with no fade, and the headline counts from its first paint.

preload() from a server component didn't preload anything. React DOM's preload() called in a server component put the hint into the RSC payload, not the HTML head, so the browser found it too late. A plain tag fixed it:

{/* Example. react-dom's preload() from a server component only ended up in the RSC payload.
    The browser needs a real tag in the HTML to start the download early. */}
<link rel="preload" as="image" href="/images/hero-dark.webp"
      media="(prefers-color-scheme: dark)" fetchPriority="high" />

In the same change I stopped preloading two fonts (a monospace font and a handwriting font) that the first screen doesn't use. That was about 100 KB fighting the important requests for bandwidth. Lighthouse's "resource load delay" for the LCP image dropped from 270 to 475 ms to 17 to 45 ms.

Next.js doesn't resize AVIF sources. I'd uploaded hero art as AVIF to keep it small. next/image passed it through untouched, so a phone downloaded the same 44 to 69 KB file as a desktop. Switching the masters to 1100 px WebP let the optimiser resize them, and phones now get 22 to 32 KB (still served as AVIF to browsers that accept it). Use WebP or PNG as the source, not AVIF.

The home page hero today is drawn in CSS (an animated laptop and orbit using only transform and opacity), so the LCP element there should be text (PSI reports LCP at the same 1.2 s as FCP). The image fixes still matter on the inner pages.

Smaller wins

Polyfills. Next.js ships a polyfill bundle even when every browser you support already has the features. My browserslist is Chrome/Edge/Firefox 111 and Safari 16.4, so I aliased the module to an empty file and Lighthouse's "Legacy JavaScript" audit passes:

// next.config.ts - every browser in our browserslist already has these features
turbopack: {
  resolveAlias: {
    "next/dist/build/polyfills/polyfill-module": "./src/lib/no-polyfills.js",
  },
},

content-visibility on the lower sections. Everything below the first screen of the home page has this, so the browser skips laying it out until it scrolls near:

.cv-auto {
  content-visibility: auto;
  contain-intrinsic-size: auto 900px;
}

One side effect: full-page screenshots of the home page change height from run to run, depending on which sections happened to render. If you diff screenshots, force those sections visible first.

What didn't help

I'm listing these because each one looked like a sure thing, and I measured them before keeping anything.

  • Making the hero image load eagerly with <picture>. No change. The image wasn't the bottleneck.
  • Cutting the home page down to just the hero, as an experiment. Local LCP went from 5.0 s to 4.8 s. That suggested the problem was the JavaScript and CSS in front of the page, not the amount of content.
  • Next's inlineCss, covered above. Bigger page, slower paint.
  • NitroPack. Its optimised HTML scored well, and it never hydrated: buttons, menus and the search did nothing. For a React app that's a non-starter.

How much to trust a PageSpeed 100

PageSpeed Insights desktop report for knowledgemarkg.com: FCP 0.4 s, LCP 0.4 s, TBT 60 ms, CLS 0, Speed Index 0.6 s
Desktop, same run: FCP 0.4 s, LCP 0.4 s, TBT 60 ms, CLS 0.

Less than the green circle suggests. Run the same URL three times and you'll get three different scores. Google ranks on field data from real Chrome users, not this lab number, and I don't have field data yet. I added a small web-vitals collector at the end of October so I'll at least see what real visitors get, and I'll be honest about it when the first useful sample comes in.

Also, my local Lighthouse runs never looked like this. On my machine, with OneDrive syncing and a browser full of tabs, the same build always scores lower, with a time to first byte near a second, because it's hitting a local server with no Cloudflare in front. Measure on the live URL, and compare before and after on the same machine back to back.

If I had to pick the order again: cache the HTML at the CDN, get analytics and other third-party scripts out of the first few seconds, ship only the CSS a page uses, then chase LCP details. The first two did more than everything else put together.

If your Next.js site is stuck in the 60s or 70s on mobile and you'd like someone to go through it the same way, that's work I take on.

Frequently asked questions

Is a PageSpeed Insights score of 100 on mobile realistic?+
Yes for a content site, but it is a lab result from one run. Scores move a few points between runs, and they drop when ads or heavy third-party scripts are added. On this site the mobile 100 came with ads switched off and analytics loading only after the first interaction.
What made the biggest difference to this site's PageSpeed score?+
Caching the HTML at Cloudflare with a Cache Rule (time to first byte went from roughly 250-300 ms to about 40 ms) and delaying Google Analytics and Microsoft Clarity until the visitor interacts. Splitting the CSS per route group and fixing LCP details came after.
Does PageSpeed Insights 100 mean good Core Web Vitals in Google Search?+
Not by itself. Google uses field data from real Chrome users (the Chrome UX Report), not the Lighthouse lab score. A site with too little traffic shows No Data for field metrics, as this one does, so the lab score is only a sign that the page is built well.
Why is my Next.js page not cached by Cloudflare?+
Two common reasons. Cloudflare does not cache HTML by default, so you need a Cache Rule. And in the App Router, reading searchParams, cookies or headers on the server makes a page dynamic, so Next.js sends Cache-Control private, no-store and Cloudflare shows BYPASS.
Should I use inlineCss in Next.js to improve FCP?+
Test it first. On this site, with a large Tailwind stylesheet, inlineCss put the CSS into the HTML twice and the home page grew from 520 KB to 1.38 MB, and First Contentful Paint got worse. Reducing the CSS each page ships worked better.
Why does Lighthouse show a long LCP render delay?+
Often because the LCP element is hidden when it first paints, for example a headline that fades in from opacity 0, or because the browser discovers the LCP image late. Animating with transform instead of opacity and adding a plain preload link in the HTML fixed both on this site.
Will Google Analytics slow down my PageSpeed score?+
It can. The gtag script is about 176 KB and adds main-thread work on a throttled phone. Loading it after the first scroll, tap or key press, with a timed fallback, keeps it out of the Lighthouse trace. Visitors who leave without interacting may not be counted.

Join the conversation

No comments yet — be the first to share what you think.

Leave a comment

Never published.

Optional.

Keep reading

Related articles

View all