Back to core workflows Fix dependency resolution Tune package metadata Validate before publishing

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.

A typical supported range for a library Timeline of Node.js lines from an end-of-life line to maintenance LTS, active LTS and current, showing which are included in the test matrix. 18.x end of life: drop in next major 20.x maintenance LTS: test 22.x active or maintenance LTS: test 24.x active LTS: test current newest release: test, allow failure
Test every line you list in engines — typically the maintained LTS lines plus the current 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: false so every cell reports, instead of cancelling on the first failure.
Matrix design trade-offs Compares testing only the latest version, every line's latest patch, and floor plus latest per line on coverage of feature floors, cost and confidence in engines. latest only latest per line floor + latest per line Catches missing features on old lines no partly yes Catches minor-version floors no no yes Jobs per run (3 lines) 1 3 6 engines field is proven no partly yes
Floor plus latest per supported line gives confidence in the engines field for modest cost.

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.

Matrix cost in CI minutes per run Example CI minutes per run for latest-only, latest per line, floor plus latest per line, and the full OS cross product. latest only 3 min latest per line 9 min floor + latest per line 18 min every OS x every version 54 min
The floor-plus-latest design costs a few extra minutes; a full OS cross product multiplies cost without much extra coverage.

Prevention and CI/CD guardrails

  • Generate the matrix from engines so 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