Node.js 22: The LTS Release You Should Be On
Node.js 22 became LTS in October 2024 and will receive security patches until April 2027. If you're running Node.js 18 (EOL April 2025) or Node.js 20 (EOL April 2026), it's time to migrate. Node.js 22 brings a substantial set of improvements that directly impact developer experience and application performance.
The highlights: the built-in test runner is now feature-complete, requiring zero dependencies for most testing needs; native WebSocket client support eliminates the ws package for many use cases; require(ESM) finally works (experimentally, then stable), ending the CommonJS/ESM split pain; and the V8 Maglev JIT compiler delivers 20-30% speedups for CPU-intensive code.
Installation and Version Management
# Install Node.js 22 LTS via nvm (recommended)
nvm install 22
nvm use 22
nvm alias default 22
# Or via fnm (faster alternative)
fnm install 22
fnm use 22
fnm default 22
# Official installer (Ubuntu)
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
# Verify
node --version # v22.x.x
npm --version # 10.x.x
# Check V8 version (Maglev is in V8 12.4+)
node -e "console.log(process.versions.v8)" # Should show 12.4+
Built-In Test Runner: node:test
Node.js 22's test runner is now production-ready and covers 90% of what teams use Jest for. No dependencies, no configuration files, runs with node --test.
Writing Tests with node:test
// test/user.test.js
import { describe, it, before, after, beforeEach, afterEach } from 'node:test'
import assert from 'node:assert/strict'
// Node 22: Full describe/it nesting
describe('User Service', () => {
let db
before(async () => {
db = await connectTestDatabase()
})
after(async () => {
await db.close()
})
beforeEach(async () => {
await db.clear()
})
describe('createUser', () => {
it('creates a user with valid data', async () => {
const user = await createUser({
email: 'test@example.com',
name: 'Test User',
})
assert.ok(user.id)
assert.equal(user.email, 'test@example.com')
assert.equal(user.name, 'Test User')
})
it('throws on duplicate email', async () => {
await createUser({ email: 'dup@example.com', name: 'First' })
await assert.rejects(
() => createUser({ email: 'dup@example.com', name: 'Second' }),
{ code: 'EMAIL_EXISTS' }
)
})
it('validates email format', async (t) => {
// t.mock for mocking (built-in!)
const mockEmailService = t.mock.fn(async () => true)
await assert.rejects(
() => createUser({ email: 'not-an-email', name: 'Bad User' }),
/Invalid email/
)
})
})
})
// Run:
// node --test test/**/*.test.js
// node --test --watch test/ (watch mode!)
// node --test --coverage test/ (coverage report!)
Mocking with node:test
import { it, mock } from 'node:test'
import assert from 'node:assert/strict'
// Mock a module
it('sends welcome email on signup', async (t) => {
// Mock the entire emailService module
const mockSend = t.mock.method(emailService, 'send', async () => ({ id: 'msg_123' }))
await signupUser({ email: 'new@example.com', name: 'New User' })
// Verify mock was called correctly
assert.equal(mockSend.mock.callCount(), 1)
assert.deepEqual(mockSend.mock.calls[0].arguments[0], {
to: 'new@example.com',
template: 'welcome',
data: { name: 'New User' }
})
// Mock is automatically restored after test
})
// Timer mocking (huge for testing setTimeout/setInterval)
it('retries failed requests with backoff', async (t) => {
t.mock.timers.enable(['setTimeout'])
const fetchSpy = t.mock.fn()
.mockResolvedValueOnce({ status: 503 }) // First call fails
.mockResolvedValueOnce({ status: 200 }) // Second succeeds
const promise = fetchWithRetry('https://api.example.com')
// Fast-forward 1 second (retry delay)
t.mock.timers.tick(1000)
const result = await promise
assert.equal(result.status, 200)
assert.equal(fetchSpy.mock.callCount(), 2)
})
Test Coverage Built-In
# Native coverage (no Istanbul/nyc needed)
node --test --experimental-test-coverage test/**/*.test.js
# Output:
# β creates a user (45ms)
# β throws on duplicate email (12ms)
# β validates email format (8ms)
#
# Coverage report:
# ββββββββββββββββββββ€βββββββββββ€ββββββββββββ€βββββββββββββ
# β File β Line % β Branch % β Function % β
# β βββββββββββββββββββͺβββββββββββͺββββββββββββͺβββββββββββββ£
# β src/user.js β 94.12% β 88.50% β 100.00% β
# β src/email.js β 87.30% β 82.10% β 95.45% β
# ββββββββββββββββββββ§βββββββββββ§ββββββββββββ§βββββββββββββ
Native WebSocket Client
Node.js 22 includes the global WebSocket class (same API as browsers), eliminating the need for the ws package in many cases.
// No imports needed β WebSocket is global in Node 22!
const ws = new WebSocket('wss://api.example.com/realtime')
ws.addEventListener('open', () => {
console.log('Connected!')
ws.send(JSON.stringify({ type: 'subscribe', channel: 'prices' }))
})
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)
})
ws.addEventListener('error', (event) => {
console.error('WebSocket error:', event)
})
// With authentication headers
const ws2 = new WebSocket('wss://api.example.com/ws', {
headers: {
'Authorization': 'Bearer your-token',
'X-Client-Version': '2.0'
}
})
// Binary data (ArrayBuffer)
ws.binaryType = 'arraybuffer'
ws.send(new Uint8Array([1, 2, 3, 4]).buffer)
require(ESM): Solving the CommonJS/ESM Split
This was the most painful aspect of Node.js for years. Packages moved to ESM-only, but CJS projects couldn't import them with require(). Node.js 22.12+ allows require() to import synchronous ES modules.
// Before (Node 18/20): ESM-only package fails in CJS
const chalk = require('chalk') // Error: require() of ES Module not supported
// Node 22 (--experimental-require-module, stable in 22.12+):
const chalk = require('chalk') // β
Works!
// This works for SYNCHRONOUS ESM only
// Packages with top-level await still need dynamic import():
const { unified } = await import('unified') // still needed for async ESM
// Migration path for existing CJS codebase:
// package.json β stay as CommonJS
{
"type": "commonjs"
}
// Gradually convert to ESM where needed
// src/modern.mjs or src/modern.js (with "type": "module")
// Best practice: Use TypeScript 5.5+ with bundler resolution
// It handles CJS/ESM interop automatically
V8 Maglev: 20-30% Performance Improvement
Maglev is V8's new mid-tier JIT compiler, sitting between Sparkplug (fast compile, slow run) and Turbofan (slow compile, fast run). It compiles faster than Turbofan but generates better code than Sparkplug β especially beneficial for short-lived functions and server-side rendering.
// Benchmark: JSON processing (CPU-bound, benefits from Maglev)
// test-maglev.js
const data = Array.from({ length: 100000 }, (_, i) => ({
id: i,
name: `User ${i}`,
email: `user${i}@example.com`,
scores: [Math.random(), Math.random(), Math.random()],
}))
console.time('process')
const result = data
.filter(u => u.scores[0] > 0.5)
.map(u => ({
...u,
avgScore: u.scores.reduce((a, b) => a + b, 0) / u.scores.length
}))
.sort((a, b) => b.avgScore - a.avgScore)
.slice(0, 100)
console.timeEnd('process')
console.log('Top user:', result[0]?.name)
// Run with/without Maglev:
// node --no-maglev test-maglev.js β ~85ms
// node test-maglev.js β ~62ms (Maglev enabled by default)
// 27% faster in this benchmark
# Check Maglev status
node --v8-options | grep -i maglev
# Disable Maglev (for debugging)
node --no-maglev app.js
# Profile to confirm Maglev is compiling your hot functions
node --prof app.js
node --prof-process isolate-*.log | grep -i maglev
New API: AbortSignal.any() and Timeout Composition
// AbortSignal.any() β abort when ANY signal fires
const userSignal = new AbortController()
const timeoutSignal = AbortSignal.timeout(5000)
// Abort if user cancels OR 5 second timeout
const combinedSignal = AbortSignal.any([
userSignal.signal,
timeoutSignal,
])
try {
const response = await fetch('https://api.example.com/data', {
signal: combinedSignal
})
const data = await response.json()
return data
} catch (err) {
if (err.name === 'AbortError') {
console.log('Request aborted:',
timeoutSignal.aborted ? 'timeout' : 'user cancelled'
)
}
}
// Practical: HTTP server with per-request timeout
import { createServer } from 'node:http'
const server = createServer(async (req, res) => {
const requestSignal = AbortSignal.timeout(10000) // 10s per request
requestSignal.addEventListener('abort', () => {
res.writeHead(503, { 'Retry-After': '5' })
res.end('Request timeout')
})
try {
const data = await processRequest(req, requestSignal)
res.writeHead(200)
res.end(JSON.stringify(data))
} catch (err) {
if (!res.headersSent) {
res.writeHead(500)
res.end('Internal Server Error')
}
}
})
Stream Performance: Improved Pipe Throughput
// Node 22: Readable.from() performance improvement
import { Readable, pipeline } from 'node:stream'
import { promisify } from 'node:util'
import { createGzip } from 'node:zlib'
import { createWriteStream } from 'node:fs'
const pipelineAsync = promisify(pipeline)
// Process large CSV with streaming (memory efficient)
async function processLargeCSV(inputPath, outputPath) {
const readStream = createReadStream(inputPath)
const gzip = createGzip({ level: 6 })
const writeStream = createWriteStream(outputPath)
let processedRows = 0
await pipelineAsync(
readStream,
// Transform: parse CSV, filter, re-serialize
new Transform({
readableObjectMode: false,
writableObjectMode: false,
transform(chunk, encoding, callback) {
// Node 22: better backpressure handling in Transform
const processed = processCSVChunk(chunk.toString())
processedRows += processed.count
callback(null, processed.output)
}
}),
gzip,
writeStream
)
console.log(`Processed ${processedRows} rows`)
}
Migration Guide: Node.js 18/20 β 22
Breaking Changes
// 1. url.parse() deprecated (use URL class)
// BEFORE:
const { pathname } = require('url').parse('/path?foo=bar')
// AFTER:
const { pathname } = new URL('/path?foo=bar', 'http://localhost')
// 2. crypto.createCipher/Decipher deprecated
// BEFORE:
const cipher = crypto.createCipher('aes256', password) // β
// AFTER:
const key = crypto.scryptSync(password, salt, 32)
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv) // β
// 3. punycode module removed (was deprecated in Node 14)
// If you use punycode directly, install the userland package:
// npm install punycode
// 4. Automatic REPL history disabled by default
// Set NODE_REPL_HISTORY_FILE=/dev/null if you want old behavior
// 5. --experimental-vm-modules now more strict
// ESM modules in vm.Module must have explicit imports
Performance Tuning for Node.js 22
# Enable all performance features
node --max-old-space-size=4096 # 4GB heap
--max-semi-space-size=128 # 128MB young generation
--expose-gc # Manual GC for memory-intensive apps
--experimental-vm-modules app.js
# For TypeScript (via tsx or ts-node):
node --import tsx/esm app.ts
# Docker base image recommendation
FROM node:22-alpine
# vs node:22-slim (debian, larger but more compatible)
# Check if your app benefits from worker threads
node -e "require('os').cpus().length"
# If > 2, consider worker threads for CPU-intensive operations
TypeScript 5.5+ with Node.js 22
// tsconfig.json for Node.js 22
{
"compilerOptions": {
"target": "ES2024",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2024"],
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": false,
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Conclusion
Node.js 22 LTS represents a mature, production-ready platform. The built-in test runner eliminates the Jest/Mocha/Chai dependency chain for most projects. Native WebSocket, improved streams, and the Maglev compiler all translate to real-world benefits without code changes. The ESM/CJS resolution improvement finally unlocks the entire npm ecosystem for legacy CJS codebases.
Migration from Node.js 18 or 20 is low-risk: run npx @npmcli/node-module-checker . to check package compatibility, then update your .nvmrc, Docker base image, and CI configuration. Most applications migrate in under an hour with no code changes.
Priya Sharma
Full-Stack Developer and open-source contributor with a passion for performance and developer experience.