Crawl a deployed page, discover its static assets, fetch their HTTP response headers, and audit the caching configuration — flagging the misconfigurations that quietly waste bandwidth or serve stale content. Each finding links to a deep-dive explainer on frontend-performance.com.
No headless browser, no Lighthouse. Just built-in fetch + a tiny HTML parse,
so it runs in a second and drops into any CI step or PR check.
cache-header-lint https://example.com/
HTTP 200 · 14 assets
/assets/app.4f3a2b1c.js [script, hashed]
cache-control: public, max-age=600
! hashed-short-max-age Content-hashed asset has max-age=600 (10m); a fingerprinted file should use max-age=31536000.
Recommended: Cache-Control: public, max-age=31536000, immutable.
→ https://frontend-performance.com/advanced-caching-strategies-cdn-architecture/http-cache-control-headers-explained/
A content-hashed bundle like app.4f3a2b1c.js can be cached forever — its URL
changes whenever its bytes change. If it ships with max-age=600 (or, worse,
no-store), every visitor re-downloads it constantly. Conversely, an HTML
document marked immutable pins visitors to a stale page until the URL changes.
These mistakes are invisible in a browser and easy to miss in review.
cache-header-lint makes them a one-line check.
Not published to npm. Run it straight from GitHub with npx:
npx github:frontend-performance/cache-header-lint https://example.comOr clone and build from source:
git clone https://github.com/frontend-performance/cache-header-lint.git
cd cache-header-lint
npm install # builds via the prepare script
npm link # exposes the `cache-header-lint` binaryRequires Node.js 20+ (uses the built-in global fetch).
cache-header-lint <url> [options]cache-header-lint https://example.com
cache-header-lint https://example.com --include-cross-origin
cache-header-lint https://example.com --json > report.json
cache-header-lint https://preview.example.com --markdown >> "$GITHUB_STEP_SUMMARY"
cache-header-lint https://example.com --fail-on warn # stricter CI gate| Option | Default | Description |
|---|---|---|
--include-cross-origin |
off | Also audit assets served from other origins. |
--concurrency <n> |
8 |
Parallel header fetches. |
--timeout <ms> |
15000 |
Per-request timeout. |
--max-assets <n> |
100 |
Cap the number of assets audited. |
--delay <ms> |
0 |
Polite delay between fetches, per worker. |
--fail-on <level> |
error |
Exit-1 threshold: error | warn | never. |
--json |
Machine-readable JSON. | |
--markdown |
Markdown table (for CI job summaries). | |
--no-color |
Disable ANSI colors (also respects NO_COLOR). |
|
-h, --help |
Show help. |
0— no findings at or above--fail-on1— at least one finding at or above--fail-on(default:error)2— bad arguments, or the target page itself could not be fetched
Findings have a severity (error / warn / info) and a "learn more" link.
| Rule | Severity | Applies to | Meaning |
|---|---|---|---|
missing-cache-control |
error | static asset | No Cache-Control (or Expires) at all — caching is left to browser guesswork. |
hashed-short-max-age |
warn | hashed asset | Content-hashed filename but max-age is missing or well under a year. |
hashed-no-store |
warn | hashed asset | Hashed asset sent with no-store/no-cache — almost always a misconfig. |
hashed-missing-immutable |
info | hashed asset | Long max-age but no immutable, so browsers still revalidate on reload. |
non-hashed-long-max-age |
warn | static asset | Non-fingerprinted asset cached ~1 year with no way to bust it → stale risk. |
missing-validators |
info | static asset | Cacheable but no ETag/Last-Modified, so revalidation re-downloads the body. |
html-immutable |
warn | document | The HTML document is marked immutable — visitors get pinned to stale markup. |
html-long-max-age |
warn | document | The document has a long max-age with no revalidation — deploys don't reach cached visitors. |
html-no-cache-control |
info | document | The document has no Cache-Control; heuristic caching can serve stale markup. |
cdn-cache-status |
info | any | Reports cf-cache-status / x-cache / x-vercel-cache (and notes repeated MISS/BYPASS). |
no-cdn-detected |
info | run | No CDN/edge cache headers seen on any asset. |
→ Background reading: HTTP Cache-Control headers explained, Cache invalidation patterns, CDN edge caching configuration, stale-while-revalidate implementation.
- Fetch the page HTML with
fetch(following redirects). - Extract asset references from the markup with cheerio:
<script src>,<link rel="stylesheet">,<link rel="preload"/"modulepreload">,<img src/srcset>,<source srcset>, and fonts. Relative URLs resolve against the page (honouring<base href>); cross-origin assets are skipped unless--include-cross-originis set. - Fetch each asset's headers with a concurrency-limited pool (default 8).
It issues a
GETwith a 1-byteRangeand cancels the body immediately, so headers are read without downloading the payload (with aHEADfallback for servers that reject the method). Individual failures are reported, never fatal. - Audit the headers with a set of pure functions (
src/rules.ts). The filename hash heuristic (src/hash.ts) decides whether an asset is fingerprinted — hex runs of 8+ chars, or mixed base-62 tokens containing a digit (e.g.app.4f3a2b1c.js,chunk-AbC123.css,style.DWA7XW_Z.css) — which determines how strict the caching expectations are. The document itself is audited with the same engine but a document-appropriate rule set.
The audit core is deliberately pure (URL + headers → findings) and is the most heavily unit-tested part of the codebase.
import { lint } from "cache-header-lint";
const result = await lint("https://example.com", {
includeCrossOrigin: false,
concurrency: 8,
timeout: 15_000,
maxAssets: 100,
});
console.log(result.summary); // { assets, fetched, failures, error, warn, info }
for (const asset of result.assets) {
for (const finding of asset.findings) {
console.log(asset.url, finding.severity, finding.rule, finding.message);
}
}The pure primitives are exported too: auditAsset({ url, kind, headers }),
parseCacheControl(value), isHashedFilename(url), and extractAssets(html, pageUrl).
A composite action is included. It runs the linter against a deploy-preview URL and appends a Markdown report to the job summary.
# .github/workflows/cache-audit.yml
name: Cache header audit
on:
deployment_status:
jobs:
audit:
if: github.event.deployment_status.state == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/setup-node@v4
with:
node-version: 20
- uses: frontend-performance/cache-header-lint@main
with:
url: ${{ github.event.deployment_status.target_url }}
fail-on: warnOr call the CLI directly in any workflow step:
- run: |
npx --yes github:frontend-performance/cache-header-lint "$PREVIEW_URL" \
--markdown >> "$GITHUB_STEP_SUMMARY"
npx --yes github:frontend-performance/cache-header-lint "$PREVIEW_URL" \
--fail-on error| Input | Default | Description |
|---|---|---|
url |
(required) | The deployed / preview URL to audit. |
fail-on |
error |
Exit-1 threshold: error | warn | never. |
include-cross-origin |
false |
Also audit cross-origin assets. |
concurrency |
8 |
Parallel header fetches. |
max-assets |
100 |
Maximum number of assets to audit. |
timeout |
15000 |
Per-request timeout in ms. |
Deep dives on the concepts this tool checks, from frontend-performance.com:
- Advanced caching strategies & CDN architecture — the cluster overview
- HTTP Cache-Control headers explained
- Cache invalidation patterns
- CDN edge caching configuration
- stale-while-revalidate implementation
- Service worker caching strategies
MIT © frontend-performance. See LICENSE.