BlogWeb Development
Web Development

Node.js 22 LTS: What Changed, What to Migrate, and Performance Deep-Dive

Node.js 22 entered LTS (Long Term Support) in October 2024. It includes native test runner maturity, WebSocket client built-in, require(ESM) support, the V8 Maglev compiler, and performance improvements up to 30% for CPU-bound tasks. Complete migration guide inside.

P

Priya Sharma

Full-Stack Developer and open-source contributor with a passion for performance and developer experience.

March 19, 2026
19 min read

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.

P

Priya Sharma

Full-Stack Developer and open-source contributor with a passion for performance and developer experience.

Ready to Transform Your Infrastructure?

Let's discuss how we can help you achieve similar results.