Get in touch

9io.ai / Blog

Caching server-rendered Next.js HTML at the edge, by device type

We cached a Next.js storefront’s HTML on Cloudflare, split by device type. Cached pages come back in about 70 ms; mobile PageSpeed rose from 65 to 87.

Key takeaways

  • Edge-cached HTML took a Next.js storefront’s mobile lab LCP from 14.1 s to 3.8 s.
  • Turn on Cache by device type when phones and desktops get different HTML. Every Cloudflare plan has it.
  • Put the bypass rules for signed-in visitors, cart, checkout and account after the rule that caches HTML.
  • On Cloudflare, s-maxage switches off stale-while-revalidate. Send max-age with stale-while-revalidate instead.
  • A lab LCP is one emulated phone. Check CrUX phone p75 before claiming a win for real visitors.

We cached the server-rendered HTML of a Next.js storefront at Cloudflare’s edge, with device type in the cache key. A cached page now comes back in about 70 ms. The mobile PageSpeed (Lighthouse) performance score went from 65 to 87, and lab LCP fell from 14.1 s to 3.8 s.

The store is a fashion commerce platform we build and run. Every page renders on the server, and phones get different HTML from desktops, so a single cached copy per URL would show half the visitors the wrong layout. Splitting the cache by device type solves that, and it changes how you bypass, purge and test.

This post covers the setup on Cloudflare, from the cache key and bypass rules to purging, stale-while-revalidate and checking the result from a terminal. The three numbers above are our measurements. The rest is general guidance, with a link to Cloudflare’s or Next.js’s documentation for each claim.

What edge caching changed on the storefront

Mobile measure Before After
PageSpeed performance score 65 87
Lab LCP 14.1 s 3.8 s
HTML response time from Cloudflare’s cache not cached about 70 ms

Before the change, every page request went through to the Next.js server and waited for a full render. After it, Cloudflare answers repeat requests for the same page and device type from a data centre near the visitor.

Caching HTML moves LCP because the server’s response sits at the front of it. web.dev splits LCP into four consecutive parts, and time to first byte is the first of them (Optimize LCP). Nothing on the page can paint until the HTML arrives. Caching shortens that wait and leaves the images, fonts and JavaScript after it as they were.

For scale, Lighthouse colours scores from 50 to 89 orange and from 90 to 100 green (performance scoring), so 87 is still orange. web.dev calls an LCP of 2.5 s or less good and anything above 4.0 s poor, judged at the 75th percentile of real page loads (LCP). Those thresholds are meant for field data, so holding one lab number against them is rough. Read that way, the lab LCP has left the poor range and has not yet reached good.

Why Cloudflare skips HTML by default

Two layers keep HTML out of a shared cache until you change them.

Cloudflare decides what to cache by file extension, and HTML and JSON are not on its default list (default cache behaviour). A product URL has no extension, so it goes to the origin every time and the response carries cf-cache-status: DYNAMIC.

Next.js adds the second layer. Dynamically rendered pages get Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate, in the App Router and the Pages Router alike (self-hosting guide). That header tells every shared cache to keep out. Fully static pages get s-maxage=31536000, and ISR pages get s-maxage plus stale-while-revalidate (CDN caching guide).

There are two ways through.

Override at the edge Change the origin’s headers
What you change A Cache Rule marks pages eligible for cache, with an Edge TTL that ignores the origin’s Cache-Control The app sends a cacheable Cache-Control on anonymous pages
Code changes None Per route (in the Pages Router, res.setHeader in getServerSideProps)
What browsers get The origin’s private, no-store header, so browsers don’t keep the page What the app sends, subject to the zone’s Browser Cache TTL
Stale-while-revalidate Off unless you rewrite the header with a Cache Response Rule Works with max-age; s-maxage turns it off (see below)
Main risk The rule caches whatever it matches, no-store responses included A route that forgets the header stays uncached

Cloudflare’s docs confirm that an Edge TTL set to ignore the origin’s headers overrides both no-store and private (investigating uncached responses). Cache Response Rules, added in March 2026, can set or remove individual Cache-Control directives before Cloudflare stores a response (changelog).

Overriding at the edge is the quicker route for an App Router site because nothing in the app changes. It also puts all of the safety on your rules, which the next three sections cover.

Adding device type to the cache key

In the Cache Rule that makes HTML eligible for cache, open the cache key settings and turn on Cache by device type. It is available on every plan, Free included (cache keys). Custom keys built from headers, cookies or user features are Enterprise-only, so on the other plans this switch is how you split phones from desktops.

Cloudflare sorts each request into one of three buckets by matching the User-Agent against regular expressions (cache by device type). Phones are mobile. iPads, and Android devices whose user agent lacks the word “mobile”, are tablet. Everything else is desktop. That gives up to three cached copies of each URL, even if your app has only two layouts.

The same page says Cloudflare sends a CF-Device-Type request header to the origin, with the bucket as its value. That page documents Automatic Platform Optimization, and the cache key docs say the Cache Rules option uses the same mechanism. Check that the header reaches your Next.js server before you build on it.

The HTML in each bucket has to match the bucket. If the app picks its layout by parsing the User-Agent itself, it can disagree with Cloudflare. The userAgent helper in Next.js, for example, reports types such as console, smarttv and wearable, and undefined for desktops (userAgent). When the two disagree, whatever layout the first visitor in a bucket triggers is the one every later visitor in that bucket gets. The simplest fix is to let Cloudflare’s header decide whenever it is present.

import { headers } from "next/headers";

type Device = "mobile" | "tablet" | "desktop";

export async function deviceFromRequest(): Promise<Device> {
  const h = await headers();
  const bucket = h.get("cf-device-type");
  if (bucket === "mobile" || bucket === "tablet" || bucket === "desktop") {
    return bucket;
  }
  // No Cloudflare in front (local dev, tests): a rough fallback.
  return /Mobi/i.test(h.get("user-agent") ?? "") ? "mobile" : "desktop";
}

Render tablets with whichever layout you prefer, as long as it is always the same one.

A Vary: User-Agent header won’t do this job. Cloudflare ignores it unless you configure Vary in a Cache Rule, which became possible in July 2026, and even then it normalises only a few headers such as Accept-Language (Vary). A User-Agent would go into the key almost as sent, giving one copy per browser version, OS version and device model.

Pages and fragments that must never be cached

Anything that differs per visitor stays out of the cache. That covers signed-in pages, the account area, wishlists, cart, checkout, order history and the APIs behind them.

Cache Rules stack, and when two matching rules set the same thing, the last matching rule wins (order and priority). Put the broad rule that caches HTML first and the bypass rules after it. For a store on www.example.com, the rules could look like this:

1. Cache storefront HTML
   when  http.host eq "www.example.com"
         and not starts_with(http.request.uri.path, "/_next/")
   then  Eligible for cache; Edge TTL of your choice; Cache by device type on

2. Bypass personal pages
   when  starts_with(http.request.uri.path, "/account")
         or starts_with(http.request.uri.path, "/cart")
         or starts_with(http.request.uri.path, "/checkout")
         or starts_with(http.request.uri.path, "/api/")
   then  Bypass cache

3. Bypass signed-in visitors
   when  http.cookie contains "session="
   then  Bypass cache

The first rule leaves /_next/ alone because Next.js already marks its hashed static files as immutable for a year, and Cloudflare caches .js and .css by extension anyway. The matches operator for regular expressions needs a Business or Enterprise plan (operators), so these expressions use the starts_with() function (functions) and the contains operator. Cloudflare only caches GET and HEAD requests, so form submissions never come from the cache (investigating uncached responses).

Cookies matter in both directions. A cookie on the request that marks a signed-in visitor should bypass the cache, which is what rule 3 does. A Set-Cookie header on the response changes what Cloudflare stores (Set-Cookie and cache).

  • With Eligible for cache and no Edge TTL override, a response that sets a cookie is never stored, and every request is a MISS.
  • With an Edge TTL override, Cloudflare strips the Set-Cookie header and caches the page.

The second case causes quiet bugs. If the app sets an anonymous cart cookie or an experiment cookie on page responses, cached pages stop setting it, and code that expects the cookie finds nothing. Set those cookies from an API call the browser makes after the page loads, on a path the cache bypasses.

The same goes for fragments inside pages that are otherwise the same for everyone. The greeting, the cart count, saved-item hearts and recently viewed products should load in the browser after the cached page arrives, from an endpoint that is never cached. If prices or stock differ by country, the cache key needs the country as well, and geography in a custom key is an Enterprise option (cache keys). On the other plans, a separate URL per region does the same job.

Once Cloudflare answers from cache, proxy.js (previously called middleware) stops running for those requests, because they never reach the server. The Next.js docs say proxy.js should run before the CDN cache, and that if it sits behind the CDN, routes that depend on its decisions should bypass caching (CDN caching guide). Redirects, geo rules and A/B assignment in proxy.js all need checking.

Next.js details that matter behind a shared cache

App Router pages answer two kinds of request at the same URL. A normal visit gets HTML. A client-side navigation sends an rsc request header and gets a React Server Components payload. Next.js lists those headers in Vary, and because many CDNs ignore Vary, it also adds an _rsc search parameter whose value is a hash of the relevant headers (CDN caching guide). Cloudflare’s default cache key includes the whole query string, so HTML and RSC responses land in separate entries. Leave “Ignore query string” off for these routes, since the Next.js docs say _rsc must stay in the cache key.

The query string works against you elsewhere. Every distinct query string is a separate cache entry, and Cloudflare names marketing tags such as utm_* as a common cause of misses (investigating uncached responses). Excluding particular parameters from the key is an Enterprise option, so on the other plans expect campaign landing pages to hit the cache less often.

Deploys are the next problem. Next.js serves JavaScript and CSS from /_next/static/ with content-hashed file names and Cache-Control: public, max-age=31536000, immutable (CDN caching guide). A cached page refers to the chunk names of the build that rendered it. After a deploy, that page asks for the old chunks, and if the server no longer has them you get what Next.js calls version skew. Its docs list missing assets, Server Function mismatches and failed navigations as the symptoms (self-hosting guide). Purge the HTML as part of every deploy and keep the previous build’s static files available for a while. Setting deploymentId lets Next.js detect a mismatch and fall back to a full page load.

On-demand revalidation does not reach the CDN either. revalidatePath() and revalidateTag() clear the Next.js server cache, while the CDN keeps serving its copy until it expires. The Next.js docs suggest calling the CDN’s purge API at the same time, for both the HTML and the RSC variants (CDN caching guide).

Purging on deploy and on product changes

Since April 2025 every Cloudflare plan can purge by URL, hostname, tag and prefix, and purge everything (changelog).

Device type complicates purge by URL. A single-file purge from the dashboard can’t say which device bucket it means, except on Enterprise (single-file purge). The API can. You send the CF-Device-Type header with each URL, and a header missing from the purge request counts as an empty value in the cache key (purging cache key resources). Clearing one product page therefore takes three entries.

{
  "files": [
    { "url": "https://www.example.com/products/example-item", "headers": { "CF-Device-Type": "mobile" } },
    { "url": "https://www.example.com/products/example-item", "headers": { "CF-Device-Type": "tablet" } },
    { "url": "https://www.example.com/products/example-item", "headers": { "CF-Device-Type": "desktop" } }
  ]
}

Purge by tag and purge by prefix are not affected by custom cache keys (Cache Rules), which makes them a better fit. A prefix purge also clears every query-string variant under the path (purge by prefix), which takes care of the _rsc entries. For tags, the origin sends a Cache-Tag response header. Cloudflare strips it before the response reaches visitors, and one response can carry roughly 1,000 tags in 16 KB (purge by tag). Tag each page with the products it shows. A category page might send this:

Cache-Tag: category-knitwear,product-4821,product-4822,product-4830

When product 4821 changes, purging the tag product-4821 clears the product’s own page and every listing that shows it, in all three device buckets.

For a deploy, purge everything or purge the storefront’s hostname, and let the cache refill on demand. On a miss, Cloudflare holds a cache lock so that each data centre sends only one request at a time for a given page to the origin (default cache behaviour). Tiered Cache, available on every plan, makes data centres near visitors ask an upper-tier data centre before they ask the origin (Tiered Cache).

Watch the purge rate limits if a catalogue sync touches thousands of products. Tag, prefix, hostname and purge-everything requests are limited to 5 per minute on Free, 5 per second on Pro, 10 per second on Business and 50 per second on Enterprise, with up to 100 tags or prefixes per request (purge limits). A purge call that returns 200 doesn’t prove anything was evicted, so request the page afterwards and check that cf-cache-status is no longer HIT.

Stale-while-revalidate on Cloudflare needs max-age

Since 26 February 2026, Cloudflare revalidates stale content asynchronously. When a cached page expires, the first visitor gets the stale copy straight away with cf-cache-status: UPDATING, and Cloudflare fetches a fresh one in the background (changelog). Before that, the first visitor after expiry waited for the origin. The changelog says the change was live on Free, Pro and Business zones and still rolling out to Enterprise zones that quarter.

It only applies when the origin sends stale-while-revalidate, and only without s-maxage. While Cloudflare follows the origin’s Cache-Control headers, it treats s-maxage as implying proxy-revalidate, which forbids serving stale content, so those requests come back EXPIRED instead of UPDATING (revalidation). Cloudflare’s Cache-Control page carries a note saying not to use s-maxage with stale-while-revalidate (Cache-Control).

This catches Next.js sites because Next.js reaches for s-maxage. ISR pages send s-maxage={revalidate}, stale-while-revalidate={expire - revalidate} (CDN caching guide), and the getServerSideProps docs show public, s-maxage=10, stale-while-revalidate=59 as the way to cache SSR responses (getServerSideProps). Behind Cloudflare, neither gets background revalidation.

Cloudflare’s suggestion is max-age with stale-while-revalidate from the origin, plus an Edge TTL in a Cache Rule when Cloudflare should keep the page for a different length of time (revalidation).

Cache-Control: public, max-age=60, stale-while-revalidate=600

max-age also reaches the browser, and purging Cloudflare’s cache does nothing to a page a browser already holds, so keep it short for HTML. Check the zone’s Browser Cache TTL as well. It defaults to 4 hours, and Cloudflare replaces any lower max-age with it unless the setting is Respect Existing Headers (browser cache TTL). A Browser TTL in the Cache Rule, or a Cache Response Rule, can give browsers a different lifetime from Cloudflare’s (changelog).

Two more settings affect stale serving. The “Serve stale content while revalidating” option in a Cache Rule can switch it off for matching pages (revalidation), which helps where a price must be right the moment it changes. And with Always Online enabled, Cloudflare ignores stale-while-revalidate and stale-if-error (Cache-Control).

Checking the cache with cf-cache-status

Every response that passes through Cloudflare carries cf-cache-status. These are the values you will see on HTML (cache responses):

cf-cache-status What it tells you about a page
HIT Served from Cloudflare’s cache
MISS Eligible for cache but not yet in this data centre’s cache, so the origin served it
EXPIRED The cached copy had expired and the origin served a fresh one
UPDATING An expired copy was served while Cloudflare revalidates in the background
REVALIDATED The origin confirmed with a conditional request that the cached copy was unchanged
BYPASS Eligible, but the origin’s response was not cacheable, for example Cache-Control: no-store
DYNAMIC Not eligible for cache, so no lookup happened; a Bypass cache rule also shows up here

Cloudflare also sets an Age header, in seconds, on HIT, STALE and UPDATING responses and leaves it off misses (cache responses). A few curl requests answer most questions. Send each one twice, because the first request to a data centre is often a MISS.

url="https://www.example.com/products/example-item"
phone="Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1"

# As a phone, twice: expect MISS, then HIT with an age header
for i in 1 2; do
  curl -s -o /dev/null -D - -A "$phone" "$url" | grep -iE '^(cf-cache-status|age|cache-control):'
done

# curl's own user agent lands in the desktop bucket, a separate entry
curl -s -o /dev/null -D - "$url" | grep -i '^cf-cache-status:'

# A session cookie must never produce a HIT
curl -s -o /dev/null -D - -A "$phone" -H 'Cookie: session=test' "$url" | grep -i '^cf-cache-status:'

# Time to first byte, in seconds
curl -s -o /dev/null -w '%{time_starttransfer}\n' -A "$phone" "$url"

Check the body as well as the headers. Fetch the page as a phone and as a desktop and look for markup that only one layout contains. If both bodies have the same layout, the origin and the cache disagree about devices. A response header from the origin that names the layout it rendered turns this into a one-line check. For anything the headers don’t explain, Cloudflare Trace shows which cache key settings applied to a request (cache keys).

How we measured, and why lab and field LCP differ

The score and LCP figures come from mobile runs of PageSpeed Insights before and after the change. The 70 ms is the response time we saw for HTML served from Cloudflare’s cache, with cf-cache-status: HIT.

We only have the before and after figures. There is no run-to-run spread, no split of LCP into its parts, no cache hit ratio and no CrUX field data for the same period. These are lab results, and we make no claim about what real visitors experienced.

PageSpeed Insights runs Lighthouse from a data centre in North America, Europe or Asia (about PageSpeed Insights). The mobile run uses simulated throttling with 150 ms of latency, 1.6 Mbps down, 750 Kbps up and a 4x CPU slowdown (Lighthouse throttling). CrUX field data comes from real Chrome users. It is reported at the 75th percentile over a rolling 28 days, split into phone, desktop and tablet, and updated daily (CrUX API).

web.dev lists the usual reasons lab and field LCP drift apart, such as the lab’s cold browser cache and the browser ending the LCP search once a real user scrolls or taps (lab and field data). Edge caching adds another. A lab run that reaches a Cloudflare data centre without the page cached measures your origin, and real traffic mixes hits with misses, signed-in visitors who bypass the cache and campaign URLs that start cold. The 70 ms applies to hits only.

Next time we would record these alongside the headline numbers:

  • The median of five lab runs, which Lighthouse’s docs describe as twice as stable as a single run (variability), with the LCP subparts for each.
  • The HTML cache hit ratio from Cloudflare’s cache analytics.
  • CrUX phone p75 LCP before the change and again 28 days after, once the window has fully turned over.

A checklist before you cache HTML

  • Every anonymous visitor in a device bucket gets the same HTML, and per-user parts load from an endpoint that is never cached.
  • Cache by device type is on, and the origin picks its layout from CF-Device-Type.
  • Bypass rules for account, cart, checkout, API paths and the session cookie sit after the rule that caches HTML.
  • No cookie the app depends on is set by a cached page, and proxy.js logic that varies per request only runs on bypassed routes.
  • The query string stays in the cache key, so _rsc variants stay apart.
  • Every deploy purges the HTML, and the previous build’s /_next/static/ files stay available.
  • Product, price and stock changes purge by tag or prefix, or by URL once per device type.
  • Origin headers that want background revalidation use max-age with stale-while-revalidate and leave out s-maxage.
  • curl as a phone and as a desktop shows MISS then HIT with a different layout in each body, and a session cookie never gets a HIT.
  • Lab numbers are the median of several runs, and someone checks CrUX phone p75 a month later.

Frequently asked questions

Does Cloudflare cache HTML by default?

No. Cloudflare caches by file extension and HTML is not on its default list. You need a Cache Rule that marks the pages as eligible for cache.

How do I cache different HTML for mobile and desktop on Cloudflare?

Turn on Cache by device type in the cache key settings of a Cache Rule. It is available on every plan and splits the cache into mobile, tablet and desktop based on the User-Agent.

Why doesn’t stale-while-revalidate work with s-maxage on Cloudflare?

Cloudflare treats s-maxage as implying proxy-revalidate, which forbids serving stale content. Send max-age with stale-while-revalidate, and set Cloudflare’s own lifetime with an Edge TTL in a Cache Rule.

Which pages should never be served from an edge cache?

Anything that differs per visitor. That means signed-in pages, the account area, cart, checkout, the APIs behind them and responses that set cookies. Bypass them with Cache Rules placed after the rule that caches HTML.

How do I purge both the mobile and desktop copies of a page on Cloudflare?

Purge by URL through the API once for each CF-Device-Type value, or purge by tag or prefix, which are not affected by custom cache keys.

Why is my Lighthouse LCP so different from my CrUX LCP?

Lighthouse loads the page once on an emulated mid-range phone over a throttled connection. CrUX reports the 75th percentile of real Chrome users over a rolling 28 days.

Work with us

Building something like this?

9io is a small team of senior engineers with a fractional CTO, and we work by the hour. Send us a note about your product. The reply comes from the person who'd do the work.