<?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>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>
