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.

  • namehttp-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.

~/.claude/skills/http-caching/SKILL.md

--- name: http-caching description: Designs and audits HTTP caching for browsers and CDNs — Cache-Control directives per resource type, fingerprinted immutable assets, ETag… license: MIT ---

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-age short and rely on revalidation.
  • Anything that depends on cookies, auth headers, or the user must be private or no-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-age for 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)

ResponseCache-ControlNotes
Fingerprinted assets (app.3f9a1c.js, hashed fonts/images)public, max-age=31536000, immutableBundlers 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 CDNMust change on deploy; send ETag
Unversioned assets (/logo.png, /robots.txt)public, max-age=3600 (or a day) + ETagUsers may see old versions until expiry
Public API, same for everyonepublic, max-age=0, s-maxage=30, stale-while-revalidate=300CDN absorbs load; browsers revalidate
User-specific API / pagesprivate, no-cache (or private, max-age=60 if staleness is fine)Never public; never s-maxage
Secrets, tokens, banking, PII formsno-storeAlso prevents bfcache in some browsers; use only where needed
Service worker scriptno-cacheBrowsers cap SW script freshness anyway

Directive meanings that get confused:

DirectiveActually means
no-cacheMay store, must revalidate before every use (304s are cheap)
no-storeMust not store at all
max-age=NFresh for N seconds in any cache
s-maxage=NOverrides max-age for shared caches (CDN, proxies)
privateOnly the browser may store it
immutableDo not revalidate while fresh, even on reload
stale-while-revalidate=NServe stale up to N s past expiry while refetching in background
stale-if-error=NServe stale up to N s if the origin errors (CDN support varies)
must-revalidateOnce 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):

CDNHeader / mechanism
Any targeted CDN (RFC 9213)CDN-Cache-Control: max-age=600
CloudflareCloudflare-CDN-Cache-Control, Cache Rules; HTML is not cached by default without a rule
FastlySurrogate-Control: max-age=600, Surrogate-Key for tag purges
VercelCDN-Cache-Control / Vercel-CDN-Cache-Control
CloudFrontCache 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-Modified works as a fallback; second-level granularity.
  • A 304 response must repeat Cache-Control, ETag, and Vary as they would be on 200.

6. Cache keys, Vary, and busting

  • Vary: Accept-Encoding is normal (CDNs normalize it). Vary: Cookie or Vary: User-Agent fragments 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=123 query 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-age on 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.
  • Expires is ignored when Cache-Control: max-age is present; do not rely on it.
  • Heuristic caching: a response with Last-Modified and no Cache-Control may 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 public or s-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-store on HTML can disqualify pages from the back/forward cache, hurting navigation speed.

Verification

  1. python3 scripts/check_cache_headers.py <urls> exits 0 for HTML, a hashed asset, an unversioned asset, and an API endpoint.
  2. Second request for a static asset shows a CDN hit and a growing Age.
  3. Conditional request returns 304 for HTML.
  4. Logged-in responses carry private or no-store; request one through the CDN twice with different users’ cookies and confirm no cross-user content.
  5. 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 177 lines · 7.8 KB
#!/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.

esc