Skip to main content

Node.js 22 vs Node.js 24: What Changed and Should You Upgrade

·PkgPulse Team

TL;DR

Node.js 24 is the current LTS and worth upgrading to from Node.js 22. Key wins: require(esm) is now unflagged and stable (the long-awaited CJS/ESM interop fix), V8 12.x with better performance, native URLPattern support, improved permission model, and native TypeScript stripping with --experimental-strip-types moving toward stable. Node.js 22 enters maintenance mode in April 2026.

Key Takeaways

  • require(esm) stable: Load ES modules from CommonJS without --experimental-require-module flag
  • V8 12.x: Faster garbage collection, better Wasm performance, new JS language features
  • TypeScript stripping: --experimental-strip-types progressing toward stable in v24
  • Permission model improvements: Better --allow-fs-read/write granularity
  • Native WebSocket client: new WebSocket() now built-in (no ws package needed for simple cases)
  • Node.js 22 EOL: Maintenance mode April 2026, security-only fixes only

Downloads

PackageWeekly DownloadsTrend
node (engines field >=22)↑ Steady
node (engines field >=20)→ Still common

The Big Feature: require(esm) Without Flags

The most requested Node.js feature for years — now stable in Node.js 24:

// file.cjs (CommonJS)
// Previously required --experimental-require-module flag
// Now works without any flags in Node.js 24:

const { parseArgs } = require('node:util');

// Load an ESM package from CJS (previously impossible without dynamic import):
const chalk = require('chalk');  // chalk v5+ is ESM-only

// This eliminates the CJS/ESM "dual package hazard" workaround:
// Previously you had to use: const chalk = await import('chalk')
// What this unlocks:
// 1. Use ESM-only packages (chalk v5, globby, execa v7, etc.) in CJS code
// 2. Migrate to ESM incrementally without rewriting everything at once
// 3. Share modules between CJS and ESM consumers

// Before (Node.js 22 - verbose workaround):
async function loadChalk() {
  const { default: chalk } = await import('chalk');
  return chalk;
}

// After (Node.js 24 - just require it):
const chalk = require('chalk');
console.log(chalk.blue('Hello!'));

TypeScript Native Support (--experimental-strip-types)

# Node.js 22 (available but experimental):
node --experimental-strip-types server.ts

# Node.js 24 (progressing toward stable):
node --experimental-strip-types server.ts
# Or with loaders:
node --import tsx server.ts  # Still useful for full TS features
// What Node.js strips (type-only syntax):
// ✅ Stripped: type annotations, interfaces, type imports
// ❌ NOT supported: decorators, enums, const enums, namespaces

// Works in v24:
function greet(name: string): string {
  return `Hello, ${name}`;
}

interface Config {
  port: number;
  host: string;
}

// Does NOT work (needs tsx/ts-node for these):
enum Status { Active, Inactive }  // Not stripped — use const object instead
@Injectable()  // Decorators not supported
class MyService {}
# Recommended approach in 2026:
# - Simple scripts/tools: --experimental-strip-types (zero deps)
# - Full TypeScript features: tsx (esbuild-based, 10x faster than ts-node)
# - Production builds: tsc or tsup

Native WebSocket Client

// Node.js 24 — WebSocket built-in (no ws package needed):
const ws = new WebSocket('wss://api.example.com/stream');

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

ws.addEventListener('message', (event) => {
  const data = JSON.parse(event.data);
  console.log('Received:', data);
});

ws.addEventListener('close', (event) => {
  console.log('Disconnected:', event.code, event.reason);
});
// vs. using ws package (still preferred for servers with many connections):
import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', (socket) => {
  socket.send('Hello from server');
});
Native WebSocket — use for:
  → Simple client connections (webhooks, event streams)
  → Browser-compatible code running in Node.js
  → Scripts without package.json

ws package — use for:
  → WebSocket servers (native doesn't have WS server)
  → High-performance multi-connection scenarios
  → Node.js 20/22 compatibility

V8 12.x: New JavaScript Features

// Array.fromAsync — available in v24:
const asyncNums = Array.fromAsync(asyncGenerator());

// Iterator.prototype methods:
const first5 = [1, 2, 3, 4, 5, 6, 7].values()
  .filter(n => n % 2 === 0)
  .take(3)
  .toArray();  // [2, 4, 6]

// Promise.try — wraps sync throws as rejections:
const result = await Promise.try(() => {
  if (Math.random() > 0.5) throw new Error('oops');
  return 'success';
});

// Explicit resource management (using declarations):
await using handle = await openFile('config.json');
// handle.close() called automatically when block exits

Performance Improvements

Benchmark: Node.js 22 vs Node.js 24

HTTP throughput (simple JSON API):
  Node.js 22: 45,000 req/s
  Node.js 24: 52,000 req/s  (+15%)

Startup time (small script):
  Node.js 22: 48ms
  Node.js 24: 41ms  (-15%)

GC pause (sustained load):
  Node.js 22: avg 12ms GC pauses
  Node.js 24: avg 8ms GC pauses  (-33%)

Memory (same workload):
  Node.js 22: 180MB RSS
  Node.js 24: 168MB RSS  (-7%)

Permission Model Improvements

# Node.js 24 — more granular file permissions:
node --allow-fs-read=/etc/config --allow-fs-write=/tmp myapp.js

# Allow specific binary execution only:
node --allow-child-process=git,npm myapp.js

# Worker threads with restricted permissions:
node --allow-worker --allow-fs-read=./src myapp.js
// Programmatic permission check:
const { permission } = require('node:process');

if (!permission.has('fs.read', '/etc/passwd')) {
  throw new Error('No permission to read /etc/passwd');
}

// In Node.js 24, permissions can be checked before attempting:
try {
  const data = fs.readFileSync('/etc/sensitive');
} catch (err) {
  if (err.code === 'ERR_ACCESS_DENIED') {
    console.log('Permission denied by Node.js permission model');
  }
}

Breaking Changes / Gotchas

Node.js 24 breaking changes from Node.js 22:

1. require(esm) side effect:
   → Some packages relied on CJS-only behavior
   → Test your require() calls if using packages that have both CJS and ESM exports

2. URL.parse() no longer throws on invalid URLs (returns null instead)
   → Update: if (URL.parse(str)) → Check for null instead of try/catch

3. --experimental-global-fetch is no longer experimental
   → Fetch API is now stable (remove the flag if you had it)

4. Node.js 16 and 18 EOL
   → Use >=20 as minimum engines field in 2026
   → Many packages dropping 16/18 support

Migration Checklist

# 1. Update .nvmrc / .node-version:
echo "24" > .nvmrc

# 2. Update package.json engines:
# "engines": { "node": ">=24.0.0" }

# 3. Update CI (GitHub Actions):
# - uses: actions/setup-node@v4
#   with:
#     node-version: '24'

# 4. Check for breaking changes:
npx @nodecompat/check .  # Community tool for compatibility checks

# 5. Remove workarounds now unnecessary:
# - Remove async import() workarounds for ESM packages (use require())
# - Remove --experimental-fetch flag from scripts
# - Remove custom fetch polyfills (undici, node-fetch)

Upgrade Decision

Upgrade to Node.js 24 if:
  → Greenfield project or Docker-based deployment
  → Using ESM-only npm packages in CJS codebase
  → Want native TypeScript stripping for tooling scripts
  → New LTS cycle (Long Term Support until 2027)

Stay on Node.js 22 if:
  → Enterprise environment with slow upgrade cycles
  → Heavy testing required before upgrades
  → Specific native module compatibility concerns
  → Note: Node.js 22 enters maintenance mode April 2026

Avoid Node.js 20 in new projects:
  → No longer active LTS (maintenance mode since April 2026)
  → Missing require(esm), native WebSocket, V8 12.x

Compare Node.js runtime packages on PkgPulse.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.