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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- http-caching Installs to ~/.agents/skills/http-caching/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install http-caching The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project http-caching Installs to .agents/skills/http-caching/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/http-caching.zipunzip -o http-caching.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) http-caching PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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.
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)
| Response | Cache-Control | Validator | Why |
|---|---|---|---|
Fingerprinted assets (app.3f9a2c1d.js, hashed CSS, fonts, images) | public, max-age=31536000, immutable | Optional | URL 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-Modified | Can change without URL change |
| HTML documents | no-cache (always revalidate), or max-age=0, s-maxage=60, stale-while-revalidate=300 with a CDN | ETag | Must pick up new deploys; references hashed assets |
| Public API GET (catalogue, config) | public, max-age=30, stale-while-revalidate=60 | ETag | Short freshness, cheap revalidation |
| Per-user API / pages (account, cart) | private, no-cache (or max-age=0) | ETag | Browser may reuse after revalidation; shared caches must not store |
| Secrets, tokens, banking, one-time data | no-store | None | Never 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.publicallows shared caches even for authenticated requests, which is dangerous.s-maxageoverridesmax-agefor shared caches (CDN, proxies) only.immutableonly matters formax-ageresponses on user reload; it is safe only when the URL is content-hashed.stale-while-revalidate=Nserves 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 forIf-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-Encodingis normal.Vary: Acceptis needed when you negotiate formats (AVIF/WebP).Vary: CookieorVary: User-Agentfragment the cache so badly that most CDNs effectively stop caching; prefer separate URLs orprivate.CDN-Cache-Control(and vendor variants likeSurrogate-Control) set CDN-only policy without affecting browsers. Cache key design, purging and tag invalidation are covered in$cdn-caching.- A response with
Set-Cookiemust 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-ageonly on content-hashed URLs. A year-long cache on/app.jswithout 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
--revalidatepassing 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.yaml
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.py
#!/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.