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-moduleflag- V8 12.x: Faster garbage collection, better Wasm performance, new JS language features
- TypeScript stripping:
--experimental-strip-typesprogressing toward stable in v24 - Permission model improvements: Better
--allow-fs-read/writegranularity - Native WebSocket client:
new WebSocket()now built-in (nowspackage needed for simple cases) - Node.js 22 EOL: Maintenance mode April 2026, security-only fixes only
Downloads
| Package | Weekly Downloads | Trend |
|---|---|---|
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.