Migrate Node.js 20 to Node.js 24 across every repo in your org

How to Migrate from Node.js 20 to Node.js 24 Across Every Repo in Your Org

Matthew Holmes

Matthew Holmes

August 11, 2026 · // 14 min read

Node.js 20 reached end of life on April 30, 2026. It is more than three months past that date and still running in production.

That is not negligence. Node 20 “Iron” shipped a stable built-in test runner, a permission model for fine-grained resource access, V8 11.3, ARM64 Windows support, and a 30-month support window. Teams that optimize for stability standardized on it, and standardizing on it meant writing the version number into a lot of files.

All of those files have to change now. Node 20 gets no more security patches. A CVE that affects both Node 20 and a supported line gets fixed in the supported line only.

Migrating Node.js 20 to Node.js 24 across an organization takes two passes on every repository. The first updates every place the version is declared: package.json engines, .nvmrc, Dockerfile base images, CI matrices, and Kubernetes manifests. The second audits every native dependency for Node 24 ABI compatibility. The second pass is where migrations stall.

What does a Node.js 20 to 24 migration actually involve?

Two passes over every repository, with different failure modes.

The version pass. Update the engines field in package.json. Change the Docker base image tag. Update .nvmrc, .node-version, and asdf config. Update the CI matrix. Update any Kubernetes manifest that pins a Node image. Every declaration of the version has to agree.

The native-dependency pass. Any package that ships a .node file or installs through node-gyp compiles against a specific Node ABI. A module built for Node 20 may or may not run under Node 24, and when it does not, it usually crashes at runtime instead of failing at import. The install succeeds. CI passes. The service breaks in production on a specific input.

Packages to audit: bcrypt, sharp, sqlite3, canvas, node-sass, puppeteer, oracledb, and the Prisma engines. Some download a prebuilt binary per Node version at install time. Some compile from source. Both have to run against Node 24 before the migration ships.

Should you target Node 22 or Node 24?

Node 24 is the Active LTS line through April 30, 2028. Node 22 moved to Maintenance LTS in October 2025 and reaches end of life on April 30, 2027. Node 26 shipped in May 2026 as the Current line and enters LTS in October 2026.

For a fleet migration starting today, target Node 24.

Node 22 buys eight months over Node 20 and puts the same coordination work back on the calendar. It is a bridge for one service blocked by a specific dependency, not a destination for the fleet. Node 26 is not on an LTS line until October, so it does not belong in production yet.

Going to 22 first and 24 later feels safer because the jump is smaller. It costs the full coordination pass twice for the same result. Unless a named dependency blocks Node 24, go straight to 24.

The Node.js project is also retiring the odd/even release model starting with Node.js 27, and Node 26 is the last line under the current schedule. Any internal runbook that says “even numbers become LTS” needs rewriting within the year.

The rest of this guide assumes Node 24. The pattern applies to Node 22 with the version number substituted.

The mechanical fix list

Run this across every repo in scope.

Node 20 patternNode 24 replacement
"engines": { "node": ">=20" } or "^20" in package.json"engines": { "node": ">=24" } or "^24"
nvm use 20 in scriptsnvm use 24
20.11.0 in .nvmrc24.X.Y (latest LTS patch)
20.11.0 in .node-version24.X.Y
FROM node:20-alpine or node:20-slim in DockerfilesFROM node:24-alpine or node:24-slim
node-version: '20' in .github/workflows/*.ymlnode-version: '24'
node-version: '20.x' in CircleCI confignode-version: '24.x'
image: node:20 in Kubernetes manifestsimage: node:24

Four things sit outside the table and get missed.

@types/node has to move to ^24.0.0 so the type definitions match the runtime.

For TypeScript, confirm the tsconfig.json target still compiles. Node 24 supports ES2023 natively, so "target": "es2023" or "esnext" is safe.

For build tooling, confirm the bundler config still works. Most bundlers survive the jump untouched. esbuild sometimes needs a version bump.

Regenerate the lockfile. npm install or yarn install under Node 24 resolves native modules to Node 24 builds, which changes the platform-specific entries. Commit the result.

The native-dependency audit: the step teams skip

Find every native dependency in the repo before touching anything else. Each one can break under Node 24.

# List all native modules installed
find node_modules -name "*.node" 2>/dev/null | sed 's/.*node_modules\///' | awk -F/ '{print $1}' | sort -u

For each package on that list, do one of three things: read its published Node compatibility matrix, run npm rebuild and see whether native compilation fails, or run the test suite against a real code path. The third catches the most. A module that installs cleanly can still fail once its functions run.

The usual suspects:

bcrypt publishes prebuilt binaries per Node version. Confirm a Node 24 build exists on the releases page.

sharp usually supports a new Node major within days of release. Confirm the lockfile version is recent enough.

sqlite3 ships prebuilt binaries and has lagged on new majors before.

node-sass has been deprecated for years. Replace it with sass (Dart Sass), which is pure JavaScript and has no native compilation step.

puppeteer bundles Chromium but carries its own Node requirements. Confirm the current version supports Node 24.

canvas is the hardest, because it links against Cairo and Pango, which may need updating alongside it.

The fix is not always a version bump. Sometimes the package gets replaced, as with node-sass to sass. Sometimes it gets deleted, because it serves one rarely-exercised path. Sometimes the answer is to wait, which is rare by mid-2026 but possible for niche packages.

Flag native dependencies for a human instead of auto-patching them. A wrong guess here costs more than the time it saves.

A prompt you can run today

For teams already running AI-assisted code changes, hand it this, repo by repo:

Migrate this repository from Node.js 20 to Node.js 24.

1. Update the version declarations:
   - "engines": { "node": ">=24" } in package.json
   - .nvmrc updated to 24.X.Y (latest LTS patch)
   - .node-version updated to 24.X.Y
   - FROM node:24-alpine or node:24-slim in Dockerfiles (every stage)
   - node-version: '24' in GitHub Actions, CircleCI, and other CI configs
   - Kubernetes deployment images updated from node:20 to node:24

2. Update @types/node to ^24.0.0 in package.json.

3. List every native dependency in package.json (any package with a
   .node file or that installs via node-gyp).

4. For each native dependency, flag for review whether the current
   version supports Node 24. Common ones to audit: bcrypt, sharp,
   sqlite3, canvas, node-sass (should be replaced with sass),
   puppeteer, oracledb.

5. Regenerate package-lock.json or yarn.lock by running install
   under Node 24. Commit the regenerated lockfile.

6. Update the tsconfig.json target if it is older than es2020.
   es2023 or esnext are safe under Node 24.

7. Run the existing test suite under Node 24 before merging.
   Treat native-dependency runtime errors as real bugs, not flakiness.

That handles one repository. What stalls the migration is twenty Node services with different native dependency sets, each owned by a different team.

Why do runtime migrations stall across a large codebase?

Because there is no single change to make.

Twenty repositories on Node 20 are twenty different migrations that happen to share a name.

Two services both depend on bcrypt. One pins [email protected], the other [email protected], and only one of those runs on Node 24. Someone resolves that per repo.

One service has a single-stage Dockerfile. The next has a multi-stage build where the builder and the runtime both pin Node 20. A third builds from an internal base image that pins Node 20 itself, so the base image migrates before anything downstream can move.

One service runs a CI matrix across three Node versions. Another pins one and has never tested a second. A third has been testing against Node 21, which was never an LTS line and reached end of life two years ago, and nobody noticed.

Each variation needs a decision, a test pass, a pull request, a reviewer, and a merge. None of it is hard. All of it needs an owner who follows it to completion across teams that have their own roadmaps.

That is why Node 20 is still in production in August 2026. The technical fix was never the blocker. Getting it into every repository and knowing which ones are done is what runs long.

“No more spreadsheets, no more chasing PRs, the real value is in the coordination,” is how one head of platform engineering described what changes when that tracking exists.

How to migrate Node.js across every repo in an organization

The change is already defined. The version pass and the native-dependency audit above cover the code. What is missing is a way to apply the same change to every repository and know when the last one lands.

  1. Inventory every repository that still declares Node 20 in package.json, .nvmrc, or a Dockerfile base image.
  2. Pick one target version for the whole fleet and record the exceptions instead of letting each team choose independently.
  3. Write the change once, as a single set of instructions covering version declarations, @types/node, and lockfile regeneration.
  4. Generate the change per repository against that repo’s actual dependency tree, Dockerfile stages, and CI config.
  5. Flag every native dependency for human review before any pull request opens.
  6. Open one pull request per repository, owned by the team that owns the service, and track merge status from one place.

Tidra is an AI coding agent for both implementation and coordination of code changes across your organization. For this migration: scope one initiative by filter to the repos still declaring Node 20, paste the prompt above as its instructions, and Tidra generates the change per repo against that repo’s own dependency tree and Docker setup. The plan comes back for review before anything is touched, with native dependencies flagged rather than edited silently. A repo blocked on a dependency gets a different target. Pull requests then open in bulk, one per repository, and merge status shows up in one dashboard instead of a spreadsheet.

Engineers keep the judgment calls. Whether [email protected] gets bumped or replaced is still a human decision. What changes is that twenty repositories stop being twenty side projects and become one initiative with one owner and a status anyone can read.

“It’s the only way to easily make an upgrade across 300 repos at once,” is how one platform engineer put the scale question plainly.

What the diff looks like in one repo

--- package.json
+++ package.json
@@ -6,10 +6,10 @@
   "main": "dist/index.js",
   "engines": {
-    "node": ">=20.0.0"
+    "node": ">=24.0.0"
   },
   "scripts": {
     "start": "node dist/index.js"
   },
   "dependencies": {
     "express": "^4.19.2",
-    "bcrypt": "^5.0.1"
+    "bcrypt": "^5.1.1"
   },
   "devDependencies": {
-    "@types/node": "^20.11.0",
+    "@types/node": "^24.0.0",
     "typescript": "^5.4.5"
   }
--- Dockerfile
+++ Dockerfile
@@ -1,4 +1,4 @@
-FROM node:20-alpine AS builder
+FROM node:24-alpine AS builder

 WORKDIR /app
 COPY package*.json ./
@@ -7,7 +7,7 @@ COPY . .
 RUN npm run build

-FROM node:20-alpine
+FROM node:24-alpine
 WORKDIR /app
 COPY --from=builder /app/dist ./dist
 COPY package*.json ./
--- .github/workflows/ci.yml
+++ .github/workflows/ci.yml
@@ -12,7 +12,7 @@ jobs:
       - uses: actions/checkout@v4
       - uses: actions/setup-node@v4
         with:
-          node-version: '20'
+          node-version: '24'
       - run: npm ci
       - run: npm test

That is one repository’s pull request. Twenty repositories produce twenty of them, each generated against that repo’s actual package.json and Dockerfile, each opened for the team that owns it.

Node.js migration checklist

A per-repo gate before calling the migration done:

  • engines field in package.json updated to >=24.0.0
  • .nvmrc, .node-version, and asdf config updated
  • Docker base image updated in every stage of every Dockerfile
  • CI matrix updated to Node 24
  • Kubernetes manifests with pinned Node images updated
  • @types/node updated to match the target runtime
  • Every native dependency confirmed against Node 24
  • Lockfile regenerated under Node 24 and committed
  • Full test suite passing under Node 24 at parity with the Node 20 pass rate
  • Native dependencies exercised by smoke test, not just by successful install

What usually breaks after a Node.js migration ships

Almost always the same four things.

A native dependency that installs fine and crashes at runtime. The install pulled a prebuilt binary. It works for most inputs and segfaults on one. CI passes, staging passes, production finds it. This is what the audit catches.

A subtle API behavior change. Node’s built-in modules shift behavior across majors without breaking the type system. Fetch response streaming, fs.promises error handling, and Buffer allocation defaults are the recurring ones. Most services are unaffected. The ones that are usually catch it in staging, if their tests exercise the right paths.

A permission-model side effect. Node 24 has more mature permission-model support. A service quietly doing a filesystem or network operation the model restricts fails with a permission error. Easy to diagnose once you know to look.

Deprecation warnings that are now hard errors. Node has been deprecating parts of the crypto and url APIs for several majors. Node 24 turns some of those warnings into errors. Grep for anything that emitted a DeprecationWarning under Node 20 and fix it first.

FAQ

When did Node.js 20 reach end of life? April 30, 2026. As of August 2026, Node 20 is more than three months past EOL and gets no further security patches. Vulnerabilities disclosed against Node 22, 24, or 26 that also affect Node 20 stay unpatched in Node 20 permanently.

Should I target Node 22 or Node 24? Node 24. It is the Active LTS line through April 30, 2028, which is the longest runway before the next migration. Node 22 has been in Maintenance LTS since October 2025 and reaches EOL on April 30, 2027, which makes it a bridge for a service blocked by a specific dependency rather than a fleet target.

What breaks in Node.js 24? Most breakage comes from native modules compiled against the older Node ABI. Some Node 20 deprecation warnings become hard errors. Fetch behavior has stabilized, so some previously undefined behavior is now defined. Permission-model restrictions can catch code doing filesystem or network operations in unexpected places.

How do I check what Node version my Docker containers actually run? docker run <image> node --version. Teams regularly find containers still running Node 20 after the engines field was updated, because a base image tag was left unchanged.

Can a Node.js migration be automated? The version pass automates well: version declarations, Docker images, CI matrices, @types/node. The native-dependency audit automates partially. Listing the native modules is trivial. Confirming each one supports the target runtime still needs a human to read the compatibility matrix and run a smoke test.

How do you migrate Node.js across many repositories at once? Define the version bump and the native-dependency audit once, then run it as a single initiative scoped to every repository still on Node 20. Each repo gets its own generated change and its own pull request, reviewed by the team that owns the service, tracked from one place instead of a spreadsheet per team.


Node 20 hit EOL in April. The next runtime EOL is already on the calendar and the repository count will be higher by then. The only open question is whether that one takes another quarter.

Ready to run this across your services? Connect your Git provider and Tidra opens pull requests in every repo that needs them: tidra.ai/get-started/