Finds the cache headers that pin your users to an old build.
"Did you deploy? I still see the old version."
The answer is almost always one line, and it looks completely reasonable:
/*
Cache-Control: public, max-age=31536000, immutable
That is exactly right for app.a1b2c3d4.js. It is a disaster for the index.html beside it. The HTML is the only file that names the current build, so a visitor holding a cached copy keeps requesting last week's bundle — from URLs the new deploy may have deleted — and sees nothing change for a year.
The deploy succeeds. The CDN is purged. The site is still old for everyone who visited it recently, and works perfectly for everyone on the team, because they all hard-refreshed an hour ago.
The service worker is the same mistake with a worse ending. The browser re-fetches sw.js to find out whether a newer worker exists; served from cache, it compares the old worker with itself, concludes nothing changed, and goes on serving its own cached copies of everything. That version survives a hard refresh.
$ npx hardrefresh .
8 files in the build · 5 pinned to the old copy
███████████████████ 3 of 8 refresh on deploy
2 fingerprinted · 8 covered by a rule · _headers
error entry-document-cached _headers:5
2 files that name the current build are cached for a year without revalidating
fix: Serve HTML with `Cache-Control: no-cache` (which still caches, but
revalidates every time), and keep the long lifetimes for the
fingerprinted assets it points at.
_headers:5 — /* → public, max-age=31536000, immutable
index.html
manifest.webmanifest
error service-worker-cached _headers:5
the service worker is cached for a year
npx hardrefresh . # no install
npm install --save-dev hardrefreshNode 20.10 or newer. Zero runtime dependencies.
Build first, then:
hardrefresh . # scan the repository
hardrefresh . --build-dir out # build output somewhere unusual
hardrefresh . --verbose # the coverage breakdown
hardrefresh . --json # machine-readableExit code 1 when something at or above the threshold is found, 0 when clean, 2 on bad usage. In CI, after the build and before the deploy:
- run: npm run build
- run: npx hardrefresh .| Flag | Meaning |
|---|---|
--build-dir <dir> |
Where the built site is, if not dist, build, out or public |
--json |
JSON report on stdout, verdict on stderr |
--verbose |
Show the coverage breakdown |
--fail-on <level> |
error (default), warning, or info |
-h, --help / -v, --version |
Header configuration, in the order the host applies it:
| File | Host |
|---|---|
_headers (root, public/, static/, dist/) |
Netlify, Cloudflare Pages |
netlify.toml |
Netlify |
vercel.json |
Vercel |
firebase.json |
Firebase Hosting |
staticwebapp.config.json |
Azure Static Web Apps |
.htaccess, nginx.conf |
Apache, nginx |
…and the build output beside it, because the same header is correct or catastrophic depending on the file it lands on. That is why this needs the built site and not just the config.
| Rule | Severity | What it means |
|---|---|---|
entry-document-cached |
error | HTML or the web manifest cached without revalidating. The classic. |
service-worker-cached |
error | The updater cannot be updated. Survives a hard refresh. |
immutable-without-fingerprint |
error | A year-long promise on a URL whose bytes can change. |
contradictory-directives |
error | no-store, max-age=600 and friends. The browser picks one, silently. |
fingerprinted-asset-undercached |
warning | Hashed files re-downloaded for no reason. Slow, not broken. |
no-cache-policy |
warning / info | Nothing sets Cache-Control at all. |
build-not-read |
info | No build output, so nothing was checked. Never reported as a pass. |
Full reasoning and the fix for each is in docs/rules.md.
| Directive | What people think | What it does |
|---|---|---|
no-cache |
do not cache | cache it, but revalidate before every use — the right answer for HTML |
no-store |
do not cache | discard it entirely; the only one that means that |
immutable |
cache harder | promise the bytes never change — true for a hashed file, a lie for anything else |
no-cache is not slow. A revalidation that finds nothing changed is a 304: a few hundred bytes of headers, no body.
Two fixtures ship with the repo: the same site, deployed two ways.
git clone https://github.com/hamodywe/hardrefresh && cd hardrefresh
node src/cli.ts examples/stale # five findings, exit 1
node src/cli.ts examples/fresh # silent, exit 0examples/fresh is the point of the pair, and its _headers is not empty or timid — it caches hashed assets for a full year with immutable, and revalidates everything else. It is aggressive where aggression is free and careful where it is not, and the tool says nothing, because there is nothing to say. A check that fires on a deploy doing everything right fires everywhere, and then people turn it off.
_headers, netlify.toml, vercel.json, … ──> Cache-Control rules (+ pattern, line)
│
build output ──> every file, classified: │
index.html → names the build ▼
sw.js → the updater for each file: which rule wins,
app.a1b2c3d4.js → immutable bytes and can a returning visitor
robots.txt → mutable at this URL reuse it without asking?
A later rule overrides an earlier one, which is how every host here resolves two rules for the same path. Freshness is computed from the directives that actually decide it — no-cache and no-store both mean the network is consulted, so neither pins anybody, whatever max-age sits beside them.
Everything is read-only, offline and deterministic. No request is made, nothing is deployed, nothing from the scanned repository is executed.
Stated plainly, because a tool that overstates its coverage is worse than no tool.
- It reads configuration, not responses. A header added by an edge function, a framework's own defaults, or your CDN's dashboard is invisible here. To check what is actually served,
curl -Ithe deployed URL. - Host defaults are not modelled. When no rule matches a file, that is reported as uncovered rather than guessed at — hosts differ, and their defaults change without a commit in your repository.
- Fingerprint detection is a heuristic. A filename segment of eight or more characters mixing letters and digits is treated as a content hash. A build that versions by directory (
/v3/app.js) is not recognised. - CDN-level caching is a separate layer.
s-maxageis read, but a purge-on-deploy setup can make a long shared lifetime perfectly safe. This tool reasons about the browser, which cannot be purged. .htaccessand nginx are read line by line. Scope from<FilesMatch>andlocationis approximated; complex rewrites are not followed.- Pattern matching is the hosts' path syntax, not a full path-to-regexp implementation. Exotic Vercel
sourcepatterns may not resolve exactly.
Isn't no-cache on HTML slow?
No. It is one conditional request that usually returns 304 Not Modified — headers only, no body. You are trading a few hundred bytes for the guarantee that a deploy reaches people.
We purge the CDN on every deploy. Are we fine?
For the CDN, yes. Not for the browser: a purge cannot reach a copy already sitting on someone's laptop. max-age on HTML is a promise you cannot take back.
Our framework sets these headers, not a config file.
Then this tool will not see them, and says so rather than reporting clean. no-cache-policy at info is exactly that admission.
Will it change my files? No. It reads, it reports, it exits. There is no write path in the codebase.
See ROADMAP.md.
See CONTRIBUTING.md. A header configuration this tool judged wrongly is the most useful thing you can send.