Skip to content

Repository files navigation

hardrefresh

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

Install

npx hardrefresh .                  # no install
npm install --save-dev hardrefresh

Node 20.10 or newer. Zero runtime dependencies.

Quick start

Build first, then:

hardrefresh .                      # scan the repository
hardrefresh . --build-dir out      # build output somewhere unusual
hardrefresh . --verbose            # the coverage breakdown
hardrefresh . --json               # machine-readable

Exit 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 .

Options

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

What it reads

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.

What it checks

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.

The three directives everyone gets backwards

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.


Try it

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 0

examples/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.

How it works

_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.

Limitations

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 -I the 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-maxage is 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.
  • .htaccess and nginx are read line by line. Scope from <FilesMatch> and location is approximated; complex rewrites are not followed.
  • Pattern matching is the hosts' path syntax, not a full path-to-regexp implementation. Exotic Vercel source patterns may not resolve exactly.

FAQ

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.

Roadmap

See ROADMAP.md.

Contributing

See CONTRIBUTING.md. A header configuration this tool judged wrongly is the most useful thing you can send.

License

MIT

About

Finds the cache headers that pin visitors to an old build — the cached index.html and service worker behind every "did you deploy? I still see the old version".

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages