<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: nocache.dev</title>
    <description>The latest articles on DEV Community by nocache.dev (@nocachedev).</description>
    <link>https://dev.to/nocachedev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4196073%2F397bf7de-c1f3-4f8f-a53c-d44ad76da94d.png</url>
      <title>DEV Community: nocache.dev</title>
      <link>https://dev.to/nocachedev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nocachedev"/>
    <language>en</language>
    <item>
      <title>Laravel responses are uncacheable by default. Here's why and how to fix it</title>
      <dc:creator>nocache.dev</dc:creator>
      <pubDate>Tue, 10 Nov 2026 13:16:14 +0000</pubDate>
      <link>https://dev.to/nocachedev/laravel-responses-are-uncacheable-by-default-heres-why-and-how-to-fix-it-ak5</link>
      <guid>https://dev.to/nocachedev/laravel-responses-are-uncacheable-by-default-heres-why-and-how-to-fix-it-ak5</guid>
      <description>&lt;p&gt;Put a CDN in front of a fresh Laravel app and the hit rate for HTML will be zero. Nothing is broken. The framework is being careful on purpose, in two separate places, and both have to be dealt with before any shared cache will help.&lt;/p&gt;

&lt;p&gt;I read the current framework source and ran live header checks on two well-known Laravel sites today. First, where the defaults come from.&lt;/p&gt;

&lt;h2&gt;
  
  
  The header comes from Symfony
&lt;/h2&gt;

&lt;p&gt;Laravel's &lt;code&gt;Response&lt;/code&gt; extends Symfony's, and Symfony's &lt;code&gt;ResponseHeaderBag&lt;/code&gt; fills in &lt;code&gt;Cache-Control&lt;/code&gt; whenever you haven't set one. From &lt;code&gt;computeCacheControlValue()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;cacheControl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Last-Modified'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Expires'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'private, must-revalidate'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// conservative by default&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'no-cache, private'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So a plain &lt;code&gt;return view('home')&lt;/code&gt; leaves with &lt;code&gt;Cache-Control: no-cache, private&lt;/code&gt;. &lt;code&gt;private&lt;/code&gt; keeps it out of every shared cache, and &lt;code&gt;no-cache&lt;/code&gt; makes the browser revalidate before reuse. If you set directives but neither &lt;code&gt;public&lt;/code&gt; nor &lt;code&gt;private&lt;/code&gt;, Symfony appends &lt;code&gt;private&lt;/code&gt; unless &lt;code&gt;s-maxage&lt;/code&gt; is present. That's a sensible default for a framework that can't know whether a page is personal. It just means caching is opt-in, route by route.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cookie comes from the &lt;code&gt;web&lt;/code&gt; group
&lt;/h2&gt;

&lt;p&gt;Every route in &lt;code&gt;routes/web.php&lt;/code&gt; runs inside the &lt;code&gt;web&lt;/code&gt; middleware group. In Laravel 12 and 13 that's &lt;code&gt;EncryptCookies&lt;/code&gt;, &lt;code&gt;AddQueuedCookiesToResponse&lt;/code&gt;, &lt;code&gt;StartSession&lt;/code&gt;, &lt;code&gt;ShareErrorsFromSession&lt;/code&gt;, the CSRF middleware (&lt;code&gt;ValidateCsrfToken&lt;/code&gt; in 12, &lt;code&gt;PreventRequestForgery&lt;/code&gt; in 13) and &lt;code&gt;SubstituteBindings&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two of those set cookies on every response, including anonymous GETs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;StartSession&lt;/code&gt; adds the session cookie. Its name comes from &lt;code&gt;APP_NAME&lt;/code&gt;: &lt;code&gt;laravel_session&lt;/code&gt; in the 10.x and 11.x skeletons, &lt;code&gt;laravel-session&lt;/code&gt; in the current 12.x and 13.x ones.&lt;/li&gt;
&lt;li&gt;The CSRF middleware adds &lt;code&gt;XSRF-TOKEN&lt;/code&gt;, which the docs describe as "a developer convenience" for Axios and Angular.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A &lt;code&gt;Set-Cookie&lt;/code&gt; header doesn't stop caching by itself. RFC 9111 says it plainly: a cacheable response with &lt;code&gt;Set-Cookie&lt;/code&gt; "can be (and often is) used to satisfy subsequent requests". That's the dangerous direction: a cache that stores the response hands one visitor's session cookie to everyone else. Most CDNs are cautious instead. Cloudflare's default behaviour page says it does not cache a resource when "the Set-Cookie header exists". Either way, a page that sets a session cookie is a page no CDN should share.&lt;/p&gt;

&lt;h2&gt;
  
  
  The built-in fix: &lt;code&gt;cache.headers&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Laravel ships a &lt;code&gt;cache.headers&lt;/code&gt; middleware (&lt;code&gt;SetCacheHeaders&lt;/code&gt;). Directives are snake case and separated by semicolons:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'cache.headers:public;max_age=0;s_maxage=600;etag'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/pricing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;PricingController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The options go straight to Symfony's &lt;code&gt;Response::setCache()&lt;/code&gt;, so &lt;code&gt;s_maxage&lt;/code&gt;, &lt;code&gt;stale_while_revalidate&lt;/code&gt;, &lt;code&gt;stale_if_error&lt;/code&gt;, &lt;code&gt;immutable&lt;/code&gt; and &lt;code&gt;last_modified&lt;/code&gt; all work. &lt;code&gt;etag&lt;/code&gt; hashes the body with xxh128, and the middleware then calls &lt;code&gt;isNotModified()&lt;/code&gt;, so a matching &lt;code&gt;If-None-Match&lt;/code&gt; gets a 304 with no body.&lt;/p&gt;

&lt;p&gt;It has limits worth knowing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It only touches GET and HEAD, successful responses, and responses with a body.&lt;/strong&gt; Errors keep the default header.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In Laravel 12 and earlier it skips HEAD requests.&lt;/strong&gt; By the time route middleware sees the response, Symfony's &lt;code&gt;prepare()&lt;/code&gt; has emptied the body for HEAD, and the empty-body check returns early. The 13.x source adds an explicit HEAD exception. On older versions, &lt;code&gt;curl -I&lt;/code&gt; can show you &lt;code&gt;no-cache, private&lt;/code&gt; while real GETs get your header.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It never looks at cookies.&lt;/strong&gt; And it couldn't see the session cookie anyway: group middleware wraps route middleware, so &lt;code&gt;StartSession&lt;/code&gt; attaches its cookie on the way out, after &lt;code&gt;cache.headers&lt;/code&gt; has already set &lt;code&gt;public&lt;/code&gt;. Inside the &lt;code&gt;web&lt;/code&gt; group you get &lt;code&gt;public, s-maxage=600&lt;/code&gt; and a session cookie on the same response.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Live check 1: laravel.com
&lt;/h2&gt;

&lt;p&gt;The header check, trimmed to the relevant headers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="na"&gt;cache-control: public, s-maxage&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;86400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stale&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;while&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;revalidate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;
&lt;span class="n"&gt;cf&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;DYNAMIC&lt;/span&gt;
&lt;span class="n"&gt;laravel&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;cloud&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;DYNAMIC&lt;/span&gt;
&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;powered&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Statamic&lt;/span&gt;
&lt;span class="na"&gt;set-cookie: XSRF-TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;…&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;expires&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Tue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;03&lt;/span&gt; &lt;span class="nc"&gt;Nov&lt;/span&gt; &lt;span class="mi"&gt;2026&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;02&lt;/span&gt; &lt;span class="no"&gt;GMT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nc"&gt;Max&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nc"&gt;Age&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;7200&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;secure&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;samesite&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;lax&lt;/span&gt;
&lt;span class="na"&gt;set-cookie: laravelcom_session&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;…&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;expires&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Tue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;03&lt;/span&gt; &lt;span class="nc"&gt;Nov&lt;/span&gt; &lt;span class="mi"&gt;2026&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;02&lt;/span&gt; &lt;span class="no"&gt;GMT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nc"&gt;Max&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nc"&gt;Age&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;7200&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;secure&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;httponly&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;samesite&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;lax&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There was also a third encrypted cookie with a random 40-character name, plus Cloudflare's &lt;code&gt;__cf_bm&lt;/code&gt;. Two &lt;code&gt;curl -sI&lt;/code&gt; requests right after gave the same &lt;code&gt;cache-control&lt;/code&gt;, &lt;code&gt;cf-cache-status: DYNAMIC&lt;/code&gt; and &lt;code&gt;laravel-cloud-cache: DYNAMIC&lt;/code&gt;, with fresh values in all the cookies each time.&lt;/p&gt;

&lt;p&gt;This is limit 3 in the wild. Someone opted the homepage into a one-day shared cache (&lt;code&gt;s-maxage=86400&lt;/code&gt;), and the &lt;code&gt;web&lt;/code&gt; group still attached &lt;code&gt;XSRF-TOKEN&lt;/code&gt; and a session cookie named after the app (&lt;code&gt;laravelcom_session&lt;/code&gt;, the slug of "laravel.com" plus &lt;code&gt;_session&lt;/code&gt;). Both cache layers answered &lt;code&gt;DYNAMIC&lt;/code&gt;, which Cloudflare's docs define as "not eligible for cache at request time": HTML isn't cached by default and no rule opts this page in. Given the cookies, that's the safe outcome. But the &lt;code&gt;s-maxage&lt;/code&gt; buys nothing, and every request goes to the origin. The check graded it D, mostly for the &lt;code&gt;Set-Cookie&lt;/code&gt; on a publicly cacheable response, plus no &lt;code&gt;ETag&lt;/code&gt; or &lt;code&gt;Last-Modified&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One more detail: Cloudflare's docs say &lt;code&gt;s-maxage&lt;/code&gt; implies &lt;code&gt;proxy-revalidate&lt;/code&gt;, which disables &lt;code&gt;stale-while-revalidate&lt;/code&gt; there. If you sit behind Cloudflare, don't count on that combination. Put the stale window in a &lt;code&gt;CDN-Cache-Control&lt;/code&gt; header instead (for example &lt;code&gt;max-age=600, stale-while-revalidate=60&lt;/code&gt;): Cloudflare uses it in place of &lt;code&gt;Cache-Control&lt;/code&gt; for its own caching decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Live check 2: laravel-news.com
&lt;/h2&gt;

&lt;p&gt;The header check (a GET):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="na"&gt;cache-control: max-age&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;public&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;maxage&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;86400&lt;/span&gt;
&lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;modified&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Tue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;03&lt;/span&gt; &lt;span class="nc"&gt;Nov&lt;/span&gt; &lt;span class="mi"&gt;2026&lt;/span&gt; &lt;span class="mi"&gt;08&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;04&lt;/span&gt; &lt;span class="no"&gt;GMT&lt;/span&gt;
&lt;span class="n"&gt;cf&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;BYPASS&lt;/span&gt;
&lt;span class="n"&gt;laravel&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;cloud&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;DYNAMIC&lt;/span&gt;
&lt;span class="na"&gt;set-cookie: __cf_bm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;…&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nc"&gt;HttpOnly&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nc"&gt;SameSite&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nc"&gt;Secure&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="o"&gt;=/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nc"&gt;Domain&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;laravel&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;news&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nc"&gt;Expires&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Tue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;03&lt;/span&gt; &lt;span class="nc"&gt;Nov&lt;/span&gt; &lt;span class="mi"&gt;2026&lt;/span&gt; &lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;04&lt;/span&gt; &lt;span class="no"&gt;GMT&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one did the cookie part right. No session cookie, no &lt;code&gt;XSRF-TOKEN&lt;/code&gt;, only Cloudflare's bot cookie, so the homepage is served without a session. It got a C.&lt;/p&gt;

&lt;p&gt;Then the two &lt;code&gt;curl -sI&lt;/code&gt; requests disagreed with each other:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="na"&gt;cache-control: max-age&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;public&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;maxage&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;86400&lt;/span&gt;
&lt;span class="n"&gt;cf&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;BYPASS&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;control&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;no&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;private&lt;/span&gt;
&lt;span class="n"&gt;cf&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;BYPASS&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I sent six more HEAD requests and all six got &lt;code&gt;no-cache, private&lt;/code&gt;. Four GETs all got &lt;code&gt;max-age=600, public, s-maxage=86400&lt;/code&gt;. HEAD and GET are getting different policies. I can't see their code, but it's exactly what limit 2 produces, and it's a good reason not to trust &lt;code&gt;curl -I&lt;/code&gt; alone on a Laravel site.&lt;/p&gt;

&lt;p&gt;Two smaller things. &lt;code&gt;last-modified&lt;/code&gt; was exactly five hours behind the &lt;code&gt;Date&lt;/code&gt; header on every request and changed every second, so it works as a validator for nothing. And Cloudflare said &lt;code&gt;BYPASS&lt;/code&gt; on every request. Its docs are specific about that status: the request was eligible (so a Cache Rule covers this HTML), but the origin response was not cacheable. Common reasons listed are &lt;code&gt;private&lt;/code&gt; or &lt;code&gt;no-store&lt;/code&gt;, a &lt;code&gt;Set-Cookie&lt;/code&gt; from the origin, or a &lt;code&gt;CDN-Cache-Control&lt;/code&gt; header. The GETs I saw carried &lt;code&gt;public&lt;/code&gt; and only Cloudflare's own cookie, so whatever disqualifies them isn't visible from outside. It's still the most useful status to see: the edge wants to cache the page, and something at the origin is saying no.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: a route group without a session
&lt;/h2&gt;

&lt;p&gt;Public pages that look the same for every anonymous visitor shouldn't run &lt;code&gt;StartSession&lt;/code&gt; at all. In Laravel 11 and later, register them in their own file with &lt;code&gt;then:&lt;/code&gt; in &lt;code&gt;bootstrap/app.php&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate&lt;/span&gt;\&lt;span class="nc"&gt;Routing&lt;/span&gt;\&lt;span class="nc"&gt;Middleware&lt;/span&gt;\&lt;span class="nc"&gt;SubstituteBindings&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate&lt;/span&gt;\&lt;span class="nc"&gt;Support&lt;/span&gt;\&lt;span class="nc"&gt;Facades&lt;/span&gt;\&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withRouting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;web&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;__DIR__&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'/../routes/web.php'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;then&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="nc"&gt;SubstituteBindings&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'cache.headers:public;max_age=0;s_maxage=600;etag'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;base_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'routes/public.php'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No session means no session cookie, no &lt;code&gt;XSRF-TOKEN&lt;/code&gt;, and no &lt;code&gt;@csrf&lt;/code&gt; forms on those pages, so keep forms and anything that reads &lt;code&gt;auth()&lt;/code&gt; in &lt;code&gt;web.php&lt;/code&gt;. &lt;code&gt;max_age=0&lt;/code&gt; keeps browsers coming back so a deploy shows up quickly, while &lt;code&gt;s-maxage&lt;/code&gt; lets the CDN absorb the traffic.&lt;/p&gt;

&lt;p&gt;For pages that do need a session, be explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'dashboard'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Cache-Control'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'private, no-cache'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's what Symfony sends anyway, but writing it down stops the next person from wrapping the route in &lt;code&gt;cache.headers:public&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On Cloudflare, origin headers alone aren't enough for HTML. It doesn't cache HTML by default, so you also need a Cache Rule that makes those paths eligible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing it
&lt;/h2&gt;

&lt;p&gt;Send real GETs, twice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl -s -D - -o /dev/null https://example.com/pricing &lt;span class="o"&gt;|&lt;/span&gt; grep -iE &lt;span class="s1"&gt;'cache-control|set-cookie|cf-cache-status|age'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a cacheable page you want &lt;code&gt;public&lt;/code&gt; with an &lt;code&gt;s-maxage&lt;/code&gt;, no &lt;code&gt;set-cookie&lt;/code&gt; from your app, and the CDN status going from &lt;code&gt;MISS&lt;/code&gt; to &lt;code&gt;HIT&lt;/code&gt; with &lt;code&gt;age&lt;/code&gt; climbing on the second request. Then repeat with a signed-in cookie and check that you get &lt;code&gt;private&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The checks above came from nocache.dev, which I built. There's also a Laravel package (10 to 13) that covers both halves: &lt;code&gt;composer require nocache-dev/laravel&lt;/code&gt; adds a &lt;code&gt;nocache.headers:900&lt;/code&gt; middleware for a session-free group like the one above. Anonymous GETs get &lt;code&gt;public, max-age=0, s-maxage=900&lt;/code&gt;, plus &lt;code&gt;CDN-Cache-Control: max-age=900, stale-while-revalidate=60&lt;/code&gt; so Cloudflare can still serve stale while it revalidates. Signed-in users, requests that started a session, and responses that set a cookie get &lt;code&gt;private, no-store&lt;/code&gt;, even if the route was left in the &lt;code&gt;web&lt;/code&gt; group. &lt;code&gt;php artisan nocache:check https://example.com --min-grade=B&lt;/code&gt; fails a CI step when a page's grade drops below B.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>webdev</category>
      <category>performance</category>
    </item>
    <item>
      <title>WordPress page cache vs CDN cache: who is answering your request?</title>
      <dc:creator>nocache.dev</dc:creator>
      <pubDate>Tue, 03 Nov 2026 13:12:33 +0000</pubDate>
      <link>https://dev.to/nocachedev/wordpress-page-cache-vs-cdn-cache-who-is-answering-your-request-479n</link>
      <guid>https://dev.to/nocachedev/wordpress-page-cache-vs-cdn-cache-who-is-answering-your-request-479n</guid>
      <description>&lt;p&gt;"I cleared the cache and nothing changed" is the most common WordPress support sentence I know. The honest reply is: which one?&lt;/p&gt;

&lt;p&gt;A typical WordPress site can have three page caches stacked on top of each other, each with its own rules for what to store, when to skip, and how to purge. A request hits them from the outside in, so the first one that has a copy wins and the others never hear about it.&lt;/p&gt;

&lt;p&gt;I ran live checks on two WordPress sites today. First, the layers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three caches, outside in
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Edge cache (CDN).&lt;/strong&gt; Cloudflare, Fastly, CloudFront and the like. Closest to the visitor, so it answers first. Cloudflare in particular doesn't cache HTML by default: it caches by file extension, and HTML pages come back with &lt;code&gt;cf-cache-status: DYNAMIC&lt;/code&gt; until you add a Cache Rule (or a product like APO) that makes them eligible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Server cache.&lt;/strong&gt; A reverse proxy in front of PHP: Varnish or nginx at a managed host, or LiteSpeed's built-in LSCache. It stores the full response in memory or on disk and never wakes PHP on a hit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plugin page cache.&lt;/strong&gt; WP Rocket, W3 Total Cache, WP Super Cache. These write the rendered page to a static HTML file under &lt;code&gt;wp-content/cache&lt;/code&gt;. Depending on the setup, the file is served by a rewrite rule (no PHP at all) or by &lt;code&gt;advanced-cache.php&lt;/code&gt; early in the WordPress boot.&lt;/p&gt;

&lt;p&gt;You don't need all three, but you need to know which ones you have: each has its own purge and its own bypass rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Telltale signatures
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare:&lt;/strong&gt; &lt;code&gt;cf-cache-status&lt;/code&gt; (&lt;code&gt;HIT&lt;/code&gt;, &lt;code&gt;MISS&lt;/code&gt;, &lt;code&gt;DYNAMIC&lt;/code&gt;, &lt;code&gt;BYPASS&lt;/code&gt;, &lt;code&gt;EXPIRED&lt;/code&gt;...).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LiteSpeed:&lt;/strong&gt; &lt;code&gt;x-litespeed-cache: hit&lt;/code&gt;, &lt;code&gt;miss&lt;/code&gt;, or &lt;code&gt;hit,private&lt;/code&gt; for its per-user private cache.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Varnish-style host caches:&lt;/strong&gt; usually &lt;code&gt;x-cache&lt;/code&gt; plus something host-specific. WP Engine adds &lt;code&gt;x-cacheable&lt;/code&gt; and &lt;code&gt;x-cache-group&lt;/code&gt;, and &lt;code&gt;x-pass-why&lt;/code&gt; when it skips the cache.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WP Rocket:&lt;/strong&gt; no header. It appends an HTML comment at the very end of the page: &lt;code&gt;This website is like a Rocket, isn't it? Performance optimized by WP Rocket.&lt;/code&gt; When the page was served from WP Rocket's cache file, the comment ends with &lt;code&gt;- Debug: cached@&amp;lt;unix timestamp&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;W3 Total Cache:&lt;/strong&gt; also an HTML comment, starting &lt;code&gt;Performance optimized by W3 Total Cache&lt;/code&gt;, with lines like &lt;code&gt;Page Caching using Disk: Enhanced&lt;/code&gt; and &lt;code&gt;Served from: ... @ &amp;lt;date&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two caveats. HTML minification can strip those comments, so their absence proves nothing. And an &lt;code&gt;age&lt;/code&gt; header only tells you how long the copy has sat in the cache that answered, not in the ones behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Live check 1: wordpress.org/news
&lt;/h2&gt;

&lt;p&gt;The nocache.dev check, trimmed to caching headers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;server: nginx
vary: Accept-Encoding, accept, content-type
x-nc: MISS dca 2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two &lt;code&gt;curl -sI&lt;/code&gt; requests right after:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;x-nc: EXPIRED dca 2
x-nc: HIT dca 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's no CDN signature here, no &lt;code&gt;Cache-Control&lt;/code&gt;, no &lt;code&gt;ETag&lt;/code&gt; and no &lt;code&gt;Last-Modified&lt;/code&gt;. The only cache status comes from &lt;code&gt;x-nc&lt;/code&gt;, the nginx cache that sits in front of WordPress.org. The three requests got &lt;code&gt;MISS&lt;/code&gt;, &lt;code&gt;EXPIRED&lt;/code&gt; and &lt;code&gt;HIT&lt;/code&gt;, and the trailing &lt;code&gt;dca 1&lt;/code&gt; / &lt;code&gt;dca 2&lt;/code&gt; changed between requests, so I was most likely talking to different cache nodes, each with its own copy.&lt;/p&gt;

&lt;p&gt;A server cache is doing all the work. It's fast, but the browser was never told how long to keep the page, so it guesses, and with no validators it can't revalidate cheaply. The check graded it D, mostly for those two gaps: missing &lt;code&gt;Cache-Control&lt;/code&gt; and no validators.&lt;/p&gt;

&lt;h2&gt;
  
  
  Live check 2: a WooCommerce store with three layers
&lt;/h2&gt;

&lt;p&gt;For a store I used nalgene.com, a WooCommerce site. Trimmed result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;cache-control: max-age=600, must-revalidate
cf-cache-status: HIT
age: 179
x-cache: HIT: 1
x-cache-group: normal
x-cacheable: SHORT
vary: Accept-Encoding, Accept-Encoding,Cookie
last-modified: Tue, 27 Oct 2026 13:18:03 GMT
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And at the bottom of the HTML:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- This website is like a Rocket, isn't it? Performance optimized by WP Rocket. Learn more: https://wp-rocket.me --&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three layers show up:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare&lt;/strong&gt; answered (&lt;code&gt;cf-cache-status: HIT&lt;/code&gt;, &lt;code&gt;age: 179&lt;/code&gt;), so HTML caching has been turned on there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A WP Engine-style Varnish layer&lt;/strong&gt; behind it (&lt;code&gt;x-cacheable&lt;/code&gt;, &lt;code&gt;x-cache-group&lt;/code&gt;, and WP Engine's default 600-second page TTL in &lt;code&gt;max-age=600&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WP Rocket&lt;/strong&gt; in WordPress, but with no &lt;code&gt;cached@&lt;/code&gt; timestamp. WP Rocket's docs say that happens when its page cache is off, for example on hosts that run their own page cache. Here it is doing optimisation only, and the host does the caching.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One detail is easy to misread. I repeated the request twice with &lt;code&gt;curl -sI&lt;/code&gt; and got &lt;code&gt;x-cache: HIT: 1&lt;/code&gt; and &lt;code&gt;age: 193&lt;/code&gt; both times. That &lt;code&gt;x-cache&lt;/code&gt; value isn't live. It's the header Varnish attached when Cloudflare first fetched the page, and Cloudflare stores and replays it with every hit. &lt;strong&gt;On an edge HIT, every header from the inner layers is a snapshot from the moment the edge filled its cache.&lt;/strong&gt; To see what the inner layers are doing right now, you have to get past the edge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Logged-in users: what WordPress actually sends
&lt;/h2&gt;

&lt;p&gt;For logged-in requests, WordPress core adds &lt;code&gt;wp_get_nocache_headers()&lt;/code&gt; to the front-end response (in &lt;code&gt;WP::send_headers()&lt;/code&gt;). Since WordPress 6.3, that means:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Cache-Control: no-cache, must-revalidate, max-age=0, no-store, private
Expires: Wed, 11 Jan 1984 05:00:00 GMT
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before 6.3 it was &lt;code&gt;no-cache, must-revalidate, max-age=0&lt;/code&gt; without &lt;code&gt;no-store, private&lt;/code&gt;, and since 6.8 every call to &lt;code&gt;nocache_headers()&lt;/code&gt; sends the longer version. Core also sends these headers on 404s for anonymous visitors. WooCommerce uses the same mechanism on the cart, checkout and account pages, and also defines &lt;code&gt;DONOTCACHEPAGE&lt;/code&gt; so page cache plugins skip them.&lt;/p&gt;

&lt;p&gt;The 1984 &lt;code&gt;Expires&lt;/code&gt; date is the giveaway. Here is nalgene.com's cart page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;cache-control: no-cache, must-revalidate, max-age=0, private
expires: Wed, 11 Jan 1984 05:00:00 GMT
x-pass-why: custom-path
cf-cache-status: BYPASS
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The host skipped the cache by path (&lt;code&gt;x-pass-why&lt;/code&gt;), and Cloudflare saw &lt;code&gt;private&lt;/code&gt; and bypassed it too. That's what you want.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cookies decide who gets the cached copy
&lt;/h2&gt;

&lt;p&gt;Headers on the response only matter once a cache has decided to ask the origin. The decision to skip the cache for a given visitor is made earlier, from request cookies: &lt;code&gt;wordpress_logged_in_*&lt;/code&gt; for signed-in users, and for WooCommerce &lt;code&gt;woocommerce_items_in_cart&lt;/code&gt;, &lt;code&gt;woocommerce_cart_hash&lt;/code&gt; and &lt;code&gt;wp_woocommerce_session_*&lt;/code&gt;. WooCommerce's docs list those cookies as the ones to exclude.&lt;/p&gt;

&lt;p&gt;Every layer needs its own copy of those rules. I sent nalgene.com's homepage two requests with dummy cookies:&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;wordpress_logged_in_abc=1&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;cache-control: max-age=0, must-revalidate, private
x-cache: MISS
x-cacheable: NO:Passed
cf-cache-status: DYNAMIC
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both layers stepped aside. With &lt;code&gt;woocommerce_items_in_cart=1; wp_woocommerce_session_abc=1&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;cache-control: max-age=600, must-revalidate
x-cache: HIT: 1
cf-cache-status: HIT
age: 197
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cloudflare served its cached copy. WP Engine documents those WooCommerce cookies as cache exclusions, but the request never reached WP Engine. On a homepage that may well be fine, if the mini-cart is filled in by JavaScript after the page loads. But this is how carts end up showing the wrong count: the host's cookie rules are correct, and the CDN in front never got the same rules.&lt;/p&gt;

&lt;p&gt;The rule of thumb: &lt;strong&gt;any cookie that bypasses an inner cache has to bypass every cache in front of it too.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Purge from the inside out
&lt;/h2&gt;

&lt;p&gt;After publishing or editing, clear caches in the same order a request travels back out:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Plugin page cache.&lt;/li&gt;
&lt;li&gt;Server cache (host dashboard, or LiteSpeed's purge).&lt;/li&gt;
&lt;li&gt;CDN.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you purge the CDN first, the next visitor makes the CDN fetch from a server cache that still holds the old page, and the stale copy goes straight back to the edge.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;curl -sI&lt;/code&gt; a page twice while logged out. Write down every cache status header you see.&lt;/li&gt;
&lt;li&gt;View the source and look for plugin comments at the end.&lt;/li&gt;
&lt;li&gt;Repeat with a &lt;code&gt;wordpress_logged_in_x=1&lt;/code&gt; cookie and with a WooCommerce cart cookie. Every layer should step aside.&lt;/li&gt;
&lt;li&gt;Check &lt;code&gt;/cart/&lt;/code&gt; and &lt;code&gt;/checkout/&lt;/code&gt; for &lt;code&gt;private&lt;/code&gt; and the 1984 &lt;code&gt;Expires&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Make sure HTML has an explicit &lt;code&gt;Cache-Control&lt;/code&gt;, not just a server cache and heuristics.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I built a small WordPress plugin for steps 1 and 5. It grades your home page, latest post, a page, the WooCommerce shop and the theme stylesheet, and if no cache plugin is active it can apply sensible headers (with a revert button). If WP Rocket, W3 Total Cache, LiteSpeed Cache or WP Super Cache is active, it only reports. It's in the WordPress.org review queue, so for now it's a zip on &lt;a href="https://nocache.dev/wordpress" rel="noopener noreferrer"&gt;nocache.dev/wordpress&lt;/a&gt;. To check from a terminal: &lt;code&gt;npx nocache-dev check https://your-site.com&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>webdev</category>
      <category>performance</category>
      <category>php</category>
    </item>
    <item>
      <title>What Next.js actually sends in Cache-Control (and what Vercel changes)</title>
      <dc:creator>nocache.dev</dc:creator>
      <pubDate>Tue, 27 Oct 2026 13:18:23 +0000</pubDate>
      <link>https://dev.to/nocachedev/what-nextjs-actually-sends-in-cache-control-and-what-vercel-changes-3mnj</link>
      <guid>https://dev.to/nocachedev/what-nextjs-actually-sends-in-cache-control-and-what-vercel-changes-3mnj</guid>
      <description>&lt;p&gt;Ask three people what &lt;code&gt;Cache-Control&lt;/code&gt; a Next.js page sends and you'll get three answers. All three can be right. It depends on how the page is rendered, which router you use, and whether Vercel sits in front of it.&lt;/p&gt;

&lt;p&gt;I ran a live check on nextjs.org today to have something concrete to look at. Here's how to read what came back, and what changes when you host Next.js yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The baseline: two requests to nextjs.org
&lt;/h2&gt;

&lt;p&gt;The homepage HTML (trimmed to the caching-related headers):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;cache-control: public, max-age=0, must-revalidate
x-vercel-cache: HIT
age: 572
x-nextjs-prerender: 1
x-nextjs-stale-time: 300
vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A JavaScript chunk from the same page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;cache-control: public,max-age=31536000,immutable
x-vercel-cache: HIT
age: 48724
etag: W/"_next/static/immutable/chunks/3-f__u1tgqe9w.js"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These two responses cover most of what you need to know.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. &lt;code&gt;/_next/static&lt;/code&gt; is solved for you
&lt;/h2&gt;

&lt;p&gt;Everything under &lt;code&gt;/_next/static&lt;/code&gt; has a content hash in its filename, and Next.js serves it with a one-year &lt;code&gt;immutable&lt;/code&gt; cache. You don't need to configure anything, and you shouldn't override it. If you put your own &lt;code&gt;Cache-Control&lt;/code&gt; on that path through a reverse proxy, you can only make things worse.&lt;/p&gt;

&lt;p&gt;The catch is everything that isn't under &lt;code&gt;/_next/static&lt;/code&gt;. Files in &lt;code&gt;/public&lt;/code&gt; (favicons, fonts you dropped in there, OG images, &lt;code&gt;robots.txt&lt;/code&gt;) are served without a hash in the name, so Next.js can't give them a long cache. Self-hosted, they get &lt;code&gt;public, max-age=0&lt;/code&gt; by default, which means every visit revalidates or re-downloads them. If you own those files and they rarely change, a day of cache plus &lt;code&gt;stale-while-revalidate&lt;/code&gt; is a reasonable middle ground. If they must change instantly, rename them.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Pages: the rendering mode decides the header
&lt;/h2&gt;

&lt;p&gt;For HTML, Next.js derives &lt;code&gt;Cache-Control&lt;/code&gt; from how the route is rendered. Roughly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Static (prerendered at build):&lt;/strong&gt; cached by shared caches until the next deploy. Self-hosted with &lt;code&gt;next start&lt;/code&gt;, you'll see &lt;code&gt;s-maxage&lt;/code&gt; with a very long value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ISR (&lt;code&gt;revalidate: 60&lt;/code&gt; or &lt;code&gt;export const revalidate = 60&lt;/code&gt;):&lt;/strong&gt; &lt;code&gt;s-maxage=60, stale-while-revalidate=...&lt;/code&gt;. The SWR window comes from &lt;code&gt;expireTime&lt;/code&gt; in &lt;code&gt;next.config&lt;/code&gt; (one year by default in recent versions).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic (cookies, headers, &lt;code&gt;no-store&lt;/code&gt; fetches, search params read at request time):&lt;/strong&gt; &lt;code&gt;private, no-cache, no-store, max-age=0, must-revalidate&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notice that the static and ISR headers use &lt;code&gt;s-maxage&lt;/code&gt;, not &lt;code&gt;max-age&lt;/code&gt;. They're instructions for a CDN, not for browsers. That's deliberate: the edge holds the page and refreshes it in the background, while browsers always come back to the edge and pick up new content after the revalidation window.&lt;/p&gt;

&lt;p&gt;The dynamic case is the one to watch. Reading &lt;code&gt;cookies()&lt;/code&gt; in a layout, even only to show a "Sign in" link, makes every page under that layout dynamic. Every page then gets &lt;code&gt;no-store&lt;/code&gt;, and your CDN can't cache any of them. If your build output shows routes you expected to be static marked as dynamic (&lt;code&gt;ƒ&lt;/code&gt;), this is usually why. Move the cookie read into a small client component or a route that actually needs it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. What Vercel changes
&lt;/h2&gt;

&lt;p&gt;Look again at the nextjs.org HTML: &lt;code&gt;public, max-age=0, must-revalidate&lt;/code&gt;, and still &lt;code&gt;x-vercel-cache: HIT&lt;/code&gt; with an &lt;code&gt;age&lt;/code&gt; of almost ten minutes.&lt;/p&gt;

&lt;p&gt;That's Vercel's edge doing its own thing. It reads the &lt;code&gt;s-maxage&lt;/code&gt; and &lt;code&gt;stale-while-revalidate&lt;/code&gt; your app sends, uses them for its own cache, and then removes them from the response that goes to the browser, replacing them with &lt;code&gt;max-age=0, must-revalidate&lt;/code&gt;. The browser is told to check back every time. Vercel does this so a purge or deploy takes effect immediately for everyone.&lt;/p&gt;

&lt;p&gt;Two consequences:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;On Vercel, the header you see in DevTools is not the header your app sent.&lt;/strong&gt; Debugging ISR by reading the browser-facing &lt;code&gt;Cache-Control&lt;/code&gt; will mislead you. &lt;code&gt;x-vercel-cache&lt;/code&gt; (&lt;code&gt;HIT&lt;/code&gt;, &lt;code&gt;MISS&lt;/code&gt;, &lt;code&gt;STALE&lt;/code&gt;, &lt;code&gt;PRERENDER&lt;/code&gt;, &lt;code&gt;REVALIDATED&lt;/code&gt;) and &lt;code&gt;age&lt;/code&gt; are what tell you how the edge is behaving.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosted, nobody strips anything.&lt;/strong&gt; If you put Next.js behind Cloudflare, Fastly or nginx, your CDN sees the raw &lt;code&gt;s-maxage=60, stale-while-revalidate=31535940&lt;/code&gt;. Whether it honours SWR depends on the CDN, and Cloudflare by default won't cache HTML at all without a Cache Rule. So "ISR works on Vercel" does not carry over automatically.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One more detail from the nextjs.org check: there's no &lt;code&gt;ETag&lt;/code&gt; on the HTML. With &lt;code&gt;max-age=0&lt;/code&gt; and no validator, every browser navigation is a full download from the edge. It's fast because the edge is close, but it's not free. Self-hosted Next.js sends an &lt;code&gt;ETag&lt;/code&gt; for pages by default (&lt;code&gt;generateEtags: true&lt;/code&gt;), so this is mainly something you notice on Vercel.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. &lt;code&gt;headers()&lt;/code&gt; in next.config won't override page caching
&lt;/h2&gt;

&lt;p&gt;A common attempt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next.config.mjs&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/:path*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Cache-Control&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;public, max-age=3600&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;}];&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works for &lt;code&gt;/public&lt;/code&gt; files and API routes. For pages and &lt;code&gt;/_next/static&lt;/code&gt;, the Next.js docs are explicit: in production, the framework sets &lt;code&gt;Cache-Control&lt;/code&gt; for those itself, and your value gets overwritten. If you want a different cache lifetime for a page, change its &lt;code&gt;revalidate&lt;/code&gt;. Don't fight the header.&lt;/p&gt;

&lt;p&gt;Route handlers (&lt;code&gt;app/api/.../route.ts&lt;/code&gt;) and the Pages Router &lt;code&gt;getServerSideProps&lt;/code&gt; are the exceptions. There you set the header on the response yourself, and Next.js leaves it alone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Cache-Control&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;public, s-maxage=300, stale-while-revalidate=60&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. The &lt;code&gt;Vary&lt;/code&gt; header on App Router pages
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;vary: rsc, next-router-state-tree, next-router-prefetch, ...&lt;/code&gt; is there because the same URL can return two different bodies: full HTML on a normal navigation, or the React Server Components payload when the client router fetches it (with an &lt;code&gt;RSC: 1&lt;/code&gt; request header). A cache that ignores &lt;code&gt;Vary&lt;/code&gt; can hand the RSC payload to a browser expecting HTML, and the user sees a page of serialized text.&lt;/p&gt;

&lt;p&gt;Vercel handles this. If you self-host behind a CDN, check that it either respects &lt;code&gt;Vary&lt;/code&gt; on these request headers or that you exclude RSC requests from caching. Recent Next.js versions also append an &lt;code&gt;_rsc&lt;/code&gt; query parameter to those requests, which helps CDNs that key on the URL only.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist for your Next.js app
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Run &lt;code&gt;next build&lt;/code&gt; and look at the route table. Is anything dynamic that shouldn't be?&lt;/li&gt;
&lt;li&gt;Check one static page, one ISR page and one asset with &lt;code&gt;curl -sI&lt;/code&gt;. Self-hosted, do you see &lt;code&gt;s-maxage&lt;/code&gt;? On Vercel, look at &lt;code&gt;x-vercel-cache&lt;/code&gt; and &lt;code&gt;age&lt;/code&gt; instead.&lt;/li&gt;
&lt;li&gt;Check something in &lt;code&gt;/public&lt;/code&gt;. Is &lt;code&gt;max-age=0&lt;/code&gt; what you want?&lt;/li&gt;
&lt;li&gt;Behind your own CDN: is HTML actually cached (look for a HIT on the second request), and are RSC requests kept separate?&lt;/li&gt;
&lt;li&gt;Make sure nothing that reads cookies is shared through a &lt;code&gt;public&lt;/code&gt; response.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I keep doing step 2 for client projects, so it's built into nocache.dev. There's a small package for Next.js too: &lt;code&gt;withNocache()&lt;/code&gt; adds sensible caching for &lt;code&gt;/public&lt;/code&gt; files and opt-in CDN caching for HTML paths you list, and &lt;code&gt;checkUrls()&lt;/code&gt; runs a grade check after a deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;checkUrls&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;nocache-dev-next&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;checkUrls&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://example.com/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;minGrade&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;B&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or, without installing anything: &lt;code&gt;npx nocache-dev check https://your-site.com&lt;/code&gt;. If a rule looks wrong for Next.js specifically, tell me. The &lt;a href="https://nocache.dev/methodology" rel="noopener noreferrer"&gt;methodology&lt;/a&gt; lists every check.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>performance</category>
      <category>react</category>
    </item>
    <item>
      <title>Your CDN says HIT. Here's what that doesn't tell you</title>
      <dc:creator>nocache.dev</dc:creator>
      <pubDate>Tue, 20 Oct 2026 11:35:52 +0000</pubDate>
      <link>https://dev.to/nocachedev/your-cdn-says-hit-heres-what-that-doesnt-tell-you-26a6</link>
      <guid>https://dev.to/nocachedev/your-cdn-says-hit-heres-what-that-doesnt-tell-you-26a6</guid>
      <description>&lt;p&gt;&lt;code&gt;x-cache: HIT&lt;/code&gt; feels like a finished job. The edge answered, the origin rested, done.&lt;/p&gt;

&lt;p&gt;But a HIT only tells you one thing: this particular edge node had a copy and served it. It says nothing about whether browsers will reuse that copy, whether the edge should have stored it at all, or how many copies of the same page the edge is keeping. Most caching bugs I've seen live in exactly those gaps.&lt;/p&gt;

&lt;p&gt;Here are the ones worth checking.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Browsers and CDNs read different numbers
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Cache-Control: public, max-age=60, s-maxage=3600
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;max-age&lt;/code&gt; applies to every cache. &lt;code&gt;s-maxage&lt;/code&gt; applies only to shared caches (CDNs, proxies) and overrides &lt;code&gt;max-age&lt;/code&gt; for them. So the line above means: the CDN keeps it for an hour, the browser for a minute.&lt;/p&gt;

&lt;p&gt;That split is usually what you want for HTML: the edge absorbs traffic, while browsers come back soon enough to pick up a deploy. The pitfall is the reverse. A response with &lt;code&gt;max-age=0, s-maxage=300&lt;/code&gt; gets a HIT on every edge request and still makes every browser go to the network on every navigation. The CDN dashboard looks great. Your users' waterfalls do not.&lt;/p&gt;

&lt;p&gt;Static assets with hashed filenames are the opposite case. They never change under the same URL, so tell everyone to keep them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Cache-Control: public, max-age=31536000, immutable
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;immutable&lt;/code&gt; tells browsers not to revalidate even on a normal reload.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. &lt;code&gt;no-cache&lt;/code&gt; does not mean "don't cache"
&lt;/h2&gt;

&lt;p&gt;This one trips up nearly everyone once.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;no-cache&lt;/code&gt;: you may store it, but you must revalidate with the origin before every reuse.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;no-store&lt;/code&gt;: don't store it anywhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;no-cache&lt;/code&gt; combined with a validator (next section) is often the right answer for HTML that changes unpredictably: the browser keeps a copy, asks "still good?", and gets a tiny 304 back most of the time. &lt;code&gt;no-store&lt;/code&gt; throws all of that away and should be reserved for genuinely sensitive responses, like account pages or anything with personal data. It also tends to keep pages out of the browser's back/forward cache.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Revalidation is cheap, if you have a validator
&lt;/h2&gt;

&lt;p&gt;When a cached copy goes stale, the cache can ask the origin whether it changed instead of downloading it again. It needs a validator to do that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;ETag: "5f3a9c"
Last-Modified: Tue, 20 Oct 2026 09:12:00 GMT
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The next request carries &lt;code&gt;If-None-Match: "5f3a9c"&lt;/code&gt; (or &lt;code&gt;If-Modified-Since&lt;/code&gt;), and if nothing changed the origin answers &lt;code&gt;304 Not Modified&lt;/code&gt; with no body.&lt;/p&gt;

&lt;p&gt;No validator means no 304s, so every expired copy is a full download. nginx sends &lt;code&gt;ETag&lt;/code&gt; and &lt;code&gt;Last-Modified&lt;/code&gt; for static files by default. Express sends weak ETags for &lt;code&gt;res.send()&lt;/code&gt; bodies by default, but generating an ETag doesn't save you the work of rendering the page, only the bytes on the wire.&lt;/p&gt;

&lt;p&gt;One subtle failure: multiple origin servers generating different ETags for the same file (inode-based ETags on older Apache setups were the classic case). Each server disagrees with the others and revalidation quietly turns into full downloads.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. &lt;code&gt;Vary&lt;/code&gt; can split your cache into thousands of pieces
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Vary&lt;/code&gt; lists request headers that change the response, so the cache stores a separate copy per value.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Vary: Accept-Encoding&lt;/code&gt; is fine; there are only a few values. &lt;code&gt;Vary: Cookie&lt;/code&gt; is where things go wrong. Every visitor's cookie header is different (analytics IDs, consent flags, A/B buckets), so every visitor gets their own cache entry and the hit ratio collapses. Some CDNs don't cache responses with &lt;code&gt;Vary: Cookie&lt;/code&gt; at all.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Vary: User-Agent&lt;/code&gt; has the same problem with a smaller blast radius. If you need per-device responses, normalize the value at the edge into a short header like &lt;code&gt;X-Device: mobile&lt;/code&gt; and vary on that instead.&lt;/p&gt;

&lt;p&gt;Also look at &lt;code&gt;Vary&lt;/code&gt; headers your framework adds. Next.js, for example, varies on its own router headers (&lt;code&gt;rsc&lt;/code&gt;, &lt;code&gt;next-router-state-tree&lt;/code&gt; and others). That's deliberate and fine, but it's worth knowing they're there before you add more.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. &lt;code&gt;Set-Cookie&lt;/code&gt; on a publicly cacheable response
&lt;/h2&gt;

&lt;p&gt;This is the one that can leak data rather than just waste it.&lt;/p&gt;

&lt;p&gt;If a response says &lt;code&gt;public&lt;/code&gt; and also sets a cookie, a shared cache is allowed to store it. If the cookie is stored with it, the next visitor may receive the previous visitor's cookie. Most CDNs protect you here by default (Cloudflare won't cache a response with &lt;code&gt;Set-Cookie&lt;/code&gt; unless told to), but "most" and "by default" are doing a lot of work, and a cache rule written for something else can turn that protection off.&lt;/p&gt;

&lt;p&gt;A real example. Here's part of the response vercel.com's homepage returned when I checked it today:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;cache-control: public, max-age=0, must-revalidate
x-vercel-cache: HIT
age: 490
set-cookie: _v-consent=â€¦; Path=/; Domain=.vercel.com; Secure; SameSite=lax
set-cookie: _v-visitor-id=â€¦; Path=/; Domain=.vercel.com; Secure; SameSite=lax
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To be fair to Vercel: these look like cookies added per request on the way out, after the cache lookup, so this is very likely not a leak. Their edge knows what it's doing. But the headers on their own say "any shared cache may store this, and here's a visitor ID." A corporate proxy or a second CDN layered in front doesn't know about Vercel's internals; it only reads the headers.&lt;/p&gt;

&lt;p&gt;The rule I follow: if a response sets a cookie, it is either &lt;code&gt;private&lt;/code&gt; or the cookie is moved somewhere else (a separate endpoint, or client-side). nginx:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/account/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Cache-Control&lt;/span&gt; &lt;span class="s"&gt;"private,&lt;/span&gt; &lt;span class="s"&gt;no-cache"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Express:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;setHeader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;setHeader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;setHeader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;set-cookie&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cache-Control&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;private, no-cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;setHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That middleware is blunt, but it means nobody on the team can accidentally ship a cookie on a public response.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. &lt;code&gt;stale-while-revalidate&lt;/code&gt; hides latency, not mistakes
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Cache-Control: public, s-maxage=300, stale-while-revalidate=86400
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For five minutes the copy is fresh. After that, for up to a day, the cache serves the stale copy immediately and refreshes it in the background. Users almost never wait on the origin.&lt;/p&gt;

&lt;p&gt;Two things to know. Support varies: browsers support it, and CDN support has improved a lot, but check yours. Some honor it only on certain plans or only from certain directives; others ignore it silently. And it extends the life of bad responses too. If an error page or a broken deploy gets cached, &lt;code&gt;stale-while-revalidate&lt;/code&gt; can keep serving it while the background refresh keeps failing. Pair it with &lt;code&gt;stale-if-error&lt;/code&gt; deliberately, and make sure 5xx responses are not cached with long lifetimes.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Reading the status header correctly
&lt;/h2&gt;

&lt;p&gt;Each CDN reports cache status differently:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;CDN&lt;/th&gt;
&lt;th&gt;Header&lt;/th&gt;
&lt;th&gt;Values to know&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cf-cache-status&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;HIT&lt;/code&gt;, &lt;code&gt;MISS&lt;/code&gt;, &lt;code&gt;EXPIRED&lt;/code&gt;, &lt;code&gt;REVALIDATED&lt;/code&gt;, &lt;code&gt;BYPASS&lt;/code&gt;, &lt;code&gt;DYNAMIC&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vercel&lt;/td&gt;
&lt;td&gt;&lt;code&gt;x-vercel-cache&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;HIT&lt;/code&gt;, &lt;code&gt;MISS&lt;/code&gt;, &lt;code&gt;STALE&lt;/code&gt;, &lt;code&gt;PRERENDER&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fastly, CloudFront, Varnish&lt;/td&gt;
&lt;td&gt;&lt;code&gt;x-cache&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;HIT&lt;/code&gt;, &lt;code&gt;MISS&lt;/code&gt; (CloudFront: &lt;code&gt;Hit from cloudfront&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Things that confuse people:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The first request to an edge location is usually a MISS.&lt;/strong&gt; Run it twice before concluding anything. You may hit a different node each time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DYNAMIC&lt;/code&gt; on Cloudflare&lt;/strong&gt; means Cloudflare didn't consider the response eligible for caching at all. By default it doesn't cache HTML, regardless of your headers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Age&lt;/code&gt;&lt;/strong&gt; tells you how long the copy has been in cache. An &lt;code&gt;Age&lt;/code&gt; close to your &lt;code&gt;s-maxage&lt;/code&gt; is normal; an &lt;code&gt;Age&lt;/code&gt; far beyond it means something is overriding your headers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A HIT for the CDN is not a HIT for the browser.&lt;/strong&gt; The browser decision is made from &lt;code&gt;max-age&lt;/code&gt;, validators and &lt;code&gt;Vary&lt;/code&gt;, and none of it shows up in the CDN's status header.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A quick checklist
&lt;/h2&gt;

&lt;p&gt;For each important URL (homepage, a typical HTML page, a JS bundle, an API response):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Does &lt;code&gt;max-age&lt;/code&gt; vs &lt;code&gt;s-maxage&lt;/code&gt; match what you want for browsers vs the edge?&lt;/li&gt;
&lt;li&gt;Is &lt;code&gt;no-store&lt;/code&gt; used only where data is sensitive?&lt;/li&gt;
&lt;li&gt;Is there an &lt;code&gt;ETag&lt;/code&gt; or &lt;code&gt;Last-Modified&lt;/code&gt;, and does the origin answer 304?&lt;/li&gt;
&lt;li&gt;Is &lt;code&gt;Vary&lt;/code&gt; limited to headers with few values?&lt;/li&gt;
&lt;li&gt;Does any response with &lt;code&gt;Set-Cookie&lt;/code&gt; say &lt;code&gt;public&lt;/code&gt; or have a long &lt;code&gt;s-maxage&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;Do errors get cached?&lt;/li&gt;
&lt;li&gt;Run it twice: what does the CDN status header say the second time?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;curl -sI https://example.com&lt;/code&gt; will answer most of these. I got tired of doing it by hand, so I built &lt;a href="https://nocache.dev" rel="noopener noreferrer"&gt;nocache.dev&lt;/a&gt;: a free checker that makes the request, identifies the CDN, flags the issues above and suggests a fix for your server. The vercel.com example came from it. There's also a CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx nocache-dev check https://example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add &lt;code&gt;--min-grade B&lt;/code&gt; and it exits non-zero below that grade, so you can run it in CI. If you find a rule you think is wrong, the &lt;a href="https://nocache.dev/methodology" rel="noopener noreferrer"&gt;methodology page&lt;/a&gt; lists all of them, and I'd like to hear about it.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>performance</category>
      <category>http</category>
      <category>cdn</category>
    </item>
  </channel>
</rss>
