Skip to main content

Node.js 20 to 22 Upgrade: Node 20 Hits EOL 2026

·PkgPulse Team
0

The Node.js LTS Cycle and Why It Matters

Node.js releases follow a predictable Long-Term Support cycle that determines which versions receive security patches and how long they remain viable for production use. Understanding this cycle is important for both planning upgrades and deciding which version to use for new projects.

Every even-numbered Node.js version (18, 20, 22) follows the LTS track. A new even version is released each April, enters "Current" status where new features arrive, transitions to "Active LTS" in October (the recommended state for production), becomes "Maintenance LTS" 12 months later (critical security only), and reaches "End-of-Life" 30 months after initial release. Odd-numbered versions (19, 21, 23) are non-LTS feature releases that reach EOL in 6 months — never run these in production.

Node.js 20 entered Maintenance LTS in October 2024 and reaches End-of-Life in April 2026. "End-of-Life" has a specific meaning: zero patches of any kind, including critical security vulnerabilities. Running Node.js 20 in production after April 2026 means running unpatched software. For applications handling sensitive data or serving public traffic, this is not a theoretical risk.

Node.js 22 entered Active LTS in October 2024 and remains in Active LTS through October 2026, meaning it receives all security patches and important bug fixes during that period. It's the correct version for new projects and the upgrade target for production Node.js 20 deployments.

TL;DR

Yes, upgrade to Node.js 22. It's been LTS since October 2024, the V8 engine upgrade brings real performance gains, require(ESM) finally works (no more createRequire hacks), and the built-in test runner is now production-worthy. Node.js 20 reaches end-of-life in April 2026 — if you haven't moved yet, now is the time. Most apps upgrade in under a day with zero code changes.

Key Takeaways

  • Node.js 20 EOL: April 2026 — no more security patches after that date
  • require(ESM) unlocked — the biggest DX win in years, ends the CJS/ESM interop pain
  • V8 12.4 — Array.fromAsync, Set operations (union/intersection/difference) natively
  • Test runner matured--experimental flag removed, coverage built-in, snapshot testing
  • Performance: +8-15% throughput on typical web server workloads vs Node.js 20

What to Expect: Upgrade Timeline and Team Rollout

For most applications, the Node.js 20 to 22 upgrade follows the same pattern as any other runtime version bump. The actual application code rarely needs changes — the breaking changes in Node.js 22 are narrow and well-documented. The upgrade timeline is typically:

Day 1: Update local development environment (nvm install 22 && nvm use 22), update .nvmrc, run test suite. Fix any issues found. For most projects, this takes under an hour with zero test failures.

Day 2: Update CI/CD pipelines to use Node.js 22. Verify the full test suite passes in CI. If you use Dependabot or Renovate, check if any dependency updates become available now that you're on a newer runtime.

Day 3-7: Update staging environment, monitor for any runtime-specific issues in staging. Run your load tests if you have them — verify the performance characteristics match expectations.

Production rollout: Update the Dockerfile and any infrastructure-as-code that specifies the Node.js version. Roll out with your standard deployment process. Monitor error rates for 24-48 hours after rollout.

For Node.js embedded in Lambda functions or container images, the runtime version is specified at deployment time. AWS Lambda's Node.js 20 runtime reaches end of support in alignment with the upstream EOL. Planning the upgrade before EOL avoids the risk of a forced migration under time pressure.

What Actually Changed in Node.js 22

Node.js 22 release timeline:
April 2024:     Node.js 22.0 (Current)
October 2024:   Node.js 22 becomes LTS ("Jod")
April 2026:     Node.js 20 reaches End-of-Life
April 2027:     Node.js 22 maintenance LTS begins
April 2028:     Node.js 22 End-of-Life

The LTS cycle matters:
→ "Current": new features, may have breaking changes
→ "Active LTS": stable, security patches, recommended for production
→ "Maintenance LTS": critical security only
→ "EOL": no patches of any kind

Node.js 20 is in Maintenance LTS now.
Node.js 22 is in Active LTS — the right choice for new projects and upgrades.

The Big One: require(ESM) Works Now

The CJS/ESM interoperability problem has been the most significant ongoing pain point in the Node.js ecosystem since ESM modules were standardized. Thousands of npm packages migrated to ESM-only publishing between 2021 and 2025, and every one of those migrations created a compatibility problem for CommonJS projects that tried to use them. The standard workaround — dynamic import() inside an async function — worked but broke the synchronous nature of module loading that CommonJS developers relied on.

require(ESM) in Node.js 22.12+ (the first version with the flag removed) ends this. The synchronous require() call now works with ESM modules that don't use top-level await. This means CJS projects can finally install and use chalk@5+, got@13+, node-fetch@3+, and hundreds of other ESM-only packages without converting their entire codebase to ESM or maintaining a complex interop layer. The practical impact is immediate: dependencies that were pinned to old CJS versions can be updated.

// Node.js 20: This FAILS
const { something } = require('./esm-module.mjs');
// Error: require() of ES Module not supported

// Node.js 20 workaround (ugly):
const { createRequire } = require('module');
const require2 = createRequire(import.meta.url);
const { something } = require2('./esm-module.mjs');

// Node.js 22.12+: This WORKS (no flag needed)
const { something } = require('./esm-module.mjs');
// ✅ Just works. Synchronously. No async needed.

// Why this matters:
// The npm ecosystem has been migrating to ESM-only packages:
// → chalk v5+: ESM only
// → got v13+: ESM only
// → node-fetch v3+: ESM only
// → hundreds of others

// In Node.js 20, using these in a CJS project required:
// → Converting your entire project to ESM (risky, time-consuming)
// → Using createRequire hacks
// → Pinning to old CJS versions of packages

// In Node.js 22: require ESM packages directly from CJS code.
// The CJS/ESM interop problem is largely solved.

// Caveat: The ESM module must not use top-level await.
// If it does, require() still throws (async by nature).
// But the vast majority of ESM packages don't use top-level await.

V8 12.4: New JavaScript Features You Can Use Without Transpilation

The V8 engine upgrade in Node.js 22 brings a set of JavaScript standard library features that have been available in browsers for a while but weren't available in Node.js 20. Using these features in production code means your TypeScript/Babel transpilation target can be set to es2024 or later, allowing the compiler to output cleaner code that doesn't polyfill things the runtime provides natively. This is a meaningful reduction in generated code complexity, especially for utility-heavy codebases.

// 1. Array.fromAsync() — finally!
// Before (Node.js 20):
async function collectStream(stream) {
  const chunks = [];
  for await (const chunk of stream) {
    chunks.push(chunk);
  }
  return chunks;
}

// Node.js 22:
const chunks = await Array.fromAsync(stream);
// Clean, native, no polyfill needed.

// 2. Set operations — long overdue
const setA = new Set([1, 2, 3, 4]);
const setB = new Set([3, 4, 5, 6]);

// Node.js 22 native Set methods:
setA.union(setB);          // Set {1, 2, 3, 4, 5, 6}
setA.intersection(setB);   // Set {3, 4}
setA.difference(setB);     // Set {1, 2}
setA.symmetricDifference(setB); // Set {1, 2, 5, 6}
setA.isSubsetOf(setB);     // false
setA.isSupersetOf(setB);   // false

// Before: needed lodash or manual iteration for these
// Now: built-in, zero-cost

// 3. Promise.withResolvers()
// Before:
let resolve, reject;
const promise = new Promise((res, rej) => {
  resolve = res;
  reject = rej;
});

// Node.js 22:
const { promise, resolve, reject } = Promise.withResolvers();
// Cleaner deferred promise pattern

// 4. Object.groupBy() (V8 12.2+, included in Node.js 22)
const items = [
  { name: 'a', type: 'x' },
  { name: 'b', type: 'y' },
  { name: 'c', type: 'x' },
];
const grouped = Object.groupBy(items, item => item.type);
// { x: [{ name: 'a' }, { name: 'c' }], y: [{ name: 'b' }] }
// No lodash.groupBy needed.

Built-in Test Runner: Now Production-Ready

The Node.js built-in test runner (node:test) graduated from experimental status in Node.js 22. This matters because it changes the risk calculation for using it in production projects. Experimental APIs can change between minor versions; stable APIs follow semantic versioning and won't break without a major version bump.

The built-in test runner covers the core use cases that drove Jest adoption: describe blocks, before/after lifecycle hooks, assertion utilities via node:assert, and test coverage reporting. For projects that are already on Node.js 22 and don't have heavy investment in Jest-specific ecosystem (jest-dom, jest-environment-jsdom, jest-fetch-mock), switching to the built-in runner eliminates a significant development dependency and its entire transitive tree.

The primary remaining advantage of Jest and Vitest over the built-in runner is browser DOM simulation (jsdom) and the rich mock/spy ecosystem. For backend-only Node.js projects — API servers, CLI tools, data processing scripts — the built-in runner is now a fully viable choice.

// Node.js 22: --experimental flag removed, full coverage support
// package.json:
{
  "scripts": {
    "test": "node --test",
    "test:coverage": "node --test --experimental-test-coverage"
  }
}

// test/user.test.js:
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';

describe('User service', () => {
  let db;

  before(async () => {
    db = await setupTestDatabase();
  });

  after(async () => {
    await db.close();
  });

  test('creates a user with valid email', async () => {
    const user = await createUser({ email: 'test@example.com' });
    assert.equal(user.email, 'test@example.com');
    assert.ok(user.id);
  });

  test('rejects invalid email', async () => {
    await assert.rejects(
      () => createUser({ email: 'not-an-email' }),
      { message: /invalid email/i }
    );
  });
});

// New in Node.js 22: snapshot testing
test('formats user correctly', (t) => {
  const result = formatUser({ name: 'Alice', role: 'admin' });
  t.assert.snapshot(result);
  // Creates/compares snapshot file automatically
});

// Run with coverage:
// node --test --experimental-test-coverage
// Outputs: Lines: 94.3%, Functions: 100%, Branches: 87.5%

Like this comparison? Get weekly Node.js upgrade guides and package breakdowns in your inbox. Subscribe to the PkgPulse newsletter — free, no spam.


Performance: Real Numbers

The performance improvements in Node.js 22 come from two distinct sources: V8 engine improvements (the Maglev compiler and Turbofan optimizations) and built-in API improvements. The benchmark numbers below are for HTTP throughput, where the V8 improvements compound with better internal optimization. Database-heavy workloads typically see smaller improvements because the bottleneck shifts to network I/O and query execution.

Benchmark methodology:
→ Express.js "Hello World" JSON endpoint
→ 10,000 concurrent connections
→ Same hardware, same code, only Node.js version different

Results (approximate, varies by workload):
Node.js 18:  ~28,000 req/s
Node.js 20:  ~34,000 req/s (+21% vs 18)
Node.js 22:  ~39,000 req/s (+15% vs 20, +39% vs 18)

Real-world web server (with DB queries, typical SaaS):
Node.js 20:  ~4,200 req/s
Node.js 22:  ~4,600 req/s (+10%)

Startup time (time-to-listen for a typical Express app):
Node.js 20:  ~180ms
Node.js 22:  ~155ms (-14%)

Memory usage (typical web server, steady state):
Node.js 20:  ~85MB
Node.js 22:  ~78MB (-8%)

The V8 12.x series included:
→ Maglev compiler improvements (mid-tier JIT)
→ Turbofan optimizations for common JS patterns
→ Improved garbage collector efficiency

These aren't benchmark numbers — they translate to real latency
improvements for high-traffic Node.js applications.

The Upgrade Process

The upgrade process for most projects is a single-day task. The vast majority of Node.js 20 applications run without code changes on Node.js 22 — the breaking changes are narrow and mostly affect code using deprecated or internal APIs. The steps below cover both the simple case (update version, run tests) and the common complications.

One underrated upgrade task: update your Dockerfile and CI configuration at the same time as the local version. It's common to upgrade nvm locally but forget to update the FROM node:20-alpine line in the Dockerfile, which means production continues to run the old version despite local development running the new one. The lts/* tag for Docker and CI node-version configuration automatically tracks the active LTS release, which is a useful default for teams that don't want to manually update version numbers on each LTS transition.

# Step 1: Check your current version
node --version
# If < 22, time to upgrade

# Step 2: Update via nvm (recommended)
nvm install 22
nvm use 22
nvm alias default 22  # Make it the default

# Or via fnm (faster nvm alternative):
fnm install 22
fnm use 22
fnm default 22

# Step 3: Update .nvmrc / .node-version in your project
echo "22" > .nvmrc
# Commit this — CI and teammates will pick it up

# Step 4: Update package.json engines field
{
  "engines": {
    "node": ">=22.0.0"
  }
}

# Step 5: Update CI
# .github/workflows/ci.yml:
- uses: actions/setup-node@v4
  with:
    node-version: '22'
    # Or use LTS tag:
    node-version: 'lts/*'  # Always picks active LTS

# Step 6: Update Docker
# Dockerfile:
FROM node:22-alpine  # was: node:20-alpine
# Or pin a specific version:
FROM node:22.12-alpine

# Step 7: Run your test suite
npm test
# For most apps: everything passes without code changes

Using Node.js 22 Built-ins to Replace npm Dependencies

One underused benefit of Node.js 22 is the expanded set of stable built-in APIs that can replace common npm dependencies. Removing a dependency is always better than updating it — fewer packages means smaller attack surface, simpler dependency graphs, and zero transitive dependency risk.

crypto.randomUUID() has been stable since Node.js 19, but Node.js 22 is when it became universally available in production. This replaces the uuid package (14KB) for UUID v4 generation. structuredClone() replaces lodash.cloneDeep for deep object cloning. The URL and URLSearchParams classes (matching the browser APIs exactly) replace url, qs, and querystring for URL manipulation. The built-in fetch API (stable since Node.js 21) replaces node-fetch and axios for simple HTTP requests.

Node.js 22's new Set methods (union, intersection, difference, symmetricDifference) replace lodash's set utility functions, and Object.groupBy() replaces lodash.groupBy. The Array.fromAsync() method simplifies consuming async iterables. If your project uses Lodash primarily for these utility functions rather than its deep clone, chunk, or throttle functionality, Node.js 22 built-ins may let you remove the dependency entirely.

Caveats: What Node.js 22 Doesn't Fix

Node.js 22 doesn't address the fundamental architectural decisions that affect Node.js application design. The event loop is still single-threaded for JavaScript execution, and CPU-intensive operations still require Worker threads or child processes to avoid blocking. The V8 improvements make individual JavaScript operations faster, but they don't change the concurrency model.

The require(ESM) fix has an important caveat: modules that use top-level await can't be require()-d because require() is synchronous and top-level await is inherently asynchronous. Fortunately, most ESM packages don't use top-level await in their module initialization code — it's primarily used in scripts and REPL sessions. Check specific packages you want to require() by looking at their source for await at module scope.

The permission model (the --allow-fs-read, --allow-env flags) is still experimental in Node.js 22 in the sense that its API may change in 22.x releases. It's not suitable for security enforcement in production yet, though it's useful for auditing what a given script accesses. Deno's permission model, which Node.js is partially inspired by here, is more mature but requires a complete runtime switch.

Node.js 22 in Production: Ecosystem Readiness

The ecosystem's readiness for Node.js 22 in 2026 is excellent. All major frameworks (Express, Fastify, Hono, NestJS), ORMs (Prisma, Drizzle, TypeORM), and tooling (TypeScript, esbuild, Vite, webpack 5) are fully compatible. If you're using a framework or library that isn't Node.js 22 compatible in 2026, that's a significant signal about the maintenance status of that package.

Native addons compiled with node-gyp (C++ extensions) may need to be recompiled for Node.js 22's V8 version. Run npm rebuild after upgrading to trigger recompilation. The most common problematic packages are those wrapping native system libraries — canvas, sharp (though sharp publishes pre-built binaries), SQLite native extensions. For packages with published pre-built binaries, the binary for the correct architecture and Node.js version is downloaded automatically.

If you maintain internal packages published to a private registry, verify they have pre-built binaries for Node.js 22 before rolling out the runtime upgrade to production. A staged rollout — test → staging → production — with node --version verification at each stage is the recommended approach for teams where native addon failures could cause service outages.

Breaking Changes to Watch For

Node.js 22 breaking changes (vs Node.js 20):

1. url.parse() deprecation warnings
   → node:url's url.parse() now emits DEP0169 warning
   → Fix: use new URL() instead
   → node:url parse is still functional, just noisy in logs

2. fs.glob() is now stable (was experimental)
   → If you were using --experimental-vm-modules or similar
     flags to access it, remove the flag

3. --experimental-permission model changes
   → The permission model was significantly redesigned
   → If you use --allow-fs-read etc., test carefully

4. V8 deprecations
   → Some rare V8 APIs changed behavior
   → Affects: code using very old C++ native addons (node-gyp)
   → Pure JavaScript code: unaffected

5. OpenSSL 3.x (was already in Node.js 20)
   → Already handled if you were on 20

What most apps will NOT hit:
→ Express, Fastify, Hono: fully compatible
→ Prisma, Drizzle: fully compatible
→ React, Vue, Svelte (compiled): fully compatible
→ TypeScript: fully compatible
→ Most npm packages: fully compatible

Check compatibility:
npx node-compat-checker@latest
# Scans your dependencies for Node.js 22 issues

Track Node.js runtime package health and download trends at PkgPulse.

Compare pnpm and npm package health on PkgPulse.

See also: AVA vs Jest and Node.js 22 vs Node.js 24, Best JavaScript Runtime in 2026: Node.js vs Deno vs Bun.

See the live comparison

View pnpm vs. npm on PkgPulse →

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.