Skip to content

Node.js 20 to 24 Migration Guide: What Breaks and Why

Upgrading Node.js from v20 to v24? Here's every breaking change that will bite you, with exact fixes and a migration checklist.

· · 10 min read

Updated: September 30, 2026

Server room aisle lined with equipment racks

Quick Take

Node.js 22 and 24 are both LTS releases, and v20 maintenance ends April 2026. The biggest breaking change is `require()` behavior for ES modules: experimental in v22.0, and no flag needed from v22.12.0 (and v20.19.0). Removals that will bite you include `crypto.createCipher/createDecipher`, deprecated `url.parse()` patterns, and some internal `process.binding()` calls. The features actually worth adopting are a much shorter list than the changelog suggests.

Node.js uses an even-numbered LTS schedule: v20 maintenance ends April 2026, v22 is the current active LTS (active through October 2026), v24 dropped in April 2025. The biggest change between v20 and v22 is require() behavior for ES modules, v22.0 added experimental support behind --experimental-require-module, and the flag stopped being needed in v22.12.0. Files with top-level await still throw ERR_REQUIRE_ASYNC_MODULE. Two hard removals: crypto.createCipher() and crypto.createDecipher() are gone in v22; migrate to createCipheriv() with an explicit IV. New capabilities in v22: native fetch is stable (drop node-fetch), fs.glob() and fs.promises.glob() handle basic file patterns natively, --experimental-strip-types lets .ts files run directly without a build step (no enums or decorators), and the global WebSocket constructor matches the browser API for client connections. When migrating, update @types/node to match your target version and test on v22 before jumping to v24.

Node.js follows a predictable LTS schedule, even-numbered versions (18, 20, 22, 24) are Long-Term Support. If you're on v20, its active LTS period ended October 2024. It gets security patches until April 2026, then nothing. V22 is the current active LTS; v24 dropped in April 2025. I checked the claims below against the Node.js docs and, where a snippet could run, against Node 20.19.5 and 22.23.2 installed side by side. The monorepo behind this site is a useful test bed for one thing: its default Node is 20.19.5, and Astro 7 refuses to start on it until you switch to v22, which is how this upgrade actually begins here. The changelog is a poor guide to which parts hurt, so this is the short list of what does.

What Does the LTS Timeline Look Like?

Don't jump from v20 straight to v24 without testing on v22 first. The bulk of breaking changes landed in v22, that's where things break in your CI.

VersionStatusActive LTS EndMaintenance End
Node.js 18End of lifeOctober 2023April 2025
Node.js 20MaintenanceOctober 2024April 2026
Node.js 22Active LTSOctober 2026April 2027
Node.js 24Active LTS (current default)October 2027April 2028
Node.js 26Current, non-LTS until October 2026--

V20 will receive security patches until April 2026. That's your real deadline, not today. If you're on v20 in production right now, treat this as the last quiet window to migrate.

Update, July 2026: Node.js 25 has already come and gone, it was a Current-only release with no LTS line, and it hit end of life in June 2026 without ever being production-recommended. Node.js 26 shipped in April 2026 and is the release to actually target if you're planning a fresh migration today; it stays Current (non-LTS) until October 2026, then becomes Active LTS. More importantly, the Node.js project announced in June 2026 that it's retiring the old even/odd release cadence: starting with Node.js 27, there will be one major release per year (April), with every release promoted to LTS status that October instead of every other one. If you're mapping out a migration for late 2026 or 2027, plan around that new yearly rhythm rather than the six-month cadence this guide originally assumed.

Hands typing on a keyboard lit in red, evoking a late-night migration debugging session
Photo by Wesley Tingey on Unsplash

What Changes with require() and ES Modules?

Module interop refers to how CommonJS require() and ECMAScript Module import syntax cooperate inside the same codebase, and Node.js 20 through 24 change the rules three times. This is the one I'd audit first. Node.js v22.0 introduced experimental support for require() of synchronous ES modules behind the --experimental-require-module flag. According to the Node.js docs, the flag stopped being needed in v23.0.0, v22.12.0 and v20.19.0, so on a current v20 or v22 patch release it just works. I ran a two-line check to be sure: on Node 20.19.5 and 22.23.2, require('./utils.mjs') returned the export, and a module with top-level await threw ERR_REQUIRE_ASYNC_MODULE on both.

Here's what that means in practice:

// Node.js 20.18 and earlier, throws ERR_REQUIRE_ESM
const utils = require('./utils.mjs');

// Node.js 20.19+, 22.12+, 23+: works if utils.mjs has no top-level await
const utils = require('./utils.mjs');

The catch: files with top-level await still throw, and the error is clear:

Error [ERR_REQUIRE_ASYNC_MODULE]: require() of an ES Module
that uses top-level await is not supported.

Fix for older v22: On v22.11 and earlier, add --experimental-require-module to your start script while testing. On v20.19+, v22.12+ and v23+, nothing extra is needed.

Fix for top-level await: Move that logic into an async factory function instead. If you want the module to be require()-able, it can't have top-level await.

TypeScript projects using "module": "NodeNext", check your package.json type field. If it's "type": "module", all .js output is treated as ESM. This is now a feature rather than a problem on v23+, but your team needs to understand it.

Why Are crypto.createCipher and createDecipher Removed?

These were deprecated since Node.js v10, but a lot of code still uses them. They're gone in v22. Key derivation is the process of turning a password into a fixed-length cryptographic key, and createCipher() did this insecurely with a single round of MD5 hashing and no salt. createCipheriv() replaces it by requiring you to supply an explicit initialization vector and a properly derived key, which closes off a class of attacks where two identical plaintexts encrypted with the same password produce identical ciphertext blocks.

// Throws in v22: TypeError: crypto.createCipher is not a function
const cipher = crypto.createCipher('aes192', password);

Fix: Migrate to createCipheriv, which requires an explicit initialization vector. That's the secure version anyway:

import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';

const iv = randomBytes(16);
const key = Buffer.from(yourKey, 'hex'); // 32 bytes for AES-256
const cipher = createCipheriv('aes-256-cbc', key, iv);

Store the IV alongside the ciphertext. You'll need it to decrypt. The crypto.createCipher API derived the key from a password insecurely, this migration also fixes a real security issue.

The crypto.createCipher API derived the key from a password insecurely. This migration is a breaking change, but it also fixes a real security issue.

Share this Post on X Bluesky

Why Does the node: Prefix Become the Convention?

Built-in modules work with or without the node: prefix in v22. But tooling, ESLint's unicorn ruleset, type-checking tools, import analyzers, increasingly expects it. It also eliminates any ambiguity between a built-in and an npm package with the same name. In a monorepo with an internal package scope the risk is easy to picture: an internal package whose name matches a built-in module resolves differently depending on whether the import carries the prefix, and that's a bad thing to debug at upgrade time.

The node: prefix removes ambiguity between Node's built-in modules and same-named npm packages. It is not required in Node.js 22, but the unicorn/prefer-node-protocol rule from eslint-plugin-unicorn will fail CI without it once you enable it.

Share this Post on X Bluesky
// Both work, but node: is the current convention
import fs from 'node:fs/promises';
import path from 'node:path';
import crypto from 'node:crypto';

Is this a breaking change? Not technically, your old imports still work. But if you're adding the unicorn/prefer-node-protocol ESLint rule (which ships in the unicorn plugin's recommended preset), your linter will fail CI until you update them.

Fix: Run a quick sed over your source or use ESLint's --fix flag:

npx eslint src --rule "unicorn/prefer-node-protocol: error" --fix

How Do You Use fs.glob() to Drop the glob Package?

Node.js v22 ships fs.glob() and fs.promises.glob() natively. No more glob as a dependency for basic file matching:

import { glob } from 'node:fs/promises';

for await (const file of glob('src/**/*.ts')) {
  console.log(file);
}

// Or collect into array
const files = await Array.fromAsync(glob('src/**/*.ts'));

Array.fromAsync() collects an async iterable into an array, much like toArray() does at the end of a chain of JavaScript iterator helpers.

The npm glob package still works and supports more advanced options. Replace it only after verifying your specific patterns match. Simple **/*.ext patterns work fine natively.

How Does TypeScript Type Stripping Work?

Node.js v22.6.0 added --experimental-strip-types. By v24, you can run .ts files directly for many use cases. Type stripping means the runtime deletes type annotations character-for-character without checking or compiling them, which is why it runs fast but cannot support syntax that has no direct JavaScript equivalent, such as enums or decorators.

# v22.6+
node --experimental-strip-types scripts/seed.ts

# v23+
node --experimental-strip-types src/server.ts

Limitations are real: no enum, no namespace, no experimentalDecorators, no path aliases. The flag strips type annotations but doesn't transform TypeScript syntax. For scripts without those features it's genuinely useful. This monorepo already leans on it in CI: the secret scan step in the validate workflow runs as node --experimental-strip-types packages/shared/src/security/scan-secrets.ts ., so it needs no yarn install before it. My guess is that path aliases, not enums, are what stop most projects from doing the same.

For production services, tsx or ts-node are still more reliable. The feature is worth watching for v24's final LTS release. For a deeper look at what native TypeScript support actually means, what it handles, what it skips, and when to still reach for ts-node, see Node.js native TypeScript without ts-node.

How Do You Use the Built-in WebSocket Client?

Node.js v22 added a global WebSocket constructor matching the browser API, no ws package needed for client connections. If a package pulls in ws purely as a client and never opens a server, swapping to the global constructor deletes the dependency outright. That is a small win per package and a real one across a monorepo, because the same transitive tree gets installed once per workspace that asks for it.

Node.js 22 ships a global WebSocket constructor that matches the browser API for outbound client connections. Teams using ws only as a client, not a server, can drop the dependency entirely on v22 and later without changing application code.

Share this Post on X Bluesky
// No npm install ws
const socket = new WebSocket('wss://stream.example.com');

socket.addEventListener('open', () => {
  socket.send(JSON.stringify({ type: 'subscribe', channel: 'prices' }));
});

socket.addEventListener('message', (event) => {
  const data = JSON.parse(event.data);
});

If you use ws only as a WebSocket client (not server), you can remove it for v22+ targets. WebSocket server functionality still requires the ws package.

Three monitors in a dark room, one of them filled with an open code editor
Photo by David Schultz on Unsplash

How Do You Run the Migration Checklist?

Before bumping the Node.js version in your Dockerfile or .nvmrc:

  1. Search for crypto.createCipher and crypto.createDecipher, migrate to createCipheriv
  2. Check dynamic require() of .mjs files, and note whether you're on v22.12+ (no flag needed) or older (needs --experimental-require-module)
  3. Search for url.parse(), migrate to new URL(urlString, baseUrl)
  4. Update @types/node to match your target: npm install -D @types/node@22
  5. Run your full test suite on v22 in CI before touching prod
  6. Update the engines field in package.json:
{
  "engines": {
    "node": ">=22.0.0"
  }
}

That last step gets skipped constantly. It prevents your package from being accidentally run under an older Node.js by a fresh install on a different machine.

Frequently Asked Questions

Do I need to go through Node.js 22 before upgrading to 24?
You don't have to, but you should. Most breaking changes landed in v22, and running your test suite on v22 first lets you isolate what breaks where. A single-version jump is easier to debug than a two-version jump.
Will require() of.mjs files work in Node.js 24?
Yes. According to the Node.js docs, require() of synchronous ES modules stopped needing a flag in v23.0.0, v22.12.0 and v20.19.0. If your.mjs file has no top-level await, you can require() it directly in v24. Files with top-level await still throw ERR_REQUIRE_ASYNC_MODULE.
Is --experimental-strip-types safe for production in Node.js 24?
Not yet for most teams. It strips type annotations but doesn't transform TypeScript syntax, no enum, no namespace, no decorators. For scripts and CLI tools, it's fine. For production services with complex TypeScript, use tsx or esbuild-register.
What happened to the glob npm package after fs.glob() landed?
The glob npm package still works and is widely used. Node's native fs.glob() is a reasonable replacement for simple patterns, but the npm package supports more advanced options and is more battle-tested. Drop it only after testing your specific patterns.