Running a Node.js Version Matrix for a Library
The engines.node field in your package.json is a promise: "this package works on these Node.js versions". Unless CI runs your package on each of them, the promise is a guess. Features arrive and change between releases — require(esm), import.meta.dirname, native fetch, type stripping, stricter module resolution — and code written on the latest version routinely breaks on the oldest one you claim to support. This guide builds a CI matrix that tests exactly your supported range, keeps it fast, and ties it to the engines field so the two cannot drift apart.
Symptoms of an untested version range
Version-specific breakage reaches consumers as errors that never appear in your own CI:
# Node 18: import.meta.dirname arrived in 20.11
TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string. Received undefined
at Object.join (node:path:1175:7)
at file:///app/node_modules/your-lib/dist/templates.js:4:24
# Node 20.18: require(esm) is not available before 20.19
Error [ERR_REQUIRE_ESM]: require() of ES Module /app/node_modules/esm-dep/index.js not supported.
# Node 18: Array.prototype.toSorted arrived in 20
TypeError: items.toSorted is not a function
Each one is legitimate code on a newer runtime and a crash on an older one. The fix is not to avoid new features — it is to know which versions you support and test all of them.
Deciding the supported range
Start from Node.js's release schedule. Even-numbered releases become LTS and receive about 30 months of support; odd-numbered releases are short-lived. A sensible default for libraries is: every LTS line that is still maintained, plus the current release. Dropping an LTS line when it reaches end of life is a breaking change for anyone still on it, so do it in a major release.
Then express the range in engines, including minimum minors where you depend on specific features:
{
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
}
That range guarantees require(esm) on every supported line. Write the same range in your README's installation section, and mention it in release notes whenever it changes, because many consumers never look at engines until npm prints a warning. If you also publish a CLI, remember that users run it on whatever Node.js is installed globally on their machine, which is often older than the version their projects use — another reason to test the floor explicitly. The general role of engines is covered in Understanding package.json Fields.
Building the matrix
name: node-matrix
on: [pull_request, push]
jobs:
test:
name: node ${{ matrix.node }} / ${{ matrix.os }}
runs-on: ${{ matrix.os }}
continue-on-error: ${{ matrix.experimental == true }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
node: ["20.19.0", "20", "22.12.0", "22", "24"]
include:
- os: windows-latest
node: "22"
- os: macos-latest
node: "24"
- os: ubuntu-latest
node: "25"
experimental: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm run build
- run: npm test
- run: bash scripts/smoke-test.sh # install the tarball and import it on this runtime
The design choices:
- Test the floor exactly (
20.19.0,22.12.0) as well as the latest patch of each line. Features often arrive in a minor release, and "20" alone tests only the newest 20.x. - Run the full OS spread on one version each. Windows and macOS rarely differ by Node.js version; testing every combination multiplies cost for little gain.
- Mark the newest non-LTS release experimental with
continue-on-error, so you see breakage early without blocking merges. fail-fast: falseso every cell reports, instead of cancelling on the first failure.
Version-gated features to watch for
Most version breakage comes from a short list of features that library code adopts casually. Knowing the list lets you decide the floor deliberately rather than discover it from bug reports.
| Feature | Available from | Typical failure on older versions |
|---|---|---|
import.meta.dirname / filename |
20.11, 21.2 | undefined passed to path.join |
require(esm) without a flag |
20.19, 22.12 | ERR_REQUIRE_ESM |
Array.prototype.toSorted, findLast |
20 (toSorted), 18 (findLast) | is not a function |
Promise.withResolvers |
22 | is not a function |
Import attributes with { type: 'json' } |
18.20, 20.10, 22 | SyntaxError on older syntax |
Global navigator, WebSocket |
21, 22 | ReferenceError |
Type stripping of .ts files |
22.18, 23.6 | ERR_UNKNOWN_FILE_EXTENSION |
process.getBuiltinModule |
22.3, 20.16 | is not a function |
Two tools help keep code honest against the floor. Setting lib and target in tsconfig.json to match your oldest supported runtime (for example, "lib": ["es2023"] for Node.js 20) makes TypeScript reject APIs that do not exist there. And @types/node pinned to the major of your floor — @types/node@20 when you support 20 — stops the editor offering Node.js APIs that arrived later. Neither replaces running the matrix, but together they catch most problems before CI does.
What to run in each cell
Running your entire test suite on every cell is simple but can be slow. A tiered approach keeps the matrix quick. Run the full unit suite on one primary version — usually the newest LTS — where developers work. On every other cell, run a reduced set: the tarball smoke test, a handful of integration tests that exercise version-sensitive code (file system, module loading, streams, crypto), and anything tagged as runtime-sensitive. Tag those tests explicitly (for example, with a .runtime.test.ts suffix) so the split is visible in the code rather than hidden in CI configuration. In practice most version bugs are loading and API-availability problems, which the smoke test and a small integration set catch reliably.
Keeping engines and the matrix in sync
The matrix and the engines field are two copies of one decision, so derive one from the other. A small script can read engines.node, compute the minimum version of each major, and emit the matrix as JSON for a dynamic job:
jobs:
versions:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.m.outputs.matrix }}
steps:
- uses: actions/checkout@v4
- id: m
run: echo "matrix=$(node scripts/node-matrix.mjs)" >> "$GITHUB_OUTPUT"
test:
needs: versions
strategy:
matrix:
node: ${{ fromJSON(needs.versions.outputs.matrix) }}
runs-on: ubuntu-latest
steps:
- uses: actions/setup-node@v4
with: { node-version: "${{ matrix.node }}" }
# ...
With that in place, changing engines changes what CI tests, and nobody has to remember to update two files.
Testing the artefact, not only the source
Running unit tests on each version proves your source works when loaded by your test runner. It does not prove the published package loads, because test runners transform code and resolve modules differently. Add the tarball smoke test from Smoke-Testing a Tarball with npm pack to each matrix cell: install the packed tarball in a clean directory and import it with plain node — both ESM and CommonJS. That is where ERR_REQUIRE_ESM, missing import.meta properties and module-format issues appear.
Worked example: a floor that was too optimistic
A library declares "node": ">=18" and tests on 22 only. It adopts import.meta.dirname to replace a fileURLToPath helper. A consumer on Node.js 20.10 reports a crash in the templates module. Adding a matrix with 18, 20.10.0, 20, 22 reproduces it immediately on the two older cells. The team chooses to raise the floor to ^20.11.0 || >=22 in a major release rather than revert, updates engines, and lets the generated matrix follow. The changelog documents the new minimum, and npm now warns consumers on older runtimes at install time instead of letting them crash at runtime.
Prevention and CI/CD guardrails
- Generate the matrix from
enginesso the two cannot disagree. - Test minimum minors, not just major lines.
- Include a tarball import on every cell, not only unit tests.
- Treat dropping a Node.js line as breaking, announced in the changelog and shipped in a major release.
Frequently Asked Questions
Do I need to test every patch release? No. Test the lowest version you support on each line (to prove the floor) and the latest patch (to catch regressions). Patch releases in between rarely matter.
Should applications use a matrix too?
Applications usually run on one pinned Node.js version, set in .nvmrc and the deployment image, so a single version is enough. Libraries need the matrix because consumers choose the runtime.
How do I test Bun and Deno support?
Add jobs using oven-sh/setup-bun and denoland/setup-deno that run the same tarball import tests. Only advertise support for runtimes you test.
Why test the exact floor version rather than the latest patch of that line?
Because the floor is what engines promises. A feature added in 20.19 works on "20" in CI (which resolves to the latest 20.x) while failing on 20.18, which your range may still allow. Testing the floor proves the lower bound.
What if a dependency drops support for an old Node.js line before I do? Your matrix will fail on that line after the dependency update. Either pin the dependency's previous major for as long as you support the old line, or raise your own floor in a major release. The matrix makes the conflict visible at the update pull request instead of in a consumer's production logs.
Related
- Testing and Validating Packages Before Publishing places the matrix in the release gate.
- Fixing npm EBADENGINE 'Unsupported Engine' Warnings explains what consumers see when
enginesdoes not match. - Loading ESM from CommonJS with require(esm) is a feature whose availability depends on minor versions.
- Splitting Monorepo Tests into Parallel CI Shards keeps matrix jobs fast in large repositories.