HTTP Caching Headers skill for Codex

Cache assets for a year, HTML for zero seconds. Codex inventories response classes, reads the headers actually served with a bundled checker, and writes an explicit policy for each: immutable year-long caching for hashed assets, revalidated HTML, short shared caching for public APIs, private or no-store for personal data. It sets headers at the right layer, proves 304 revalidation works, and checks that nothing with a session cookie is cached publicly.

  • Performance
  • $http-caching
  • 151 lines
  • 3 files
  • ~1,850 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- http-caching

Installs to ~/.agents/skills/http-caching/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $http-caching, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Designs and fixes HTTP caching headers - Cache-Control directives (max-age, s-maxage, no-cache, no-store, private, immutable, stale-while-revalidate), ETag and Last-Modified revalidation with 304s, Vary, and CDN-Cache-Control - so hashed assets cache for a year, HTML revalidates, and personal responses never land in a shared cache. Includes a header checker script. Use when repeat visits re-download everything, users see stale files after a deploy, a CDN serves one user's page to another, or someone asks what Cache-Control to set for a route or asset type.

Use it when

  • Repeat visits re-download assets that have not changed
  • Users see stale files or pages after a deploy
  • You need a Cache-Control policy for assets, HTML and APIs
  • There is a risk that personalised responses are cached publicly

Use something else when

  • The question is CDN cache keys, purging or edge rules: use cdn-caching
  • You need an application-level cache in Redis: use redis-caching

How to check the result

The bundled checker exits clean for one URL per response class, conditional requests return 304, and two different sessions never get a shared-cache hit on private routes.

The mistake it prevents

Giving an un-hashed file like /app.js a year-long max-age, which strands users on old code until they clear their cache.

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

--- name: http-caching description: Designs and fixes HTTP caching headers - Cache-Control directives (max-age, s-maxage, no-cache, no-store, private, immutable,… license: MIT ---

Done well means: every response class has an explicit, intentional policy; fingerprinted assets are fetched once per version; documents and APIs are never served stale beyond what the product accepts; and nothing personalised can be stored by a shared cache. Proven with real response headers, not by reading config.

Policy table (default recommendations)

ResponseCache-ControlValidatorWhy
Fingerprinted assets (app.3f9a2c1d.js, hashed CSS, fonts, images)public, max-age=31536000, immutableOptionalURL changes when content changes; immutable stops reload revalidation
Un-hashed static files (/logo.png, /robots.txt)public, max-age=3600 (or no-cache)ETag or Last-ModifiedCan change without URL change
HTML documentsno-cache (always revalidate), or max-age=0, s-maxage=60, stale-while-revalidate=300 with a CDNETagMust pick up new deploys; references hashed assets
Public API GET (catalogue, config)public, max-age=30, stale-while-revalidate=60ETagShort freshness, cheap revalidation
Per-user API / pages (account, cart)private, no-cache (or max-age=0)ETagBrowser may reuse after revalidation; shared caches must not store
Secrets, tokens, banking, one-time datano-storeNoneNever written to any cache
Errors (5xx)Usually no-store; a CDN may use stale-if-error on the good response instead-Do not cache an outage

Key meanings people get wrong:

  • no-cache = store it, but revalidate before every use. It does not mean “do not cache”.
  • no-store = never store. Costs a full download every time and disables bfcache in most browsers for documents; use only where data is sensitive.
  • private = only the browser may store it. public allows shared caches even for authenticated requests, which is dangerous.
  • s-maxage overrides max-age for shared caches (CDN, proxies) only.
  • immutable only matters for max-age responses on user reload; it is safe only when the URL is content-hashed.
  • stale-while-revalidate=N serves stale for up to N seconds while refreshing in the background (supported by browsers and most CDNs).

Workflow

- [ ] 1. Inventory response classes: assets (hashed or not), HTML, APIs, downloads, errors
- [ ] 2. Capture current headers (script below, or curl -sI) for one URL per class
- [ ] 3. Find where headers are set: framework, server config, CDN rules, object storage metadata
- [ ] 4. Write the intended policy per class (table above, adjusted to the product)
- [ ] 5. Implement at the layer closest to the content; remove conflicting overrides
- [ ] 6. Verify headers, 304 revalidation and CDN behaviour; then deploy with approval

Check headers

python3 scripts/check_cache_headers.py https://example.com/ \
  https://example.com/assets/app-3f9a2c1d.js https://example.com/api/products --revalidate
python3 scripts/check_cache_headers.py --file urls.txt --header "Authorization: Bearer $TOKEN"

The script classifies each URL (hashed asset, static asset, HTML, other), prints Cache-Control, CDN-Cache-Control, ETag, Last-Modified, Vary, Age, flags policy problems (short-lived hashed assets, HTML cached for long, public with Set-Cookie, no validator), and with --revalidate sends If-None-Match / If-Modified-Since and expects 304. Exits 1 on any problem. Needs network to reach the URLs; local dev servers work offline.

Quick manual check:

curl -sI https://example.com/ | grep -iE 'cache-control|etag|last-modified|vary|age|cf-cache-status|x-cache'
curl -sI -H 'If-None-Match: "abc123"' https://example.com/   # expect 304 when unchanged

Setting headers

location /assets/ {            # Vite/webpack hashed output
  add_header Cache-Control "public, max-age=31536000, immutable" always;
}
location / {
  add_header Cache-Control "no-cache" always;
  try_files $uri /index.html;
}
// Express
app.use('/assets', express.static('dist/assets', { immutable: true, maxAge: '1y' }));
app.get('/api/products', (req, res) => {
  res.set('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');
  res.json(products); // Express adds a weak ETag and answers 304 automatically
});

Object storage (S3, R2, GCS): set Cache-Control metadata at upload time (aws s3 cp --cache-control "public, max-age=31536000, immutable"); it does not change when you edit server config.

Framework defaults to know: Next.js sets long-lived immutable headers on /_next/static and controls page caching itself ($nextjs-caching); Vite and webpack emit hashed file names but leave headers to the server or host.

ETags and revalidation

  • Strong ETag ("abc") means byte-identical; weak (W/"abc") means semantically equivalent. Compression layers often convert strong to weak; that is fine for If-None-Match.
  • Multi-server deployments must generate the same ETag for the same content (hash of content, not inode or mtime per machine), or every revalidation misses.
  • Dynamic responses: compute the ETag from a version or updated_at, and return 304 before doing the expensive render when it matches.

Vary and shared caches

  • Vary: Accept-Encoding is normal. Vary: Accept is needed when you negotiate formats (AVIF/WebP). Vary: Cookie or Vary: User-Agent fragment the cache so badly that most CDNs effectively stop caching; prefer separate URLs or private.
  • CDN-Cache-Control (and vendor variants like Surrogate-Control) set CDN-only policy without affecting browsers. Cache key design, purging and tag invalidation are covered in $cdn-caching.
  • A response with Set-Cookie must not be cached publicly. Most CDNs skip caching it by default; some can be configured to ignore it, which leaks sessions.

Hard rules

  • Long max-age only on content-hashed URLs. A year-long cache on /app.js without a hash strands users on old code with no fix except renaming the file.
  • Keep old hashed assets available for a while after deploy; cached HTML still references them (otherwise chunk-load errors).
  • Never mark responses that depend on the user’s session public.
  • Changing production CDN or server cache rules affects all users; show the diff and get the user’s confirmation before deploying, and plan a purge if a bad policy shipped.

Verification

  • Script exits 0 for one URL per class, with --revalidate passing on HTML and APIs.
  • Browser: DevTools Network on a reload shows hashed assets “(memory cache)” or “(disk cache)” and HTML as 304 or 200 with fresh content after a deploy.
  • CDN: second request shows a hit (Age > 0, cf-cache-status: HIT, x-cache: Hit) for public classes and never for private ones (test with two different logged-in sessions).

Report template

## HTTP caching policy: <site>
| Class | Example URL | Before | After | Verified |
|---|---|---|---|---|
| Hashed assets | /assets/app-3f9a.js | max-age=3600 | public, max-age=31536000, immutable | script ok |
| HTML | / | max-age=86400 | no-cache + ETag | 304 on revalidate |
| Account API | /api/me | public, max-age=60 | private, no-cache | CDN MISS for both sessions |
Set in: nginx.conf L40-52, server/routes/api.ts
Deploy + CDN purge: pending user approval

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "HTTP Caching Headers"
  short_description: "Cache assets for a year, HTML for zero seconds"
  default_prompt: "$http-caching Users keep getting old JavaScript after deploys, fix our Cache-Control headers"
scripts/check_cache_headers.py145 lines, 5.6 KB
#!/usr/bin/env python3
"""Check HTTP caching headers for a list of URLs and flag policy problems.

Standard library only. For each URL it fetches the response headers, classifies the
resource (hashed static asset, other static asset, HTML document, API/other), checks
Cache-Control against the expected policy, and optionally sends a conditional request
to prove revalidation returns 304.

Usage:
  python3 check_cache_headers.py URL [URL ...] [--revalidate] [--header 'Name: value']
  python3 check_cache_headers.py --file urls.txt --revalidate

Exit code: 0 when no problems, 1 when any URL has a problem, 2 on usage errors.
"""
from __future__ import annotations

import argparse
import re
import sys
import urllib.error
import urllib.request

HASHED = re.compile(r"[.\-_][0-9a-zA-Z_-]{8,}\.(?:js|mjs|css|woff2?|png|jpe?g|webp|avif|svg|gif|ico|wasm)$")
STATIC = re.compile(r"\.(?:js|mjs|css|woff2?|ttf|png|jpe?g|webp|avif|svg|gif|ico|wasm|mp4|webm)$")


def parse_cc(value: str) -> dict[str, str | None]:
    out: dict[str, str | None] = {}
    for part in value.split(","):
        part = part.strip()
        if not part:
            continue
        k, _, v = part.partition("=")
        out[k.strip().lower()] = v.strip().strip('"') or None
    return out


def fetch(url: str, headers: dict[str, str]):
    req = urllib.request.Request(url, headers={"User-Agent": "cache-check/1.0", **headers})
    try:
        with urllib.request.urlopen(req, timeout=15) as r:
            r.read(1024)
            return r.status, r.headers
    except urllib.error.HTTPError as e:  # 304 and 4xx/5xx arrive here
        return e.code, e.headers


def classify(url: str, ctype: str) -> str:
    path = url.split("?", 1)[0]
    if HASHED.search(path):
        return "hashed-asset"
    if STATIC.search(path):
        return "static-asset"
    if "text/html" in ctype:
        return "html"
    return "other"


def check(url: str, extra: dict[str, str], revalidate: bool) -> list[str]:
    status, h = fetch(url, extra)
    cc_raw = h.get("Cache-Control", "")
    cc = parse_cc(cc_raw)
    kind = classify(url, h.get("Content-Type", ""))
    etag, lm = h.get("ETag"), h.get("Last-Modified")
    print(f"\n{url}\n  status={status} kind={kind}")
    for name in ("Cache-Control", "CDN-Cache-Control", "Surrogate-Control", "ETag",
                 "Last-Modified", "Vary", "Age", "Expires", "Set-Cookie"):
        if h.get(name):
            val = h.get(name) if name != "Set-Cookie" else "(present)"
            print(f"  {name}: {val}")

    problems: list[str] = []
    max_age = int(cc["max-age"]) if (cc.get("max-age") or "").isdigit() else None
    if status >= 400:
        print(f"  PROBLEM: HTTP {status} (cache policy not checked; pass auth with --header)")
        return [f"HTTP {status}"]
    if not cc_raw:
        problems.append("no Cache-Control: browsers apply heuristic freshness")
    if kind == "hashed-asset":
        if "no-store" in cc or "no-cache" in cc:
            problems.append("hashed asset is not cacheable; use public, max-age=31536000, immutable")
        elif max_age is None or max_age < 31536000:
            problems.append(f"hashed asset max-age={max_age}; expected 31536000")
        elif "immutable" not in cc:
            problems.append("hashed asset lacks immutable (reload triggers revalidation)")
    if kind == "static-asset" and max_age and max_age > 86400 and not (etag or lm):
        problems.append("un-hashed asset cached > 1 day with no validator; users may keep stale files")
    if kind == "html":
        if max_age and max_age > 600 and "s-maxage" not in cc:
            problems.append(f"HTML browser max-age={max_age}s; deploys will not reach users quickly")
    if h.get("Set-Cookie") and "public" in cc:
        problems.append("Set-Cookie on a public response: a shared cache may store a user's cookie")
    if "no-store" not in cc and not (etag or lm) and kind != "hashed-asset":
        problems.append("no ETag or Last-Modified: revalidation impossible, full re-download")

    if revalidate and (etag or lm):
        cond = dict(extra)
        if etag:
            cond["If-None-Match"] = etag
        if lm:
            cond["If-Modified-Since"] = lm
        rstatus, _ = fetch(url, cond)
        print(f"  conditional request -> {rstatus}")
        if rstatus != 304:
            problems.append(f"conditional request returned {rstatus}, expected 304")

    for p in problems:
        print(f"  PROBLEM: {p}")
    if not problems:
        print("  ok")
    return problems


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    ap.add_argument("urls", nargs="*")
    ap.add_argument("--file", help="file with one URL per line")
    ap.add_argument("--revalidate", action="store_true", help="send a conditional request and expect 304")
    ap.add_argument("--header", action="append", default=[], help="extra request header, 'Name: value'")
    a = ap.parse_args()
    urls = list(a.urls)
    if a.file:
        with open(a.file, encoding="utf-8") as f:
            urls += [ln.strip() for ln in f if ln.strip() and not ln.startswith("#")]
    if not urls:
        ap.print_usage(sys.stderr)
        return 2
    extra = {}
    for hv in a.header:
        k, _, v = hv.partition(":")
        extra[k.strip()] = v.strip()
    bad = 0
    for u in urls:
        try:
            if check(u, extra, a.revalidate):
                bad += 1
        except (urllib.error.URLError, OSError) as e:
            print(f"\n{u}\n  PROBLEM: request failed: {e}")
            bad += 1
    print(f"\n{len(urls)} URL(s) checked, {bad} with problems")
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc