Lint the resource hints in your built HTML — catch broken
preload,prefetch,preconnect,dns-prefetch,modulepreloadandfetchprioritybefore they ship.
resource-hint-lint crawls your built/static HTML, parses every <link> hint and
fetchpriority attribute, and reports the mistakes that quietly waste bandwidth,
open idle connections, or defeat the whole point of an early hint. It prints a
grouped, colorized report with a 0–100 score and exits non-zero when errors are
found, so it drops straight into CI.
Zero runtime dependencies. Pure Node (>=18). One small, well-tested HTML tokenizer, one rules engine, two reporters.
- Parses each HTML file with a dependency-free tag/attribute scanner.
- Runs 13 rules over the resource hints and
fetchpriorityusage it finds. - Groups findings by file, computes a score, and prints a summary.
- Emits machine-readable JSON for pipelines with
--json. - Exits
1when any error-severity finding exists (or on warnings too with--strict).
Resource hints are a sharp tool: used well they shave real milliseconds off Largest Contentful Paint; used carelessly they do the opposite.
- A
preloadwith noas(or an invalid one) is ignored — you pay the bytes for nothing. - A font preload without
crossoriginis fetched twice, because fonts always load in CORS mode. - An unused
preloadcompetes with the resources the browser actually needs right now. - Too many
preconnecthints open idle sockets that fight for bandwidth. - The same URL marked both
preloadandprefetchsends the browser conflicting priorities. - Marking many images
fetchpriority="high"dilutes prioritization and creates LCP contention.
For the background on getting these right, see the resource hint implementation overview.
This is a standalone repository. It is not published to npm — clone it and run it directly.
git clone https://github.com/network-priority/resource-hint-lint.git
cd resource-hint-lint
# Run straight from the checkout (zero dependencies, nothing to install):
node bin/resource-hint-lint "dist/**/*.html"To get a global resource-hint-lint command on your PATH, link it:
npm link
resource-hint-lint "dist/**/*.html"resource-hint-lint <glob-or-paths...> [options]Real examples:
# Lint an entire build directory (glob support is built in — no shell expansion needed).
resource-hint-lint "dist/**/*.html"
# A single file, plus JSON for a CI artifact.
resource-hint-lint public/index.html --json
# A directory (recursed for .html), with a tighter preconnect budget, failing on warnings too.
resource-hint-lint build --max-preconnect 4 --strict
# Exclude vendored output.
resource-hint-lint "build/**/*.html" --ignore "build/vendor/**" --ignore "build/legacy/**"| Option | Description |
|---|---|
--json |
Machine-readable JSON output (for CI). |
--quiet |
Only show error-severity findings. |
--strict |
Exit non-zero on warnings too, not just errors. |
--max-preconnect <n> |
Threshold for the preconnect-too-many rule (default 6). |
--config <path> |
Path to a JSON config file (see below). |
--ignore <glob> |
Glob to exclude; repeatable. |
--no-color |
Disable ANSI colors (also honors the NO_COLOR env var). |
--list-rules |
Print every rule id and default severity, then exit. |
--version |
Print the version and exit. |
--help |
Show help and exit. |
Exit codes: 0 clean (warnings allowed unless --strict), 1 errors present, 2 usage/config error.
| Rule id | Severity | What it catches | Why it matters |
|---|---|---|---|
preload-missing-as |
error | <link rel=preload> with no as |
Without as the browser can't set priority or request headers, and may double-fetch. |
preload-invalid-as |
error | as outside the allowed set |
An unrecognized as value is ignored, so the preload is wasted. |
preload-font-missing-crossorigin |
error | as=font preload without crossorigin |
Fonts load in CORS mode; without it the preload is discarded and the font is fetched twice. |
preload-missing-type |
warn | as=font/as=image preload without type |
A type lets the browser skip preloads for formats it can't use. |
unused-preload |
warn | A preloaded URL never referenced in the doc | An unused preload wastes bandwidth and delays other downloads. |
preconnect-unused |
warn | A preconnect origin no subresource uses |
An unused preconnect holds an idle connection open for nothing. |
preconnect-too-many |
warn | More than --max-preconnect connection hints |
Too many early connections cause bandwidth/connection contention. |
preconnect-missing-crossorigin-font |
warn | preconnect to a known font origin without crossorigin |
Font files need a CORS connection; otherwise a second connection is opened anyway. |
prefetch-same-as-preload |
warn | Same URL both preloaded and prefetched | preload (this navigation) and prefetch (the next) send conflicting priority signals. |
duplicate-hint |
warn | Identical rel+href+as declared twice |
Duplicate hints add parser work and clutter with no benefit. |
fetchpriority-invalid |
error | fetchpriority outside {high, low, auto} |
An invalid value is ignored, so the intended prioritization never happens. |
preload-not-in-head |
warn | A preload/preconnect in <body> |
Hints in the body are discovered late, defeating the point of an early hint. |
multiple-fetchpriority-high-images |
warn | More than N images with fetchpriority=high |
Marking many images high dilutes prioritization and creates LCP contention. |
Deep dives on the rules above:
- Choosing between the hints: preload vs prefetch vs modulepreload decision matrix.
- Getting connection hints right: strategic preconnect / dns-prefetch usage.
- The
fetchpriorityrules: fetchpriority attribute & Priority Hints. - Why fonts need
crossoriginand atype: font loading optimization (FOUT/FOIT prevention).
Pass a JSON file with --config. Everything is optional; provided keys override the
defaults. Rules can be turned off ("off" or false) or have their severity changed
("warn" / "error").
{
"maxPreconnect": 4,
"maxHighPriorityImages": 1,
"fontOrigins": ["https://fonts.example.com"],
"rules": {
"unused-preload": "off",
"preload-missing-type": "error",
"duplicate-hint": "warn"
}
}maxPreconnect— threshold forpreconnect-too-many(CLI--max-preconnectwins over this).maxHighPriorityImages— threshold formultiple-fetchpriority-high-images.fontOrigins— extra origins treated as font hosts (merged with the built-in list) for the fontcrossoriginchecks.rules— per-rule severity overrides.
Because the linter exits non-zero on errors, wiring it into CI is a one-liner. Add a step to your workflow that runs it against your build output:
# .github/workflows/perf-hints.yml
name: Resource hints
on: [push, pull_request]
jobs:
hint-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
# Build your site first (adjust to your toolchain):
# - run: npm ci && npm run build
- name: Clone resource-hint-lint
run: git clone --depth 1 https://github.com/network-priority/resource-hint-lint.git .tools/rhl
- name: Audit resource hints
run: node .tools/rhl/bin/resource-hint-lint "dist/**/*.html" --strictThis repository's own .github/workflows/ci.yml runs the
test suite on Node 18, 20 and 22.
Pretty (terminal) output for a page with problems:
examples/messy.html
8: error <link rel="preload" href="/css/late.css"> is missing an "as" attribute. preload-missing-as
11: error Invalid as="styles" on preload (allowed: style, script, image, font, ...). preload-invalid-as
14: error preload as="font" href="/fonts/inter.woff2" is missing "crossorigin" ... preload-font-missing-crossorigin
39: warn 7 preconnect/dns-prefetch hints exceed the limit of 6. preconnect-too-many
51: warn 3 images use fetchpriority="high" (limit 2); this dilutes prioritization. multiple-fetchpriority-high-images
5 error(s), 15 warning(s) — score 0/100
Summary
files: 1 errors: 5 warnings: 15
overall score: 0/100
JSON output (--json):
{
"tool": "resource-hint-lint",
"summary": { "files": 1, "errors": 5, "warnings": 15, "score": 0 },
"results": [
{
"file": "examples/messy.html",
"score": 0,
"errors": 5,
"warnings": 15,
"findings": [
{
"rule": "preload-missing-as",
"severity": "error",
"message": "<link rel=\"preload\" href=\"/css/late.css\"> is missing an \"as\" attribute.",
"line": 8
}
]
}
]
}Try it yourself against the bundled fixtures in examples/:
node bin/resource-hint-lint examples/clean.html # exits 0, score 100/100
node bin/resource-hint-lint examples/messy.html # exits 1, nearly every rule firesThree small pieces, all dependency-free:
-
The parser (
src/parser.js) is a tag/attribute tokenizer — not a full DOM. It walks the HTML once, extracting<link>,<script>,<img>,<source>,<a>,<video>and<audio>tags with their attributes. It handles single/ double/unquoted attribute values, boolean attributes, self-closing tags and case-insensitivity, skips comments and the raw text inside<script>/<style>, and tracks whether each tag lives in<head>or<body>(plus a line number). -
The rules engine (
src/rules.js) collects the hints and the subresources the document actually references, then runs each enabled rule. Cross-cutting rules (unused preloads, unused preconnects, prefetch-vs-preload conflicts) work by comparing the hinted URLs/origins against the referenced ones. Severities come from each rule's default, overridable per-rule via config. -
The reporters (
src/reporters.js) render either the colorized terminal report (respectingNO_COLOR/--no-color) or JSON, and compute the score (errors weigh more than warnings, clamped to 0–100).
Globbing and file discovery (src/glob.js) are hand-rolled too: a recursive
directory walker plus a minimatch-lite matcher supporting **, *, ? and
character classes.
If you want to go deeper than the linter's messages, these guides cover the "why" behind each rule:
- Resource hint implementation overview
- preload vs prefetch vs modulepreload decision matrix
- Strategic preconnect / dns-prefetch usage
- fetchpriority attribute & Priority Hints
- Font loading optimization (FOUT/FOIT prevention)
Issues and pull requests are welcome. To work on the tool:
git clone https://github.com/network-priority/resource-hint-lint.git
cd resource-hint-lint
npm test # runs node --testAdding a rule is small and self-contained: register its metadata in RULES
(src/rules.js), emit findings from runRules, and add a fixture-backed test under
test/. Please keep the project dependency-free.
MIT © 2026 Network Priority.
Built by the team behind network-priority.com.