HTTP Caching skill for Claude Code
Year-long assets, always-fresh HTML, zero leaks. Claude writes a caching policy per response class instead of tweaking headers file by file: fingerprinted assets cached for a year as immutable, HTML revalidated on every load, shared APIs held at the CDN with stale-while-revalidate, and anything personalized marked private. A bundled checker audits live URLs, confirms 304 revalidation and flags public responses that set cookies.
- name
http-caching - size163 lines · ≈2,000 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Designs and audits HTTP caching for browsers and CDNs — Cache-Control directives per resource type, fingerprinted immutable assets, ETag and Last-Modified revalidation, s-maxage and CDN-specific headers, stale-while-revalidate and stale-if-error, Vary, cache keys, purging, and cache busting — with a bundled header checker. Use when setting cache headers, configuring a CDN such as Cloudflare, Fastly, CloudFront, or Vercel, users see stale content after deploys, cache hit ratio is low, or personalized data might leak through a shared cache. Try asking
- Users keep seeing the old site after deploys — fix our cache headers
- Set up CDN caching for our product API without leaking logged-in data
- Audit the caching headers on these five URLs and tell me what is wrong
Use it when
- Setting Cache-Control for a new app or CDN
- Stale content persists after deploys
- CDN hit ratio is low or origin load is high
- Checking that personalized responses never hit a shared cache
Skip it when
- Caching application data in Redis rather than HTTP responses — use redis-patterns
- Hardening CSP, HSTS and other security headers — use security-headers
How to check the result
scripts/check_cache_headers.py exits 0 for each response class, repeat requests show CDN hits with growing Age, HTML revalidates with 304, and authenticated responses are private.
The common mistake it prevents
Putting a long max-age on HTML or unversioned URLs, which pins users to old builds in browser caches that no CDN purge can reach.
Done well means: every response class has a deliberate policy, fingerprinted assets are cached for a year and never revalidated, HTML is always fresh after a deploy, shared caches never store personalized responses, revalidation returns 304, and the CDN hit ratio for static assets is above ~95%.
Ground rules
- Decide policy per response class (HTML, fingerprinted asset, unversioned asset, public API, private API), not per file ad hoc.
- Long caching requires a versioned URL. If the URL does not change when the content
changes, keep
max-ageshort and rely on revalidation. - Anything that depends on cookies, auth headers, or the user must be
privateorno-store. A leaked cached account page is a security incident, not a perf bug. - Browser cache and CDN cache are separate layers with separate controls:
max-agefor both,s-maxage/ CDN-specific headers for shared caches only. - Purging production CDN caches broadly (purge everything) can stampede the origin; confirm with the user before running it.
Policy table (start here)
| Response | Cache-Control | Notes |
|---|---|---|
Fingerprinted assets (app.3f9a1c.js, hashed fonts/images) | public, max-age=31536000, immutable | Bundlers generate the hash; never reuse a name for new content |
| HTML documents (SSR/static) | no-cache (browser revalidates every time) or public, max-age=0, s-maxage=60, stale-while-revalidate=600 at the CDN | Must change on deploy; send ETag |
Unversioned assets (/logo.png, /robots.txt) | public, max-age=3600 (or a day) + ETag | Users may see old versions until expiry |
| Public API, same for everyone | public, max-age=0, s-maxage=30, stale-while-revalidate=300 | CDN absorbs load; browsers revalidate |
| User-specific API / pages | private, no-cache (or private, max-age=60 if staleness is fine) | Never public; never s-maxage |
| Secrets, tokens, banking, PII forms | no-store | Also prevents bfcache in some browsers; use only where needed |
| Service worker script | no-cache | Browsers cap SW script freshness anyway |
Directive meanings that get confused:
| Directive | Actually means |
|---|---|
no-cache | May store, must revalidate before every use (304s are cheap) |
no-store | Must not store at all |
max-age=N | Fresh for N seconds in any cache |
s-maxage=N | Overrides max-age for shared caches (CDN, proxies) |
private | Only the browser may store it |
immutable | Do not revalidate while fresh, even on reload |
stale-while-revalidate=N | Serve stale up to N s past expiry while refetching in background |
stale-if-error=N | Serve stale up to N s if the origin errors (CDN support varies) |
must-revalidate | Once stale, never serve without successful revalidation |
Workflow
- [ ] 1. Inventory response classes and current headers (run the checker on samples)
- [ ] 2. Check the build: are asset filenames content-hashed?
- [ ] 3. Write the policy table for this app; identify personalized responses
- [ ] 4. Implement headers at the origin (framework/server config), then CDN overrides
- [ ] 5. Verify revalidation returns 304 and CDN hits on repeat requests
- [ ] 6. Plan invalidation: deploy flow, purge-by-tag/URL, never-purge for hashed assets
1. Inspect current behavior
python3 scripts/check_cache_headers.py https://example.com/ https://example.com/assets/app.3f9a1c.js https://example.com/api/products
curl -sI https://example.com/assets/app.js | grep -iE "cache-control|etag|age|vary|x-cache|cf-cache-status"
The script prints caching headers and CDN status headers, sends a conditional request
to confirm a 304, guesses the resource class (override with --kind html|hashed|asset|api),
and flags errors such as short-lived fingerprinted assets, long-lived HTML, or public
responses that set cookies. It exits 1 on errors. Run it twice to see CDN HIT and a
non-zero Age on the second request.
4. Implementation snippets
Express:
app.use("/assets", express.static("dist/assets", { immutable: true, maxAge: "1y" }));
app.use((req, res) => { res.set("Cache-Control", "no-cache"); res.sendFile(indexHtml); }); // SPA fallback; ETag on by default
Nginx:
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
location / {
add_header Cache-Control "no-cache" always;
etag on;
}
add_header in a nested block replaces, not merges, headers from outer blocks; repeat
security headers where needed.
Next.js: hashed /_next/static/* assets already get immutable caching; for route
handlers and pages set headers via headers() in next.config or the response object,
and use the framework’s revalidation (ISR, revalidate, tags) rather than fighting it.
CDN-only controls (keep browser caching short while the CDN holds longer):
| CDN | Header / mechanism |
|---|---|
| Any targeted CDN (RFC 9213) | CDN-Cache-Control: max-age=600 |
| Cloudflare | Cloudflare-CDN-Cache-Control, Cache Rules; HTML is not cached by default without a rule |
| Fastly | Surrogate-Control: max-age=600, Surrogate-Key for tag purges |
| Vercel | CDN-Cache-Control / Vercel-CDN-Cache-Control |
| CloudFront | Cache policies (TTL min/default/max, cache key headers/cookies/query) |
Check the specific CDN’s docs for whether it honors stale-while-revalidate and
stale-if-error, and whether it caches responses that carry Set-Cookie.
5. Validators and revalidation
- Strong ETag from content hash; weak (
W/"...") when the representation may differ byte-wise (compression). Many frameworks generate ETags automatically. - Multi-server origins must produce the same ETag for the same content (no inode-based ETags).
Last-Modifiedworks as a fallback; second-level granularity.- A 304 response must repeat
Cache-Control,ETag, andVaryas they would be on 200.
6. Cache keys, Vary, and busting
Vary: Accept-Encodingis normal (CDNs normalize it).Vary: CookieorVary: User-Agentfragments the cache to near-zero hit rate; normalize at the CDN instead (device class header, specific cookie).- Exclude tracking query parameters (
utm_*,fbclid,gclid) from the CDN cache key. - Cache busting: content hash in the filename (
app.3f9a1c.js) — not?v=123query strings (some proxies ignore query strings; easy to forget to bump). - Deploy order: upload new hashed assets first, then switch HTML. Keep old assets for a few days so open tabs with old HTML still load their chunks.
- Purge by surrogate key / tag for content changes (product updated), not purge-all.
Gotchas
max-ageon HTML is the classic “users stuck on the old version” bug; it cannot be recalled from browser caches. Even 1 hour hurts during an incident.Expiresis ignored whenCache-Control: max-ageis present; do not rely on it.- Heuristic caching: a response with
Last-Modifiedand noCache-Controlmay be cached for ~10% of its age. Always send an explicit policy. - Authorization header responses are not stored by shared caches unless explicitly
allowed with
publicors-maxage— adding those to an authenticated API leaks data. - CDN caching 404s and 5xx: set short TTLs for errors so a transient failure is not pinned for an hour.
- Service workers add another cache layer; a stale SW can serve old assets regardless of HTTP headers.
no-storeon HTML can disqualify pages from the back/forward cache, hurting navigation speed.
Verification
python3 scripts/check_cache_headers.py <urls>exits 0 for HTML, a hashed asset, an unversioned asset, and an API endpoint.- Second request for a static asset shows a CDN hit and a growing
Age. - Conditional request returns 304 for HTML.
- Logged-in responses carry
privateorno-store; request one through the CDN twice with different users’ cookies and confirm no cross-user content. - After a test deploy, a hard-refresh-free reload of HTML picks up the new version.
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/check_cache_headers.py
#!/usr/bin/env python3
"""Inspect HTTP caching headers for one or more URLs and test revalidation.
For each URL: fetch it, print caching-relevant headers (Cache-Control, ETag,
Last-Modified, Age, Vary, Expires, CDN cache status headers), send a conditional
request with If-None-Match / If-Modified-Since to check for 304, and flag common
mistakes based on what kind of resource it looks like.
Usage:
python3 check_cache_headers.py URL [URL ...] [--kind auto|html|hashed|asset|api] [--header "Name: value"]
Exit code: 0 no errors (warnings allowed), 1 at least one error, 2 usage/network failure.
Standard library only (Python 3.10+).
"""
from __future__ import annotations
import argparse
import re
import sys
import urllib.error
import urllib.request
CDN_HEADERS = ["cf-cache-status", "x-cache", "x-vercel-cache", "x-served-by", "x-cache-hits",
"cdn-cache-control", "cloudflare-cdn-cache-control", "surrogate-control", "x-amz-cf-pop",
"fastly-debug-digest", "akamai-cache-status", "x-nf-request-id"]
SHOW = ["cache-control", "expires", "etag", "last-modified", "age", "vary", "content-type",
"content-encoding", "set-cookie", "pragma"]
HASHED_RE = re.compile(r"[.\-_][0-9a-f]{8,}[.\-_]|[.\-_][A-Za-z0-9_-]{8,}\.(?:js|css|mjs|woff2?|png|jpe?g|webp|avif|svg)$")
ASSET_RE = re.compile(r"\.(?:js|mjs|css|woff2?|ttf|otf|png|jpe?g|gif|webp|avif|svg|ico|mp4|webm)(?:\?|$)", re.I)
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs): # report redirects instead of following silently
return None
def fetch(url: str, extra: dict[str, str]) -> tuple[int, dict[str, str], str]:
req = urllib.request.Request(url, headers={"User-Agent": "cache-check/1.0", "Accept-Encoding": "gzip, br", **extra})
opener = urllib.request.build_opener(NoRedirect)
try:
with opener.open(req, timeout=20) as resp:
headers = {k.lower(): v for k, v in resp.headers.items()}
return resp.status, headers, resp.geturl()
except urllib.error.HTTPError as err: # 304, 3xx, 4xx, 5xx land here
headers = {k.lower(): v for k, v in err.headers.items()} if err.headers else {}
return err.code, headers, url
def parse_cc(value: str) -> dict[str, str | bool]:
out: dict[str, str | bool] = {}
for part in value.split(","):
part = part.strip()
if not part:
continue
key, _, val = part.partition("=")
out[key.lower()] = val.strip('"') if val else True
return out
def guess_kind(url: str, headers: dict[str, str]) -> str:
path = url.split("?", 1)[0]
if HASHED_RE.search(path):
return "hashed"
if ASSET_RE.search(path):
return "asset"
ctype = headers.get("content-type", "")
if "text/html" in ctype:
return "html"
if "json" in ctype:
return "api"
return "asset"
def audit(url: str, kind: str, status: int, h: dict[str, str]) -> tuple[list[str], list[str]]:
errors, warns = [], []
cc = parse_cc(h.get("cache-control", ""))
max_age = int(cc["max-age"]) if isinstance(cc.get("max-age"), str) and cc["max-age"].isdigit() else None
s_maxage = int(cc["s-maxage"]) if isinstance(cc.get("s-maxage"), str) and cc["s-maxage"].isdigit() else None
has_validator = "etag" in h or "last-modified" in h
if 300 <= status < 400:
warns.append(f"redirect {status} -> {h.get('location')}; each hop costs a round trip")
return errors, warns
if status >= 400:
errors.append(f"status {status}: fix the response before auditing caching (check auth/URL)")
return errors, warns
if "cache-control" not in h:
(errors if kind in ("hashed", "html") else warns).append("no Cache-Control header: caches apply heuristic freshness")
if "public" in cc and "set-cookie" in h:
errors.append("Cache-Control public with Set-Cookie: shared caches may store and serve one user's cookie")
if h.get("vary", "").strip() == "*":
warns.append("Vary: * makes responses effectively uncacheable")
if "vary" in h and "user-agent" in h["vary"].lower():
warns.append("Vary: User-Agent fragments CDN caches into thousands of variants")
if kind == "hashed":
if max_age is None or max_age < 31536000:
errors.append("fingerprinted asset should use max-age=31536000 (1 year)")
if "immutable" not in cc:
warns.append("fingerprinted asset: add 'immutable' to skip revalidation on reload")
if "no-cache" in cc or "no-store" in cc:
errors.append("fingerprinted asset marked no-cache/no-store: wastes the fingerprint")
elif kind == "html":
if max_age and max_age > 300 and "must-revalidate" not in cc:
errors.append(f"HTML cached for {max_age}s in browsers: deploys will not reach users until it expires")
if not has_validator and "no-store" not in cc:
warns.append("HTML without ETag/Last-Modified: revalidation downloads the full body")
elif kind == "asset":
if max_age is not None and max_age > 86400:
warns.append("long max-age on a non-fingerprinted URL: users keep stale versions after deploys")
if not has_validator:
warns.append("no ETag/Last-Modified: cannot revalidate cheaply")
elif kind == "api":
if "private" not in cc and "no-store" not in cc and s_maxage is None and max_age:
warns.append("API response cacheable by shared caches; confirm it is not user-specific (use private)")
if "pragma" in h and "cache-control" in h:
warns.append("Pragma is obsolete for responses; Cache-Control wins")
return errors, warns
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("urls", nargs="+")
ap.add_argument("--kind", choices=["auto", "html", "hashed", "asset", "api"], default="auto")
ap.add_argument("--header", action="append", default=[], help='extra request header, e.g. "Cookie: a=b"')
args = ap.parse_args()
extra = {}
for raw in args.header:
name, sep, value = raw.partition(":")
if not sep:
print(f"bad --header {raw!r}", file=sys.stderr)
return 2
extra[name.strip()] = value.strip()
total_errors = 0
for url in args.urls:
print(f"\n=== {url}")
try:
status, h, _ = fetch(url, extra)
except (urllib.error.URLError, TimeoutError, ValueError) as exc:
print(f" FETCH FAILED: {exc}")
return 2
kind = guess_kind(url, h) if args.kind == "auto" else args.kind
print(f" status {status} kind={kind}")
for name in SHOW + CDN_HEADERS:
if name in h:
print(f" {name}: {h[name]}")
cond = {}
if "etag" in h:
cond["If-None-Match"] = h["etag"]
if "last-modified" in h:
cond["If-Modified-Since"] = h["last-modified"]
if cond and status == 200:
try:
status2, h2, _ = fetch(url, {**extra, **cond})
verdict = "OK (304 Not Modified)" if status2 == 304 else f"got {status2}, expected 304"
print(f" revalidation: {verdict}")
if status2 != 304:
print(" note: a changing ETag per request (e.g. per-server or per-compression) defeats revalidation")
except (urllib.error.URLError, TimeoutError) as exc:
print(f" revalidation request failed: {exc}")
errors, warns = audit(url, kind, status, h)
for e in errors:
print(f" ERROR: {e}")
for w in warns:
print(f" WARN: {w}")
if not errors and not warns:
print(" no issues found")
total_errors += len(errors)
print(f"\n{total_errors} error(s)")
return 1 if total_errors else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.