{"id":"rmdwXW0iQlGFXKH78QH1","title":"Why Cloudflare Refuses to Cache Your JS (Set-Cookie on Static Files)","slug":"cloudflare-set-cookie-bypasses-edge-cache","summary":"Your hashed JS and CSS can ship perfect Cache-Control headers and still get cf-cache-status BYPASS at Cloudflare — because Set-Cookie on static GETs disables edge cache. Here is how I found it, fixed origin WAF cookies, and verified HIT.","imageUrl":"https://briancrabtree.me/images/journal-cloudflare-set-cookie-bypasses-edge-cache.webp","category":"Performance","date":"2026-06-05T18:00:00.000Z","featured":false,"likes":2,"author":"Brian Crabtree","content":"<h2>Perfect headers, zero edge cache</h2>\n\n<p>I fingerprint every Vite bundle and stylesheet with content hashes. Origin nginx serves them with <code>Cache-Control: public, max-age=31536000, immutable</code>. On paper that is the textbook setup for a CDN: long-lived assets, cache busting baked into filenames, HTML stays fresh with <code>no-cache, must-revalidate</code>. And yet Cloudflare kept answering every request to <code>/assets/index-*.css</code> with <code>cf-cache-status: BYPASS</code>. Repeat visitors paid full origin round trips on bytes that should have lived at the edge for a year.</p>\n\n<p>That mismatch is worse than slow lab scores. Field Core Web Vitals aggregate real repeat loads. If your edge never caches JavaScript and CSS, every return visit re-downloads the main thread budget you already paid to optimize. I spent an afternoon chasing dashboard toggles before I read response headers like a crawler would.</p>\n\n<figure>\n  <img src=\"/images/journal-inline-cloudflare-set-cookie-bypass.webp\" alt=\"Split diagram: static GET with Set-Cookie header shows cf-cache-status BYPASS; same asset without cookie shows HIT at Cloudflare edge\" width=\"1200\" height=\"675\" loading=\"lazy\" />\n  <figcaption>Set-Cookie on a cacheable GET forces BYPASS — even when Cache-Control says immutable.</figcaption>\n</figure>\n\n<h2>Cloudflare will not cache Set-Cookie responses</h2>\n\n<p>Cloudflare’s caching model is conservative about personalization. If the origin attaches <code>Set-Cookie</code> on a response, the edge treats that response as potentially user-specific and refuses to store it in the shared cache — even when the URL is a hashed static file that will never vary per visitor. That is not a bug. It is the same rule that protects logged-in HTML pages from leaking session state to the next visitor on a shared cache node.</p>\n\n<p>The trap is applying session machinery globally. Hosting panels, WAF modules, and legacy middleware often drop a session cookie on <em>every</em> response because it was easier to write one hook than to branch on file type. Your immutable asset headers become decorative. Cloudflare sees cookie plus long TTL, chooses cookie, and BYPASS wins.</p>\n\n<p>HTML document routes should stay dynamic — <code>cf-cache-status: DYNAMIC</code> with short or revalidate directives is correct for prerender shells and SPA fallbacks. The failure mode is polluting static extensions with the same cookie HTML needs for WAF or admin tooling.</p>\n\n<h2>How I diagnosed it</h2>\n\n<p>Stop guessing from the Cloudflare dashboard cache analytics. Pull headers twice on the same asset URL with GET — not HEAD. Some stacks set cookies on HEAD probes but behave differently on GET; browsers use GET. I run the same check through Cloudflare and directly to origin when debugging.</p>\n\n<pre><code># First GET — note cf-cache-status and Set-Cookie\ncurl -sD - -o /dev/null \"https://briancrabtree.me/assets/index-*.css\" | \\\n  rg -i '^(HTTP/|cf-cache-status|set-cookie|cache-control)'\n\n# Second GET — if fixed, cf-cache-status should be HIT and age should climb</code></pre>\n\n<p>Before the fix, every pass returned BYPASS and a <code>server_name_session</code> cookie on hashed CSS and JS. Origin already sent the right cache policy; the cookie was the disqualifier. Once I saw that pattern, the fix stopped being “purge harder” and became “stop sending cookies on static GETs.”</p>\n\n<h2>Root cause on this origin</h2>\n\n<p>On this VPS, aaPanel’s BT WAF injected a session cookie from Lua on every response — including fingerprinted files under <code>/assets/</code> and images under <code>/images/</code>. Nginx location blocks already set aggressive caching for those paths. The WAF hook ran afterward and undid the performance work silently.</p>\n\n<p>That explains why PageSpeed lab runs could still look acceptable while field data lagged: Lighthouse often cold-loads once. Real users navigating between journal posts and the homepage hit the same JS chunks repeatedly. Without edge HITs, Time to First Byte on assets stays tied to origin latency even when filenames never change between deploys.</p>\n\n<p>Purge workflows alone cannot fix BYPASS. See <a href=\"/journal/cloudflare-cache-stale-assets-after-deploy/\">Cloudflare Cache and Stale Assets After Deploy</a> for when targeted purges matter after you ship new hashes — but purging a URL that is permanently ineligible for cache just repeats the origin fetch.</p>\n\n<h2>The fix: cookies on HTML only</h2>\n\n<p>I patched the WAF Lua entry so GET requests whose URI ends in a static extension — <code>js</code>, <code>css</code>, <code>mjs</code>, fonts, images, <code>webp</code>, and the rest — skip the session cookie entirely. Document routes, admin paths, and HTML shells still receive the cookie the WAF expects. No Cloudflare dashboard rule changes were required; this was origin behavior blocking edge cache eligibility.</p>\n\n<p>After nginx reload, the first GET on a hashed asset may still show MISS or EXPIRED while the edge populates. The second GET on the same URL should flip to HIT with an increasing <code>age:</code> header and no <code>Set-Cookie</code> line. That single header pair is the proof — not a green gauge in a performance tool.</p>\n\n<h2>Verify without breaking PSI or SEO</h2>\n\n<p>This change touched infrastructure only. The React app, prerender shells, robots policy, and homepage critical path stayed untouched. I ran <code>npm run verify:edge</code> to confirm PageSpeed still sees clean HTML without bot-fight scripts injected at the edge. AI crawl bots still get HTTP 200 on journal URLs with correct per-slug canonicals — unrelated to asset caching but non-negotiable on this site.</p>\n\n<p>After verification I ran a prefix purge on <code>/assets/</code> and <code>/images/</code> so stale BYPASS responses did not linger at individual pops. New deploys still rely on filename hashes for busting; HTML stays on <code>no-cache</code> so journal shells update immediately after <code>deploy.sh prod</code>. The full stack — Vite hashes, nginx headers, Cloudflare orange cloud, WAF exceptions for PSI and answer bots — is documented in <a href=\"/journal/how-this-site-is-built/\">How This Site Is Built (Reference Stack)</a>.</p>\n\n<h2>What to check on your site</h2>\n\n<p>If Cloudflare analytics show low cache hit ratio on <code>/assets/*</code> despite immutable filenames, curl your largest JS and CSS twice. Look for BYPASS plus Set-Cookie before you enable Rocket Loader or crank browser TTL in the dashboard. Fix origin response headers first. Edge cache eligibility is binary: cookie on a cacheable GET means BYPASS until you remove it.</p>\n\n<p>Field Core Web Vitals will not jump overnight — CrUX needs traffic — but repeat-load performance and origin egress improve immediately when hashed assets start HITing. That is the kind of boring infrastructure win that compounds. If your stack has the same symptom and you want another pair of eyes on headers, <a href=\"/contact?ref=journal\">send your asset URL and cf-cache-status output</a> — not a Lighthouse screenshot, the raw headers.</p>","tags":["cloudflare","caching","core-web-vitals","nginx"],"views":11}