Tag: Web Performance

  • Cache-Control Headers in 2026: Caching Rules That Make Repeat Visits Instant

    Cache-Control Headers in 2026: Caching Rules That Make Repeat Visits Instant

    Flat vector illustration of a browser window with a small cache shelf of files, where an orange-red return arrow serves a repeat visit instantly from the browser cache instead of travelling to the origin server

    The message I get most often after a deploy goes something like this: “I changed the button color. Why do I still see the old one?” One person on the team sees the new color, another sees the old one, and a hard refresh fixes it for them but not for their customers. Usually nothing is wrong with the code. The stylesheet is called style.css, the server sends it with a long max-age, and every browser that visited recently is doing exactly what it was told: reuse the copy it already has.

    That is the most common caching bug I see, and it comes from the same root cause every time. Cache-Control headers are a set of instructions, and most sites send them by accident: a hosting default here, a plugin setting there, a framework default nobody looked at. When the instructions are wrong, you get stale pages after a deploy, or you get the opposite problem: a site that downloads the same 300 KB bundle on every single visit because nothing is allowed to be reused.

    In this guide I will walk through how I set Cache-Control headers on client projects: the three layers of cache a request passes through, the three rules that cover almost every file on a normal site, how stale-while-revalidate works behind a CDN, and the exact snippets I use for Next.js, Nginx, Apache or LiteSpeed .htaccess, and WordPress. I will finish with how I check headers with curl -I and DevTools, because the headers you think you send and the headers you actually send are often different.

    Why Cache-Control headers matter more than another plugin

    Every repeat visit is a chance to skip the network. When the browser already holds a fresh copy of your CSS, JavaScript, fonts and images, the page can start rendering as soon as the HTML arrives. When it does not, the browser has to ask the server for each file again, wait for the answer, and only then continue. On a fast office connection you barely notice. On a phone on a train, it is the difference between a page that feels instant and one that feels broken.

    Good Cache-Control headers also protect your server. A file served from a CDN edge or the browser’s own cache never touches your origin, so your hosting plan, your PHP workers and your database get to rest. On WordPress sites I often get a bigger speed win from fixing headers than from swapping themes, and it costs nothing. If you want the server side of that story, I wrote about it in my guide to WordPress performance and TTFB.

    One more reason to be explicit: if a response has no Cache-Control header at all, it is not automatically “not cached”. HTTP allows heuristic caching, where a cache guesses a lifetime from the Last-Modified date. MDN notes that the spec suggests roughly 10 percent of the time since the file was last modified. So a stylesheet that was last changed a year ago could be reused for weeks without you ever asking for that. The safest habit is simple: every response your site sends should carry a deliberate Cache-Control header.

    The three cache layers a request travels through

    When I explain caching to clients, I draw three boxes: the browser, the CDN edge and the origin server. A request starts at the left and only travels as far right as it needs to.

    The browser cache is a private cache. It belongs to one person on one device, so it can safely store things that are personal to them. It is also the fastest layer by far, because a fresh hit there does not touch the network at all.

    The CDN edge is a shared cache. Cloudflare, Vercel’s CDN, Fastly, Akamai, a reverse proxy like Varnish or an Nginx cache: they all store one copy and hand it to many visitors. That makes them great for public files and public pages, and dangerous for anything personal. If a logged-in dashboard ends up in a shared cache, the next visitor can get someone else’s data.

    The origin is your actual server: Node, PHP, a WordPress install, a static file host. Every request that reaches it costs CPU time and, usually, more latency, because the origin is often far away from the visitor.

    Infographic of the three cache layers a request passes through: the browser cache answers fastest, the CDN edge answers next, and the origin server is the longest trip
    Infographic 1: Three cache layers – browser cache (fastest, private), CDN edge (shared), origin server (slowest trip). A request only travels as far as it needs to.

    Cache-Control is how you tell each of these layers what it may do. Some directives apply to every cache, some only to shared caches (s-maxage, proxy-revalidate), and some only make sense for the browser (immutable, private). Once you see the three boxes, the directives stop looking like random words.

    The Cache-Control directives you actually need

    The full list on MDN is long, but on real projects I reach for about eight response directives. Here is how I think about each one.

    • max-age=N: the response stays fresh for N seconds, counted from when the origin generated it. While fresh, any cache may reuse it without asking.
    • s-maxage=N: the same idea, but only for shared caches like a CDN. Browsers ignore it. It overrides max-age for those shared caches.
    • no-cache: caches may store the response, but must check with the server before every reuse. It does not mean “do not cache”.
    • no-store: no cache, private or shared, should store the response at all.
    • private: only the browser may store it. CDNs and proxies must not.
    • public: shared caches may store it even in cases where they normally would not, such as responses to requests with an Authorization header. You rarely need it otherwise.
    • immutable: tells the browser the file will never change while it is fresh, so it should not even revalidate on a reload.
    • stale-while-revalidate=N and stale-if-error=N: extensions from RFC 5861 that allow a cache to serve a slightly stale copy while it refreshes in the background, or when the origin is failing.

    The single most misunderstood pair is no-cache and no-store. MDN puts it bluntly: no-cache allows caches to store a response but requires them to revalidate it before reuse. If what you really mean is “never keep a copy”, that is no-store. I have seen login pages sent with no-cache because someone thought it blocked storage, and marketing pages sent with no-store because someone wanted them “always fresh”. Both are backwards.

    You will also see kitchen-sink headers like private, no-cache, no-store, max-age=0, must-revalidate copied from old answers. Because the most restrictive directive wins, that whole line effectively means no-store. Write what you mean instead.

    Rule 1: hashed assets get a year and immutable

    Infographic of three Cache-Control rules: hashed assets cached long-lived and immutable, HTML stored but revalidated on every visit, and private data never stored
    Infographic 2: Three rules – hashed assets: public, max-age=31536000, immutable. HTML: no-cache with ETag revalidation. Private data: no-store (or private).

    The first rule covers JavaScript bundles, CSS files, fonts and images that your build tool fingerprints. When a file name contains a hash of its contents, like app.3f9a1c.js, the content behind that URL can never change. If you edit the code, the build produces a new hash and therefore a new URL. That makes it safe to cache the file for as long as browsers allow:

    Cache-Control: public, max-age=31536000, immutable

    31536000 seconds is one year. The immutable directive tells supporting browsers not to send a revalidation request even when the user hits reload. According to MDN, Chrome took a different route and stopped revalidating subresources on normal reloads instead, so the practical result is similar across browsers. The public part is optional for most sites, but this exact public, max-age=31536000 value is so common that it sits in the HTTP/3 QPACK static header table.

    The classic mistake: a long max-age on style.css

    Here is the bug from the opening story, in header form. The HTML references a stylesheet without any version in its name:

    <link rel="stylesheet" href="/css/style.css">

    And the server sends this for every CSS file:

    Cache-Control: max-age=2592000

    That is 30 days. Every visitor who loaded the page before your deploy keeps using the old style.css for up to a month, and there is nothing you can do from the server to recall it. MDN is clear on this: there are no cache directives for clearing already-stored responses, and Clear-Site-Data: cache only clears the browser cache of a visitor who actually reaches you, not CDNs in between.

    The fix is not a shorter max-age. The fix is to change the URL whenever the content changes, and then cache aggressively:

    <!-- Built by Vite, webpack, Next.js and others -->
    <link rel="stylesheet" href="/assets/style.8c41d2.css">
    
    <!-- WordPress style: version in the query string -->
    <link rel="stylesheet" href="/wp-content/themes/mytheme/style.css?ver=2.4.1">

    Once every static file has a hash or version in its URL, you can safely give it the one-year header. If you cannot add hashes yet, give unversioned files no-cache with a validator instead, so browsers check before reusing them. That is slower than a hashed file, but it is never wrong.

    Rule 2: HTML gets no-cache with ETag revalidation

    HTML is different, because its URL can never change. /pricing has to stay /pricing. If you give HTML a long max-age, you are back to the stale-page problem, and this time hashed assets will not save you, because the old HTML still points at the old hashed files.

    For most public HTML I use:

    Cache-Control: no-cache
    ETag: "5f2b-1a9c"
    Last-Modified: Wed, 07 Oct 2026 09:12:00 GMT

    The browser stores the page but asks before reusing it. It sends a conditional request with the validators it saved:

    GET /pricing HTTP/1.1
    If-None-Match: "5f2b-1a9c"
    If-Modified-Since: Wed, 07 Oct 2026 09:12:00 GMT

    If nothing changed, the server answers 304 Not Modified with no body, which is tiny. If the page did change, it sends a normal 200 with the new HTML, and that new HTML points to the new hashed CSS and JS. When both validators are present, If-None-Match wins, so a good ETag is the one that matters most. Most web servers and frameworks generate ETags for static files automatically; for dynamic pages you may need to check that your stack actually sends one, otherwise every revalidation becomes a full download.

    This pairing, hashed assets cached for a year plus HTML that always revalidates, is the cache-busting pattern MDN recommends, and it is the backbone of every site I build.

    A quick word on bfcache

    There is one place where no-cache does not revalidate: the back and forward buttons. Browsers can restore a page from the back/forward cache (bfcache), which is a memory snapshot of the whole page, not an HTTP cache entry. That restore is instant and does not check with the server. It is usually exactly what you want. If a page shows data that changes minute to minute, listen for the pageshow event and refresh that data when event.persisted is true.

    Rule 3: private data gets no-store (or private)

    Anything personal to one user needs special care: account pages, order history, checkout, dashboards, API responses that include a user’s details. Two directives matter here.

    # Personalized, but fine to keep in this user's own browser for a moment
    Cache-Control: private, no-cache
    
    # Sensitive: never store anywhere
    Cache-Control: no-store

    private keeps responses out of shared caches while still letting the browser reuse or revalidate them. MDN recommends adding private to responses after login and for cookie-based sessions, because forgetting it is how personal data leaks through a shared cache. no-store goes further and tells every cache not to keep a copy at all. I use it for banking-style screens, password reset pages and anything with tokens or personal documents.

    Do not spread no-store across the whole site, though. The web.dev bfcache guide explains that pages served with Cache-Control: no-store have historically been excluded from the back/forward cache, which makes back navigation slower for everyone (Chrome is working on changing this in a privacy-preserving way, but I would not rely on it yet). For pages that just need to be up to date and are not sensitive, no-cache is the better tool.

    A related trap is the Vary header. Vary: Accept-Encoding is normal and harmless. Vary: User-Agent splits the cache into thousands of variants and kills your hit rate. And if you are tempted to use Vary: Cookie to separate users, MDN and Vercel both point you to private instead.

    Behind a CDN: s-maxage and stale-while-revalidate

    So far HTML always goes back to the origin. That is fine for many sites, but for content-heavy pages that are the same for every visitor, like blog posts, docs or product listings, you can let the CDN hold a copy for a few minutes and serve it to everyone. The browser still revalidates, but the CDN answers instead of your server.

    Infographic of a cached response aging over time: fresh and served instantly, then a stale-while-revalidate window where the stale copy is served while a refresh happens in the background, then expired and waiting on the server
    Infographic 3: Freshness timeline – fresh (served instantly), stale-while-revalidate window (stale copy served while the cache refreshes in the background), expired (visitor waits for the origin).

    A cached response moves through three phases. While it is fresh, the cache serves it instantly. Once it goes stale, stale-while-revalidate gives a grace window where the cache still serves the old copy immediately and fetches a new one in the background, so no visitor waits for the origin. After that window, the response is expired and the next visitor waits for a full trip to the server.

    On Vercel, and for Next.js ISR pages, the documented pattern is:

    Cache-Control: s-maxage=600, stale-while-revalidate=60

    That reads as: the CDN keeps the page fresh for 10 minutes, then for one more minute it may serve the stale copy while refreshing it in the background. Browsers ignore s-maxage, and Vercel’s docs say its CDN consumes s-maxage and stale-while-revalidate and strips them from the response the browser sees when Cache-Control is the only cache header.

    The Cloudflare gotcha with s-maxage

    This is the part I had to double check while writing, because CDNs read the same header differently. Cloudflare’s documentation says not to combine s-maxage with stale-while-revalidate: under RFC 9111, s-maxage carries the semantics of proxy-revalidate, so a shared cache must not serve stale content without revalidating first, and Cloudflare will report EXPIRED instead of serving stale. Their suggested workarounds are to send max-age with stale-while-revalidate and set a separate Edge Cache TTL in Cache Rules, or to use a CDN-specific header.

    Also remember that Cloudflare does not cache HTML or JSON by default at all. It caches by file extension (CSS, JS, images, fonts and so on), and it skips responses with private, no-store, no-cache, max-age=0 or a Set-Cookie header. If you want HTML at the edge, you need a Cache Rule plus sensible headers.

    CDN-Cache-Control: one header per audience

    RFC 9213 standardized targeted cache control, which solves exactly this problem. CDN-Cache-Control uses the same directives as Cache-Control, but only CDNs that support it read it, and when they do, they ignore the normal Cache-Control header for their own decisions:

    Cache-Control: no-cache
    CDN-Cache-Control: max-age=600, stale-while-revalidate=60

    Here the browser always revalidates, while the CDN holds the page for 10 minutes with a one-minute background refresh window. Cloudflare and Vercel both document support for CDN-Cache-Control, plus their own vendor headers, Cloudflare-CDN-Cache-Control and Vercel-CDN-Cache-Control, which take priority and are not forwarded downstream. Fastly’s docs center on its own Surrogate-Control header and Akamai has its own settings, so check your provider’s documentation before relying on the standard header there.

    One warning that comes with any edge caching of HTML: a long CDN lifetime is only safe if you can purge it. Next.js documents that revalidatePath() and revalidateTag() refresh the Next.js server cache but do not purge an external CDN, so pair them with your CDN’s purge API, or keep the edge lifetime short.

    Framework and server examples

    Here are the snippets I actually paste into projects. Adjust paths to match where your build puts its files.

    Next.js

    Next.js already does the hard part for hashed files. Its docs state that it sends public, max-age=31536000, immutable for truly immutable assets, like the hashed files under /_next/static, and that you cannot override that in next.config.js. For pages, it sets headers from the rendering strategy: static pages get s-maxage=31536000, ISR pages get s-maxage equal to your revalidate value plus a stale-while-revalidate window, and dynamic pages get private, no-cache, no-store, max-age=0, must-revalidate.

    For everything else, use the headers() function in next.config.js:

    // next.config.js
    module.exports = {
      async headers() {
        return [
          {
            // Files in /public are not hashed, so revalidate them
            source: '/images/:path*',
            headers: [{ key: 'Cache-Control', value: 'public, max-age=0, must-revalidate' }],
          },
          {
            source: '/account/:path*',
            headers: [{ key: 'Cache-Control', value: 'no-store' }],
          },
        ];
      },
    };

    In a Route Handler you can set headers directly on the response:

    // app/api/posts/route.js
    export async function GET() {
      const posts = await getPublicPosts();
      return Response.json(posts, {
        headers: {
          'Cache-Control': 'public, max-age=60',
          'CDN-Cache-Control': 'max-age=600, stale-while-revalidate=60',
        },
      });
    }

    Nginx

    # Hashed build output, for example /assets/app.3f9a1c.js
    location /assets/ {
        add_header Cache-Control "public, max-age=31536000, immutable" always;
        try_files $uri =404;
    }
    
    # HTML and everything else: always revalidate
    location / {
        add_header Cache-Control "no-cache" always;
        try_files $uri $uri/ /index.html;
    }
    
    location /account/ {
        add_header Cache-Control "no-store" always;
        proxy_pass http://app;
    }

    A gotcha that has bitten me more than once: in Nginx, add_header directives are inherited from the parent level only if the current level defines none. So the moment you add a Cache-Control header inside a location, any security headers you set at the server level disappear from that location unless you repeat them. Newer Nginx releases (1.29.3 and later) add add_header_inherit merge to change that behavior. Also note always: without it, Nginx only adds the header for a list of success and redirect status codes.

    Apache, LiteSpeed and .htaccess

    Most shared hosting runs Apache or LiteSpeed, and both read .htaccess. With mod_headers available:

    <IfModule mod_headers.c>
      # Fingerprinted files like app.3f9a1c.js or style.8c41d2.css
      <FilesMatch "\.[0-9a-f]{6,}\.(js|css|woff2|svg|png|jpg|webp|avif)$">
        Header set Cache-Control "public, max-age=31536000, immutable"
      </FilesMatch>
    
      # HTML and PHP output: always revalidate
      <FilesMatch "\.(html|php)$">
        Header set Cache-Control "no-cache"
      </FilesMatch>
    </IfModule>

    The pattern above matches hex hashes like app.3f9a1c.js. Vite and some other tools produce names like index-B4f9xQ2a.js instead, so either adjust the pattern or, simpler, put all build output in one folder such as /assets/ and give that folder the long header.

    If you only have mod_expires, the older style still works, but it cannot express immutable:

    <IfModule mod_expires.c>
      ExpiresActive On
      ExpiresByType text/css "access plus 1 year"
      ExpiresByType application/javascript "access plus 1 year"
      ExpiresByType image/webp "access plus 1 year"
      ExpiresByType text/html "access plus 0 seconds"
    </IfModule>

    Only give a year to files whose URLs change when their content changes. On WordPress that is true for theme and plugin assets loaded with a ?ver= query string, as long as the version actually bumps on each release.

    WordPress

    WordPress core already protects private screens. Its wp_get_nocache_headers() function returns Cache-Control: no-cache, must-revalidate, max-age=0, no-store, private, and since WordPress 6.8 it includes no-store and private regardless of whether the user is logged in. It is used for things like the REST API server and admin requests, and you can adjust it with the nocache_headers filter.

    For public pages, your page cache plugin or host usually decides the headers. For theme assets, the trick is to make sure the version changes on every deploy. I tie it to the file’s modification time:

    // functions.php
    add_action( 'wp_enqueue_scripts', function () {
        $path = get_stylesheet_directory() . '/assets/main.css';
        wp_enqueue_style(
            'theme-main',
            get_stylesheet_directory_uri() . '/assets/main.css',
            array(),
            filemtime( $path ) // new ?ver= whenever the file changes
        );
    } );

    Now the browser sees main.css?ver=1791349920 and a new number after every change, so a long cache lifetime on that file is safe.

    How I inspect Cache-Control headers

    I never trust a config file on its own. CDNs, hosting panels and plugins all rewrite headers, so I check what actually comes back.

    From the terminal, curl -I sends a HEAD request and prints only the headers:

    curl -I https://example.com/
    curl -I https://example.com/assets/app.3f9a1c.js
    
    # Test revalidation: send the ETag back and expect a 304
    curl -I -H 'If-None-Match: "5f2b-1a9c"' https://example.com/
    
    # Some servers treat HEAD differently, so compare with a real GET
    curl -s -o /dev/null -D - https://example.com/

    Look for cache-control, etag, last-modified, age and vary, plus the CDN’s own status header: cf-cache-status on Cloudflare (HIT, MISS, EXPIRED, UPDATING, BYPASS, DYNAMIC) or x-vercel-cache on Vercel. Run the same command twice; a growing age value tells you the response really is coming from a shared cache.

    In Chrome DevTools, open the Network panel, reload, and click any request to see its response headers. The Size column shows “(memory cache)” or “(disk cache)” when a file never touched the network, and a 304 status when it revalidated. Two things trip people up here. First, the “Disable cache” checkbox is on in many setups, which makes everything look uncached. Second, Vercel notes that DevTools often sends Pragma: no-cache, which makes its CDN revalidate synchronously, so timings with DevTools open are not what real visitors see. To test bfcache, open Application, then Back-forward Cache, and click Run Test.

    My default Cache-Control setup, as a checklist

    If you only take one thing from this post, take this table of decisions. It covers nearly every site I work on:

    • Hashed JS, CSS, fonts and images: public, max-age=31536000, immutable.
    • Unhashed static files (favicon, robots.txt, files in /public): no-cache with an ETag, or a short max-age if you accept some staleness.
    • Public HTML: no-cache with an ETag. Add CDN caching with CDN-Cache-Control or s-maxage only when you can purge.
    • Logged-in or personalized pages: private, no-cache.
    • Sensitive pages and API responses with personal data: no-store.
    • Every response: an explicit Cache-Control header, so no cache has to guess.

    Caching rules are one of those things that feel boring until a stale stylesheet ships to every customer. Spend twenty minutes with curl -I on your own site this week. Check what your HTML sends, check what your CSS sends, and make sure every long lifetime belongs to a URL that changes when the file does. Repeat visits get faster, deploys stop needing “try a hard refresh”, and your server gets a lot quieter. If you are looking for the next speed win after that, my guide to the Speculation Rules API shows how to make the next page load before the click.

  • Speculation Rules API in 2026: Prerender the Next Page Before the Click

    Speculation Rules API in 2026: Prerender the Next Page Before the Click

    Flat vector illustration of a browser window with a cursor hovering a link while the next page is already prerendered behind it, outlined in orange-red

    Last month a client asked me why their competitor’s site “felt instant” while theirs, which scored better in Lighthouse, still felt a beat slow when you clicked around. Same kind of WordPress setup, similar hosting, similar page weight. I opened the competitor’s homepage, looked at the source, and found a small <script type="speculationrules"> block near the bottom. That was the whole trick. Their next page was already loaded, sometimes already rendered, by the time the visitor’s finger left the mouse button.

    That little JSON block is the Speculation Rules API, and in 2026 it is one of the cheapest performance wins I know of for content sites, WooCommerce stores, documentation, and marketing sites. You do not rewrite your front end. You do not move to an SPA. You tell the browser which links are likely next, how early it is allowed to act, and which URLs it must never touch.

    In this guide I will walk through how I actually use the speculation rules API on client projects: the difference between prefetch and prerender, list rules versus document rules, the four eagerness levels, the HTTP header option, how to detect speculative requests on the server, how to keep analytics honest, how to measure the gain, and what WordPress 6.8 already does for you out of the box. I will also be clear about where it does not help, because that matters just as much.

    What the Speculation Rules API actually is

    The speculation rules API is a browser feature that lets a page declare, in JSON, which future navigations the browser may load ahead of time. It replaces the old <link rel="prerender"> hint, which Chrome stopped treating as a real prerender years ago, and it is a better tool than <link rel="prefetch"> for whole documents.

    The simplest version looks like this:

    <script type="speculationrules">
    {
      "prerender": [
        { "urls": ["/pricing/", "/contact/"] }
      ]
    }
    </script>

    That is a list rule. It names specific URLs. A browser that supports the API will prerender those two pages in the background, and if the visitor then clicks a link to one of them, the hidden page is swapped into the tab almost instantly. A browser that does not support the API sees a script type it does not recognise and ignores it. No errors, no broken pages. That is why I am comfortable shipping it on client sites: it is a pure progressive enhancement.

    Two details trip people up. First, speculation rules only cover full document navigations. They do not handle images, fonts, or scripts, and they do nothing for client-side route changes inside a single page app. Second, they work best on multi-page sites, which is most of the web: WordPress, Shopify-style stores, static sites, server-rendered apps.

    Prefetch vs prerender: pick the right level of speculation

    There are two actions you can ask for, and the difference is bigger than the names suggest.

    Prefetch

    A prefetch downloads the HTML document for a URL and keeps it in a per-document memory cache. It does not fetch the page’s images, CSS, or scripts, and it does not run any JavaScript. When the user navigates, the browser already has the response, so you save the full server round trip, including TTFB. On a slow origin that alone can cut hundreds of milliseconds.

    Prefetch is cheap and low risk. The cost is one extra GET request per speculated URL. If the user never clicks, you wasted a bit of bandwidth and a bit of server time.

    Prerender

    A prerender goes much further. The browser fetches the document and all its subresources, runs its JavaScript, and renders the whole page in a hidden background renderer, almost like an invisible tab. When the user clicks, that page is activated and shown. On a good day, the visitor sees the new page in effectively zero time.

    The trade-off is cost and side effects. Prerendering uses memory, CPU, and data on the visitor’s device, and your page’s JavaScript runs before anyone is looking at it. Analytics, ad impressions, A/B test assignments, and anything that writes to storage on load can misbehave if you have not prepared for that. I come back to this further down because it is where most real-world problems hide.

    My rule of thumb on client sites: prefetch broadly, prerender selectively.

    Infographic comparing prefetch, which downloads only the HTML document, with prerender, which fully loads, renders and runs scripts for the next page in the background
    Infographic 1: Prefetch fetches only the HTML document. Prerender loads, renders and runs the whole next page before the click.

    List rules vs document rules

    List rules are fine when you know the exact next page, such as step two of a checkout, the next article in a series, or the most clicked link on a landing page. But most sites have hundreds of links, and hardcoding URLs gets stale fast. That is what document rules are for.

    A document rule uses a where condition instead of urls. The browser looks at links in the page and picks the ones that match. Conditions can use href_matches, which takes URL patterns, and selector_matches, which takes CSS selectors, combined with and, or, and not.

    Here is the document rule I start most client projects with:

    <script type="speculationrules">
    {
      "prerender": [{
        "where": {
          "and": [
            { "href_matches": "/*" },
            { "not": { "href_matches": "/wp-admin/*" } },
            { "not": { "href_matches": "/logout*" } },
            { "not": { "href_matches": "/*\\?*(^|&)add-to-cart=*" } },
            { "not": { "selector_matches": ".no-prerender" } },
            { "not": { "selector_matches": "[rel~=nofollow]" } }
          ]
        },
        "eagerness": "moderate"
      }]
    }
    </script>

    Read it from top to bottom: any same-origin link, except the admin area, logout URLs, add-to-cart query links, anything I have marked with a no-prerender class, and nofollow links. The selector_matches exclusion is my favourite part, because it gives content editors a manual escape hatch without touching JSON.

    You may still see older examples with a "source": "list" or "source": "document" key. Chrome made that key optional from Chrome 121, because the presence of urls or where already tells the browser which kind of rule it is. Leaving it in does no harm.

    You can also mix both actions in one block. A pattern I use a lot is to prerender same-origin links on hover and only prefetch cross-origin ones:

    <script type="speculationrules">
    {
      "prerender": [{
        "where": { "href_matches": "/*" },
        "eagerness": "moderate"
      }],
      "prefetch": [{
        "where": { "not": { "href_matches": "/*" } },
        "eagerness": "moderate"
      }]
    }
    </script>

    Prerendering is same-origin by default. Cross-origin prefetch works with limits: for a different site, Chrome only uses the prefetch when the visitor has no cookies for that site, for privacy reasons.

    Eagerness: when should the browser act?

    Eagerness controls how early a speculation fires. It is the setting that decides whether this feature saves your users time or quietly burns their data plan. There are four values.

    • immediate: speculate as soon as the rules are seen. No user signal at all.
    • eager: in current Chrome, a very short hover (around 10 milliseconds) on desktop, and a simple viewport heuristic on mobile, where links that scroll into view become candidates. In older Chrome versions eager behaved the same as immediate, so do not be surprised if you read older posts that say so.
    • moderate: on desktop, the pointer rests on a link for about 200 milliseconds, or the user presses down on it, whichever comes first. On mobile, Chrome uses viewport-based heuristics, since there is no hover.
    • conservative: only when the user presses down on the link with a pointer or touch.

    The defaults matter. List rules default to immediate, which makes sense because you explicitly named the URLs. Document rules default to conservative, because a page can contain hundreds of links and the browser should not guess wildly.

    Chrome also caps how much you can speculate. With immediate, it allows up to 50 prefetches and 10 prerenders. With eager, moderate, and conservative, it keeps only 2 at a time and drops the oldest when a new one starts. So a visitor sweeping the mouse across a mega menu does not trigger forty prerenders.

    For most of my projects, moderate is the sweet spot. A 200 millisecond hover is a strong signal of intent, and the time between hover and click is usually long enough to finish a prefetch and get a good head start on a prerender. Conservative is the safe default for very heavy pages or sites with lots of risky links. Immediate I reserve for list rules with one or two URLs that I am confident about from analytics.

    Infographic of the four speculation rules eagerness levels from immediate and eager to moderate and conservative, with moderate highlighted as the recommended setting
    Infographic 2: Eagerness from immediate to eager, moderate (hover about 200 ms, the sweet spot) and conservative (pointer down).

    Delivering rules with the Speculation-Rules HTTP header

    You do not have to edit HTML to add speculation rules. You can send a Speculation-Rules response header with the document that points to a JSON file. This is handy when a CDN or a reverse proxy should own the rules, or when the theme is hard to change.

    # In the server block that serves HTML pages
    add_header Speculation-Rules '"/speculationrules.json"';
    
    # Serve the rules file with the right MIME type
    location = /speculationrules.json {
      types { }
      default_type application/speculationrules+json;
    }

    Two gotchas I hit the first time. The header value is a structured header string, so the double quotes around the path are required. And the JSON file must be served as application/speculationrules+json, plus a CORS header if it lives on another origin. Relative URLs inside an external file resolve against the file’s own URL unless you add "relative_to": "document" to a rule.

    If your site uses a Content Security Policy, inline speculation rules need to be allowed in script-src with a hash or a nonce, because they live inside a script element. There is also a dedicated 'inline-speculation-rules' source keyword for rules injected by a trusted script. If you already run a strict CSP with nonces, the injected-script route is usually the least painful one.

    Adding rules with JavaScript

    Sometimes the right next page depends on logic, such as the next lesson in a course or a recommended product. You can inject rules at runtime, as long as you create the script element properly. Setting innerHTML with a speculationrules script does not register the rules.

    function addSpeculationRules(rules) {
      if (!HTMLScriptElement.supports?.('speculationrules')) return;
      const script = document.createElement('script');
      script.type = 'speculationrules';
      script.textContent = JSON.stringify(rules);
      document.head.append(script);
    }
    
    addSpeculationRules({
      prerender: [{ urls: ['/course/lesson-4/'], eagerness: 'moderate' }]
    });

    HTMLScriptElement.supports('speculationrules') is the feature check. Removing the script element later cancels its speculations, which is useful if the page state changes and a speculated URL no longer makes sense.

    Detecting speculative requests on the server

    Every speculative request carries a Sec-Purpose request header. A prefetch sends Sec-Purpose: prefetch, and a prerender sends Sec-Purpose: prefetch;prerender. This is how your backend can tell a real visit from a guess.

    I do not use it to serve different content. I use it to skip server-side side effects that should only happen on a real view, like incrementing a view counter or logging a “page seen” event in a CRM.

    // Express middleware
    app.use((req, res, next) => {
      const purpose = req.get('Sec-Purpose') || '';
      req.isSpeculative = purpose.includes('prefetch');
      next();
    });
    
    app.get('/articles/:slug', async (req, res) => {
      const article = await getArticle(req.params.slug);
      if (!req.isSpeculative) {
        await incrementViewCount(article.id);
      }
      res.render('article', { article });
    });

    If a page must never be speculated, the blunt option is to return a non-2xx status, such as 503, when you see that header. The browser then discards the speculation. I treat that as a last resort. Usually it is better to allow the load and move the side effect to the client, where you can wait for activation.

    Keeping analytics honest: document.prerendering and prerenderingchange

    This is the part people skip, and it is why some teams turn prerender off after a week. A prerendered page runs its JavaScript before anyone sees it. If your analytics script sends a page view on load, you will count views that never happened.

    The good news is that Google Analytics, Google Publisher Tag, and AdSense already wait for activation by default, and so do several other big vendors. Custom scripts, smaller vendors, and home-grown tracking often do not. The fix is small:

    const whenActivated = new Promise((resolve) => {
      if (document.prerendering) {
        document.addEventListener('prerenderingchange', resolve, { once: true });
      } else {
        resolve();
      }
    });
    
    async function initTracking() {
      await whenActivated;
      sendPageView();
      startHeatmap();
    }
    
    initTracking();

    document.prerendering is true while the page is hidden in the background. The prerenderingchange event fires on the document the moment the user actually navigates to it. Anything that should only happen when a human is looking, such as page views, chat widget greetings, ad impression pings, or “you have 3 free articles left” counters, goes behind that promise.

    Chrome already holds back a lot on its own while a page is prerendering. Permission prompts, geolocation, notifications, and media playback are deferred until activation, and cross-origin iframes are not loaded until then. So a YouTube embed or most third-party ad frames will not start in the background. You only have to deal with your own scripts.

    There is one more subtle problem: stale state. If a visitor adds something to the cart with a fetch call and then clicks a cart link that was prerendered a few seconds earlier, they might see an empty cart. MDN and the Chrome team recommend refreshing that state on activation, or clearing speculations with a Clear-Site-Data response header using the "prefetchCache" and "prerenderCache" values on the request that changed state, for example on logout or add-to-cart.

    Clear-Site-Data: "prefetchCache", "prerenderCache"

    What not to prerender or prefetch

    Speculation is only safe for URLs where a GET request has no side effects. The HTTP spec has always said GET should be safe, but real sites are messy. Before I turn on anything more eager than conservative on a client site, I go hunting for these:

    • Logout and sign-out links. Prefetching a logout URL can log the user out without a click.
    • Add-to-cart, add-to-wishlist, and “remove” or “delete” links that work with a plain GET.
    • Language or currency switchers that set a cookie on the server.
    • Login flows that send an email or SMS code as soon as the page loads.
    • Metered content, where viewing a page consumes a free article or a credit.
    • Personalised or private pages, like account dashboards, order history, and anything behind a login, where the content can go stale between prerender and click.
    • Pages with heavy, non-prerender-aware third-party scripts you cannot control.

    A useful shortcut from MDN: check the paths disallowed in your robots.txt. They are often the same private or action URLs you should keep out of speculation rules.

    Infographic of prerender safety guardrails: logout, add-to-cart, delete and private page links are blocked while safe links are prerendered and analytics wait for activation
    Infographic 3: Exclude logout, add-to-cart, delete and private pages, and defer analytics until the prerendered page is activated.

    Measuring the impact on LCP, INP, and Core Web Vitals

    When a prerender is used, Chrome measures Core Web Vitals from the moment of activation, not from when the prerender started. That is why prerendered navigations often report an LCP close to zero, and why layout shifts during loading largely disappear from the user’s point of view. Interaction delays during load also tend to drop, because the page has finished its heavy startup work before the user can interact. If INP is your weak metric, pair this with the main-thread work I described in my INP guide, because speculation helps the load, not slow event handlers on a page that is already open.

    To see whether a page was prerendered, check activationStart on the navigation timing entry:

    const nav = performance.getEntriesByType('navigation')[0];
    const wasPrerendered =
      document.prerendering || (nav && nav.activationStart > 0);
    
    console.log('activationStart:', nav ? nav.activationStart : 0);

    A non-zero activationStart means the page was prerendered, fully or partly, and then activated. For prefetched pages, the navigation entry’s deliveryType is "navigational-prefetch". If you collect real user metrics, the web-vitals library (version 3.1.0 and later) already adjusts for prerendered navigations and labels them through the metric’s navigationType. I send that label as a custom dimension so I can compare prerendered and normal navigations side by side.

    Also measure waste. Log an event when you insert rules, compare it with activations, and if most speculations are never used, your eagerness is too high or your URL patterns are too broad.

    For debugging, Chrome DevTools has a dedicated area: open the Application panel, then Background services, then Speculative loads. It shows the rule sets found on the page, every candidate URL, its status (not triggered, ready, failure), and the reason a speculation failed. Prefetches also show up in the Network panel with the Sec-Purpose header. One tip: open that panel before you load the page, or reload once it is open, otherwise it starts empty.

    Browser support in 2026

    Be honest with clients here. Full support is in Chromium-based browsers: Chrome, Edge, Opera, and other browsers built on Chromium. Chrome has supported prerendering through speculation rules since version 109, with document rules, eagerness, and the HTTP header arriving in Chrome 121.

    Safari has an implementation of speculation rules prefetch, but as of my latest check it is not turned on by default for regular users, and WebKit has switched it back off at least once while fixing bugs. Firefox has signalled support for parts of the spec but has not shipped it. Things move quickly in this area, so check the current compatibility table on MDN before you promise anything.

    In practice, that means Chromium visitors get faster navigations and everyone else gets exactly the site they had before. Even with partial support, a large share of most audiences benefits.

    Chrome also skips speculation in situations where it would hurt the user: when Data Saver is on, in Energy Saver mode on low battery, under memory pressure, when the user has turned off the “Preload pages” setting, and for pages in background tabs. Some extensions, such as uBlock Origin, disable preloading too. So never build a feature that depends on speculation actually happening.

    WordPress: speculative loading is already in core

    If you run WordPress 6.8 or newer, you already have speculation rules on your front end. Core prints a document rule that prefetches links to your own front end with conservative eagerness, which means the prefetch starts when the visitor presses down on a link. It is turned off for logged-in users and on sites without pretty permalinks, and URLs with query strings are excluded by default, which catches most action links from plugins.

    The defaults are deliberately cautious, so that it is safe across millions of sites. On client sites where I have audited the risky URLs, I usually go one step further with the wp_speculation_rules_configuration filter:

    add_filter( 'wp_speculation_rules_configuration', function ( $config ) {
        if ( is_array( $config ) ) {
            $config['mode']      = 'prerender'; // auto, prefetch or prerender
            $config['eagerness'] = 'moderate';  // auto, conservative, moderate or eager
        }
        return $config;
    } );

    Note the is_array() check: the filter passes null when speculative loading is disabled for the current request, for example for logged-in users, and you do not want to accidentally turn it back on for them.

    To keep specific paths out, use wp_speculation_rules_href_exclude_paths. Its second argument tells you the mode, so you can exclude a path from prerender only:

    add_filter( 'wp_speculation_rules_href_exclude_paths', function ( $paths, $mode ) {
        $paths[] = '/cart/*';
        $paths[] = '/my-account/*';
        if ( 'prerender' === $mode ) {
            $paths[] = '/members/*';
        }
        return $paths;
    }, 10, 2 );

    Core also respects two CSS classes: add no-prefetch or no-prerender to a block in the editor through the Additional CSS class field, and links inside it are skipped. no-prefetch opts out of both, since prerendering includes prefetching. For custom rules on top of the main one, there is a wp_load_speculation_rules action that hands you a WP_Speculation_Rules object with an add_rule() method.

    If you prefer a settings screen over code, the Speculative Loading plugin from the WordPress Performance Team adds options under Settings, Reading, and defaults to prerender with moderate eagerness. Whichever route you take, speculation does not fix a slow origin. If TTFB is high, every prefetch is slow too, so start with the server work from my WordPress TTFB guide and then add speculation on top.

    What about Next.js and other SPA frameworks?

    If you build with Next.js, the Link component already prefetches routes in production when links enter the viewport. Those are client-side navigations, and speculation rules cannot prerender them. Adding a speculationrules block to every Next.js page will not make in-app route changes faster.

    Where speculation rules still help in a Next.js project:

    • Plain <a> links that cause full page loads, such as links between separately deployed apps or zones on the same domain.
    • The first navigation into the app from a static landing page, a docs site, or a WordPress blog on the same origin.
    • Marketing pages built as a traditional multi-page site that link into the app’s sign-up page.

    The same thinking applies to any SPA. Speculation rules can prerender the app itself from a previous page, which hides its startup cost, but route changes inside the app are your router’s job.

    Speculation rules and view transitions

    One pairing I really like: speculation rules plus cross-document view transitions. Prerendering makes the next document ready instantly, and a view transition animates the swap so it feels like an app. I wrote about the transition side in Cross-Document View Transitions in 2026. Together they give a multi-page site the smoothness people usually reach for a JavaScript framework to get, with none of the hydration cost.

    My rollout checklist for client sites

    This is the order I follow when I add speculation rules to an existing site:

    1. Measure first. Record field LCP, INP, and the share of Chromium traffic so you can compare later.
    2. Audit links. Find every GET URL with side effects: logout, cart actions, language switchers, one-time login links, metered pages.
    3. Start with prefetch and moderate eagerness on same-origin links, with exclusions for the URLs from step 2.
    4. Make analytics prerender-safe. Confirm your tools wait for activation, and wrap custom tracking in a prerenderingchange check.
    5. Move to prerender with moderate for content pages: blog posts, product pages, category pages, docs.
    6. Keep private and personalised areas on prefetch or out entirely.
    7. Watch the server. Expect a modest increase in HTML requests; make sure page caching absorbs it.
    8. Verify in DevTools under Application, Background services, Speculative loads, and in your RUM data with activationStart.
    9. Review after two weeks: hit rate, Core Web Vitals, conversions, and any odd analytics changes.

    Wrapping up

    The speculation rules API is one of those rare performance features where the effort is small and the user-visible result is big. A dozen lines of JSON, a careful list of exclusions, and a few lines of analytics hygiene can make a normal multi-page site feel instant in Chromium browsers, while every other browser simply carries on as before.

    My advice: start with prefetch, move to prerender with moderate eagerness once you have checked your side-effect URLs and analytics, and measure with real user data instead of trusting a lab score. If you want help rolling this out on a WordPress or headless site without breaking tracking or checkout flows, that is exactly the kind of work I do for clients.