Skip to main content

Node.js 20 to 22 Upgrade: What Breaks and What to Fix (2026)

·PkgPulse Team
0

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 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

// 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

// 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

// 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%

Performance: Real Numbers

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

# 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

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 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.