Node.js

Node.js 24 Breaking Changes: What Bites in CI When You Upgrade

Node.js 20 reached end of life on 2026-04-30, and Node.js 22 drops to security fixes only in a few months. So if your pipelines still run on either, the move to 24 is due. This post is for developers and platform engineers moving a CI pipeline from Node 20 or 22 to Node 24 LTS. It covers the Node.js 24 breaking changes that fail a build, rather than the new features. Every claim below comes from a probe script run on Node 20, 22, 24 and 26 on the same day, with the exit code and output pasted. You will learn which calls now crash, which flags stop every Node process from starting, which native dependencies stop installing, and how to find all of it while you are still on 22.

What Was Checked, and Against Which Versions

To keep the Node.js 24 breaking changes below verifiable, the version facts were read from the official changelogs and the Node.js release index on the day of writing. The behaviour was not taken from the changelogs. Instead, each change got a small probe script, and every probe ran once on each of four portable Node builds.

Checked 2026-10-06
Windows 11 Pro (build 26200), official win-x64 zip builds, no version manager
Node.js 20.20.2 (npm 10.8.2, ABI 115, V8 11.3, undici 6.24.1)
Node.js 22.23.3 (npm 10.9.9, ABI 127, V8 12.4, undici 6.28.1)
Node.js 24.21.0 (npm 11.19.0, ABI 137, V8 13.6, undici 7.29.1)
Node.js 26.10.0 (npm 11.19.1, ABI 147, V8 14.6, undici 8.10.2)
Changelog  https://github.com/nodejs/node/blob/main/doc/changelogs/CHANGELOG_V24.md
Schedule   https://github.com/nodejs/Release/blob/main/schedule.json
Next scheduled re-check: 2027-01-06, or when Node 26 enters LTS

Each probe is a short file in a probes/ directory. The runner below executes every probe on each version and prints the exit code with the first warning or error line, so most output blocks in this post come from it:

#!/usr/bin/env bash
# run-probes.sh: runs every probe on each Node version, prints exit code and
# the first warning or error line. Usage: ./run-probes.sh [probe-glob]
cd "$(dirname "$0")"
VERSIONS="v20.20.2 v22.23.3 v24.21.0 v26.10.0"
for probe in probes/${1:-*}.{cjs,mjs,ts}; do
  [ -f "$probe" ] || continue
  echo "## $(basename "$probe")"
  for v in $VERSIONS; do
    node="./node-$v-win-x64/node.exe"
    args=("$probe")
    case "$probe" in probes/test-*) args=(--test "$probe");; esac
    out=$(cd probes && PATH="$PWD/../node-$v-win-x64:$PATH" timeout 30 "../$node" "${args[@]/probes\//}" 2>&1)
    code=$?
    line=$(printf '%s\n' "$out" | grep -E 'Warning|Error|->|fired|ok|not ok|# (pass|fail|cancelled)|failureType|error:' | grep -vE '^\s+at |node --trace' | head -4 | sed 's/^/    /')
    printf '%-9s exit=%s\n%s\n' "$v" "$code" "$line"
  done
done

The results are exit codes and messages, not timings, so they do not vary between runs. One limitation applies throughout: everything ran on Windows. The JavaScript behaviour is the same on Linux and macOS, but the native addon section names a Windows failure (no Python) where a Linux runner would fail on a missing compiler instead.

Why Is the Node.js 24 Upgrade Due Now?

Node.js 24 is the Active LTS line until 2026-10-20, when it moves to Maintenance and keeps receiving security fixes until 2028-04-30. Node 20 is already out of support, and Node 22 ends on 2027-04-30. Node 26 becomes the Active LTS on 2026-10-28, which matters for any pipeline that asks for lts/* instead of a fixed major.

LineCodenameActive LTS fromMaintenance fromEnd of life
Node 20Iron2023-10-242024-10-222026-04-30
Node 22Jod2024-10-292025-10-212027-04-30
Node 24Krypton2025-10-282026-10-202028-04-30
Node 26not yet named2026-10-282027-10-202029-04-30

That last row is the hidden deadline. actions/setup-node with node-version: lts/*, a .nvmrc containing lts/*, and the node:lts Docker tag all resolve to the newest LTS line. Consequently, on 2026-10-28 they jump from 24 to 26 without a commit in your repo. Pin the major explicitly before then, whichever version you land on.

Which Node.js 24 Breaking Changes Actually Crash?

Seven calls that run on Node 22 throw on Node 24. In each case, Node 22 printed a deprecation warning and carried on, and Node 24 exits with code 1. The table is the result of running each probe on both versions.

Old callNode 22.23.3Node 24.21.0Replacement
util.isFunction(), util.isString() and the other util.is* checksWorks, DEP0049 warningTypeError: util.isFunction is not a functiontypeof value === 'function'
util.log()Works, DEP0059 warningTypeError: require(...).log is not a functionconsole.log with a timestamp
process.assert()Works, DEP0100 warningTypeError: process.assert is not a functionnode:assert
tls.createSecurePair()Works, DEP0064 warningTypeError: tls.createSecurePair is not a functiontls.TLSSocket
fs.truncate(fd, len, cb)Works, DEP0081 warningTypeError [ERR_INVALID_ARG_TYPE]fs.ftruncate(fd, len, cb)
dirent.pathReturns the directoryReturns undefined, no warningdirent.parentPath
OutgoingMessage._headersReturns the headers, DEP0066 warningReturns undefined, no warninggetHeaders()

The first five fail loudly, which makes them the friendly ones. The last two fail quietly, since they return undefined and let your code crash somewhere else. Notably, the util.is* removal happened in Node 23, so it surprises teams who skipped the odd-numbered release and read only the 24 changelog. util.isArray() and util._extend() survived: both still work on 24 and 26 with a warning.

Removed APIs That Now Throw a TypeError

Here is the probe for the most common of them, run on all four versions.

// probes/util-isFunction.cjs
const util = require('node:util');
console.log('util.isFunction ->', util.isFunction(() => {}));
./run-probes.sh util-isFunction
## util-isFunction.cjs
v20.20.2  exit=0
    util.isFunction -> true
v22.23.3  exit=0
    util.isFunction -> true
    (node:18136) [DEP0049] DeprecationWarning: The `util.isFunction` API is deprecated.  Please use `typeof arg === "function"` instead.
v24.21.0  exit=1
    console.log('util.isFunction ->', util.isFunction(() => {}));
    TypeError: util.isFunction is not a function
v26.10.0  exit=1
    console.log('util.isFunction ->', util.isFunction(() => {}));
    TypeError: util.isFunction is not a function

In practice, your own code rarely calls util.isFunction anymore. Instead, the call usually sits in an old, unmaintained dependency, often several levels down the tree. That is why the crash shows up in CI the first time a test exercises that path, rather than at install time.

The fs.truncate case is similar. Passing a file descriptor where a path belongs worked with a warning until Node 22. On Node 24 the probe exits with TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string or an instance of Buffer or URL. Received type number (3).

dirent.path Disappears Without a Warning

This one deserves its own section, because none of the usual safety nets catch it. Node 22 gives no deprecation warning for dirent.path, even with --pending-deprecation, and Node 24 returns undefined instead. The crash then happens one line later, inside path.join. Here is the pattern that build and lint scripts commonly use to collect source files:

// scripts/list-source-files.cjs
const fs = require('node:fs');
const path = require('node:path');

// Collects every .ts file under src/, the way many build and lint scripts do
function listSourceFiles(root) {
  return fs
    .readdirSync(root, { recursive: true, withFileTypes: true })
    .filter((entry) => entry.isFile() && entry.name.endsWith('.ts'))
    .map((entry) => path.join(entry.path, entry.name));
}

console.log(listSourceFiles(path.join(__dirname, 'walk', 'src')).map((f) => path.relative(__dirname, f)));
for v in v22.23.3 v24.21.0; do
  echo "=== $v"; ../node-$v-win-x64/node.exe dirent-walker.cjs; echo "exit=$?"
done
=== v22.23.3
[ 'walk\\src\\index.ts', 'walk\\src\\routes\\users.ts' ]
exit=0
=== v24.21.0
node:internal/errors:543
      throw error;
      ^

TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string. Received undefined
exit=1

The stack frames under the TypeError are trimmed here. They all point into node:path and the script’s .map() line, never at readdirSync.

The fix is a one-word change to entry.parentPath, which exists on Node 20.20.2 and later and produced the same file list on both 20 and 24. Because the stack trace points at path.join rather than at dirent, this is the change most likely to cost an afternoon.

OutgoingMessage._headers fails the same quiet way. Old logging and compression middleware read res._headers directly, and on Node 24 that property is undefined instead of an object. As a result, the middleware usually throws a TypeError on the first request it handles. The public res.getHeaders() has existed for years and works on every version tested here.

NODE_OPTIONS Flags That Stop Every Node Process

An unknown flag in NODE_OPTIONS does not produce a warning. Instead, it stops every Node process in the job from starting, including npm itself. CI configs collect these flags over the years, so check the environment block of every workflow file. Each flag below was passed through NODE_OPTIONS to node -e 0.

printf '%-42s%-12s%-12s%-12s%-12s\n' '' v20.20.2 v22.23.3 v24.21.0 v26.10.0
for flag in --experimental-permission --permission --experimental-default-type=module \
  --experimental-network-imports --no-experimental-fetch --openssl-legacy-provider \
  --experimental-require-module --experimental-strip-types --trace-atomics-wait; do
  printf '%-42s' "$flag"
  for v in v20.20.2 v22.23.3 v24.21.0 v26.10.0; do
    out=$(NODE_OPTIONS="$flag" ./node-$v-win-x64/node.exe -e 0 2>&1); c=$?
    if [ $c -eq 0 ]; then w=$(echo "$out" | grep -oE 'Warning|deprecated' | head -1); r="ok${w:+(warn)}"; else r="FAIL"; fi
    printf '%-12s' "$r"
  done
  echo
done
                                          v20.20.2    v22.23.3    v24.21.0    v26.10.0
--experimental-permission                 ok(warn)    ok          FAIL        FAIL
--permission                              FAIL        ok          ok          ok
--experimental-default-type=module        ok          ok          FAIL        FAIL
--experimental-network-imports            ok          FAIL        FAIL        FAIL
--no-experimental-fetch                   ok          ok          FAIL        FAIL
--openssl-legacy-provider                 ok          ok          ok          ok
--experimental-require-module             ok          ok          ok          ok
--experimental-strip-types                FAIL        ok          ok          ok
--trace-atomics-wait                      ok          ok(warn)    FAIL        FAIL

The error is short and specific, which helps once you know to look for it. The full path to node.exe is shortened here:

node.exe: --experimental-permission is not allowed in NODE_OPTIONS
node.exe: --experimental-default-type= is not allowed in NODE_OPTIONS

For the permission model, the rename is the cleanest migration on this list. Node 22 accepts both spellings, so switch to --permission while you are still on 22 and the upgrade becomes a no-op. On the other hand, --no-experimental-fetch has no direct replacement. If a test suite used it to force a polyfill, that polyfill now has to be installed explicitly in the test setup file.

Interestingly, --openssl-legacy-provider still works on every version. That matters because old webpack 4 builds rely on it, and those builds survive the upgrade unchanged.

Native Addons: ABI 137 and the Missing Prebuilt Binary

Among the Node.js 24 breaking changes, this is the one that fails before a single test runs. Node 24 bumped NODE_MODULE_VERSION to 137, so every compiled addon needs a binary built for that ABI. Packages that ship prebuilt binaries only cover the Node versions that existed at their release. An older release of a native package therefore has no binary for 24, and the install falls back to compiling from source. To see this happen, the same install ran on both versions in an empty directory:

for v in v22.23.3 v24.21.0; do
  echo "=== $v"
  mkdir -p addon-$v && cd addon-$v
  PATH="$PWD/../node-$v-win-x64:$PATH" npm install better-sqlite3@11.0.0 --foreground-scripts --no-audit --no-fund 2>&1 \
    | grep -E 'prebuild-install (warn|\|\|)|gyp ERR! find Python|^added|^npm error (code|command)' | head -8
  echo "exit=${PIPESTATUS[0]}"
  cd ..
done
=== v22.23.3
> prebuild-install || node-gyp rebuild --release
added 38 packages in 3s
exit=0
=== v24.21.0
> prebuild-install || node-gyp rebuild --release
prebuild-install warn install No prebuilt binaries found (target=24.21.0 runtime=node arch=x64 libc= platform=win32)
gyp ERR! find Python 
gyp ERR! find Python --python was not set on the command line
gyp ERR! find Python Python is not set from environment variable PYTHON
gyp ERR! find Python checking if the py launcher can be used to find Python 3
gyp ERR! find Python - executable path is ""
gyp ERR! find Python - "" could not be run
exit=1

On Node 22 the prebuilt binary downloaded and the install finished. On Node 24 there was nothing to download, so node-gyp tried to compile and stopped at the first missing tool. A Linux runner with Python installed gets one step further and stops at the compiler instead. Meanwhile, the current release, better-sqlite3 13.0.3, installed from a prebuilt binary on Node 24 and opened an in-memory database without a compiler.

Even when a compiler is present, the source build has a new requirement. The headers that node-gyp downloads for each version set a different C++ standard on Linux and macOS:

for v in v20.20.2 v22.23.3 v24.21.0; do
  curl -sSL https://nodejs.org/dist/$v/node-$v-headers.tar.gz \
    | tar -xzO node-$v/include/node/common.gypi | grep -n "std=gnu++" | sed "s/^/$v: /"
done
v20.20.2: 474:        'cflags_cc': [ '-fno-rtti', '-fno-exceptions', '-std=gnu++17' ],
v20.20.2: 645:              'CLANG_CXX_LANGUAGE_STANDARD': 'gnu++17',  # -std=gnu++17
v22.23.3: 514:          '-std=gnu++17',
v22.23.3: 694:              'CLANG_CXX_LANGUAGE_STANDARD': 'gnu++17',  # -std=gnu++17
v24.21.0: 583:          '-std=gnu++20',
v24.21.0: 756:              'CLANG_CXX_LANGUAGE_STANDARD': 'gnu++20',  # -std=gnu++20

The first line for each version is the Linux flag, and the second is the macOS Xcode setting.

As a result, an addon whose C++ does not compile as C++20, or an old nan version, fails at compile time on 24 even on a fully equipped runner. The fix is almost always to upgrade the dependency rather than to fight the compiler. Check every package with an install or gypfile entry in your lockfile, and confirm its current release publishes binaries for ABI 137.

Which Platforms Did Node.js 24 Drop?

Some Node.js 24 breaking changes are about where Node runs at all. Node 24 publishes no 32-bit Windows build and no 32-bit ARM Linux build. The release directory listing for each version shows the difference directly:

for v in v22.23.3 v24.21.0; do
  echo "== $v"
  curl -s https://nodejs.org/dist/$v/ \
    | grep -oE "node-v[0-9.]+-(win-x86|win-x64|win-arm64|linux-armv7l)\.(zip|tar\.gz)" | sort -u | tr '\n' ' '
  echo
done
== v22.23.3
node-v22.23.3-linux-armv7l.tar.gz node-v22.23.3-win-arm64.zip node-v22.23.3-win-x64.zip node-v22.23.3-win-x86.zip
== v24.21.0
node-v24.21.0-win-arm64.zip node-v24.21.0-win-x64.zip

The linux-armv7l gap is the one that hits CI. Self-hosted runners on older Raspberry Pi boards, and Docker builds that target linux/arm/v7, have no official Node 24 binary to install. The changelog moved armv7 to experimental support, which in practice means no download on nodejs.org. Additionally, the official macOS binaries now require macOS 13.5 or later, and building Node itself needs Xcode 16.1.

How Did node:test Output Change in CI?

Two of the Node.js 24 breaking changes live in the test runner, and they alter what CI sees even when no test code changes.

First, the default reporter is now spec even when output is piped. Node 22 printed TAP when stdout was not a terminal, so a pipeline that piped node --test into a TAP-to-JUnit converter worked by accident. Running the same one-test file piped on both versions shows the difference. Only the first lines of each run are kept here, and the summary counters that follow are identical:

// probes/test-then.cjs
const test = require('node:test');
test('runs', () => {}).then(() => console.log('test() returned a promise'));
for v in v22.23.3 v24.21.0; do echo "=== $v --test"; ../node-$v-win-x64/node.exe --test test-then.cjs | cat; done
=== v22.23.3 --test
TAP version 13
# test() returned a promise
# Subtest: runs
ok 1 - runs
=== v24.21.0 --test
test() returned a promise
✔ runs (0.4323ms)
ℹ tests 1

The fix is to state the reporter explicitly with --test-reporter=tap, or better, to use the built-in JUnit reporter with --test-reporter=junit --test-reporter-destination=junit.xml. If your suite runs on Jest or Vitest instead of node:test, this change does not touch it, and the Jest and Vitest testing guide covers how those runners report to CI.

Second, the runner now waits for subtests that were never awaited. That changes what a test failure looks like:

// test/unawaited-subtest.test.cjs
const test = require('node:test');
const assert = require('node:assert');

test('parent', (t) => {
  // Subtest is not awaited, a pattern that was legal and common before Node 24
  t.test('child', async () => {
    await new Promise((resolve) => setTimeout(resolve, 50));
    assert.strictEqual(1, 2);
  });
});

On Node 22 the child is cancelled with test did not finish before its parent and was cancelled, counted as cancelled 1. On Node 24 it actually runs and reports the real AssertionError, counted as fail 2. Both exit with code 1, so CI fails either way. However, dashboards that track cancelled and failed tests separately will shift, and some tests that were “cancelled” for years start revealing genuine failures.

One change from the 24.0.0 notes did not stick. Node 24.0.0 stopped test() and t.test() from returning promises, and a later 24.x release reverted it. On 24.21.0, test(...).then(...) still works, so code written that way does not need changing.

Deprecation Warnings That Fail Strict Pipelines

The following calls still work on Node 24, but now print a runtime deprecation warning. That only matters if your pipeline treats warnings as errors: with --throw-deprecation, with a log scanner, or with tests that assert a clean stderr. The Node 26 column shows which ones become crashes next.

CallNode 22Node 24Node 26
url.parse()SilentDEP0169 warningDEP0169 warning
spawn(cmd, args, { shell: true })SilentDEP0190 warningDEP0190 warning
fs.F_OK, fs.R_OK, fs.W_OK, fs.X_OKSilentDEP0176 warningundefined
SlowBuffer()SilentDEP0030 warningTypeError
zlib.Gzip() without newSilentDEP0184 warningDEP0184 warning
crypto.fipsSilentDEP0093 warningDEP0093 warning
fs.existsSync(123)SilentDEP0187 warningDEP0187 warning
setTimeout(fn, -1)SilentTimeoutNegativeWarningTimeoutNegativeWarning

The url.parse() warning is the one you will see most, since it fires from inside many HTTP and proxy libraries. The spawn warning is a close second, because build scripts commonly write spawn('npm', ['run', 'build'], { shell: true }) to make .cmd files work on Windows. Here is the warning, verbatim:

(node:10548) [DEP0190] DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.

Note the fs.F_OK row in particular. On Node 26 it returns undefined, and fs.accessSync(file, undefined) silently checks existence instead of failing. That is harmless for F_OK, but fs.R_OK and fs.W_OK become no-op checks, which is a quiet permissions bug rather than a crash.

What Changed in npm 11?

Node 24 bundles npm 11 instead of npm 10. Per the npm 11 changelog, three breaking changes matter in a release pipeline:

  • Publishing a version lower than the current latest, such as a 1.4.1 backport after 2.0.0, now requires an explicit --tag
  • Publishing a pre-release version also requires an explicit --tag
  • --ignore-scripts now applies to all lifecycle scripts, including prepare

The prepare change was checked with a root package whose prepare script writes a file. Running npm install --ignore-scripts skipped it on both npm 10.9.9 and npm 11.19.0, so the root package behaves the same. If the change bites you, look further down the tree instead, at dependencies that build themselves in prepare. Separately, if you compare package managers on Node 24, the pnpm vs npm vs Yarn install time measurements ran on the same npm 11 line.

Coming From Node 20: Two More Changes

Teams jumping straight from Node 20 also inherit what changed in 22. The probes surfaced one crash and one welcome change.

Import assertions with assert are a syntax error from Node 22 onwards. Node 20 accepted both keywords, so code written for 20 may still use the old one:

// Fails on 22, 24 and 26 with: SyntaxError: Unexpected identifier 'assert'
import config from './config.json' assert { type: 'json' };

// Works on 20, 22, 24 and 26
import config from './config.json' with { type: 'json' };

On the brighter side, TypeScript type stripping works without a flag on 22.23.3 and 24.21.0. A .ts file with type annotations failed on Node 20 with SyntaxError: Missing initializer in const declaration and ran on both newer versions. As a result, some ts-node steps in CI become removable, though not for code that uses enums or parameter properties.

How to Find Node.js 24 Breaking Changes Before You Switch

The most useful fact from the probes is that six of the seven crashes on 24 print a deprecation warning on 22 first. So the cheapest detector is your existing test suite, run on Node 22 with warnings turned into errors. Here is the order that works for catching Node.js 24 breaking changes early:

  1. Run the full test suite on Node 22 with NODE_OPTIONS=--throw-deprecation and fix every failure it reports
  2. Grep your own source for .path on Dirent objects, since no flag catches that one
  3. Remove or rename retired flags in every NODE_OPTIONS value across workflow files, Dockerfiles and .npmrc
  4. List native dependencies and confirm each one publishes prebuilt binaries for ABI 137
  5. Add Node 24 to the CI matrix next to 22, and keep both green for at least a week
  6. Pin the major version explicitly instead of lts/*, then drop 22 from the matrix

For step 1, here is what --throw-deprecation caught on Node 22.23.3 for each probe that crashes on 24:

cd probes
for p in util-isFunction util-log process-assert tls-createSecurePair fs-truncate-fd http-_headers dirent-path; do
  out=$(../node-v22.23.3-win-x64/node.exe --throw-deprecation $p.cjs 2>&1); c=$?
  printf '%-22s throw-deprecation exit=%s %s\n' "$p" "$c" "$(echo "$out" | grep -oE 'DEP[0-9]+' | head -1)"
done
util-isFunction        throw-deprecation exit=1 DEP0049
util-log               throw-deprecation exit=1 DEP0059
process-assert         throw-deprecation exit=1 DEP0100
tls-createSecurePair   throw-deprecation exit=1 DEP0064
fs-truncate-fd         throw-deprecation exit=1 DEP0081
http-_headers          throw-deprecation exit=1 DEP0066
dirent-path            throw-deprecation exit=0

Importantly, npm 10.9.9 and npm 11.19.0 both ran cleanly with --throw-deprecation in NODE_OPTIONS, so the flag does not break installs. It does, however, turn warnings from your dependencies into failures as well. That is the point, since the dependency calls are the ones you cannot see by reading your own code.

For step 2, a grep catches most direct calls in your own source. It found every crashing probe except require('node:util').log(, which is written in a form the pattern does not match:

grep -rnE "util\.(log|isBoolean|isBuffer|isDate|isError|isFunction|isNull|isNullOrUndefined|isNumber|isObject|isPrimitive|isRegExp|isString|isSymbol|isUndefined)\(|process\.assert\(|createSecurePair|\._headers\b|\.truncate\(fd|[A-Za-z]\.path\b.*(name|Dirent)|assert \{ *type" \
  --include='*.cjs' --include='*.mjs' --include='*.js' --include='*.ts' src/

For steps 5 and 6, the CI change is small. This GitHub Actions job runs both majors with deprecations as errors, and pins each one by number so the lts/* switch on 2026-10-28 cannot move it:

# .github/workflows/ci.yml
name: ci
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        node: ['22', '24']   # explicit majors, never lts/*
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: npm
      - run: npm ci
      - run: npm test
        env:
          NODE_OPTIONS: --throw-deprecation

Setting --throw-deprecation only on the test step keeps npm ci independent of it, which is a sensible default even though npm ran cleanly with it here. If the CI pipeline itself is new to you, the GitHub Actions guide for Node.js projects covers caching and job layout in more depth.

Finally, every replacement listed in this post was combined into one file and run with --throw-deprecation on all four versions:

v20.20.2: all replacements passed
v22.23.3: all replacements passed
v24.21.0: all replacements passed
v26.10.0: all replacements passed

So the fixes work before and after the upgrade, which means you can ship them on 22 first and switch the runtime in a separate change.

Real-World Scenario: A Monorepo Pinned to lts/*

Consider how the Node.js 24 breaking changes typically land on a small platform team running a TypeScript monorepo with a dozen packages, an Express API and a handful of build scripts. The CI workflow uses node-version: lts/*, the Dockerfile starts from node:lts-alpine, and the pipeline has been green for over a year. Because both resolve to the newest LTS, the team never chose to upgrade. The runtime moved under them, from 22 to 24 in late 2025.

In a setup like this, failures typically arrive in the order the pipeline runs. First, a NODE_OPTIONS value set years ago for a permission experiment stops every step, including npm ci, with a one-line error. Once that flag is renamed, the install fails on an old native dependency pinned in a lockfile nobody has touched. Upgrading it fixes the install, and then a code generation script crashes inside path.join because it reads dirent.path.

None of those three is hard to fix. The trade-off is that each one appears only after the previous fix, so the upgrade feels like a series of surprises spread over several days. Running the six steps above on Node 22 first turns that into one planned change. Moreover, pinning the major stops the same thing from happening on 2026-10-28, when lts/* moves again, this time to 26.

When to Upgrade CI to Node.js 24 Now

  • Your pipelines still run Node 20, which stopped receiving security fixes on 2026-04-30
  • Your workflow or Dockerfile uses lts/* or node:lts and you want to control the next jump yourself
  • Your test suite passes on Node 22 with --throw-deprecation, so the crash list above does not apply to you
  • You want unflagged TypeScript type stripping, URLPattern as a global, or explicit resource management with using
  • Your deployment targets, such as Lambda or your container base image, already offer Node 24

When NOT to Move CI to Node.js 24 Yet

  • A native dependency has no ABI 137 binary and no maintained release, so the upgrade needs a library swap first
  • Self-hosted runners or deploy targets are 32-bit ARM or 32-bit Windows, which have no official Node 24 build
  • Production still runs Node 22 and cannot move in the same release, since CI should test what you ship
  • You rely on --no-experimental-fetch or --experimental-default-type, and the replacement is not in place yet

Common Mistakes When Upgrading to Node.js 24

  • Reading only the 24.0.0 changelog, when the util.is* and process.assert removals happened in Node 23
  • Leaving lts/* in workflows and assuming the version will stay on 24 after 2026-10-28
  • Treating deprecation warnings on Node 22 as noise instead of as the list of next crashes
  • Searching for dirent.path only through warnings, when Node 22 never prints one
  • Adding Python and build tools to the runner to compile an old addon, instead of upgrading the package
  • Piping node --test into a TAP parser without setting --test-reporter=tap
  • Upgrading CI to 24 while production stays on 22, so tests pass on a runtime nobody runs
  • Assuming npm publish of a backport still lands without --tag after the move to npm 11

Next Steps After the Node.js 24 Upgrade

Most Node.js 24 breaking changes are old deprecations reaching end of life, which is good news. Six of the seven crashes found here announce themselves on Node 22 if you run the tests with --throw-deprecation. The changes that hurt are the quiet ones: dirent.path, retired NODE_OPTIONS flags, and native packages with no ABI 137 binary. Start today by running your suite on 22 with that flag, then pin the major in CI before lts/* moves to 26 at the end of October. If your Docker images move to Node 24 at the same time, the Docker base image size comparison shows what each node tag costs. Furthermore, the Bun vs Node vs Deno measurements are worth a look if the upgrade has you reconsidering the runtime altogether.