Modern Node.js Features
Node.js has shipped substantial new capabilities in versions 18, 20, and 22 — many of which replace common npm dependencies with built-in equivalents. This page gives an overview of the most impactful recent additions, their minimum Node version, and what they replace. Each feature has a dedicated page for deeper coverage.
Feature overview by version
Feature | Available from | What it replaces / enables |
|---|---|---|
| Node 18 (stable) |
|
| Node 18.11 (stable in 19+) |
|
| Node 20.6 |
|
Top-level | Node 14.8 (ESM only) | Async IIFE at module top level |
| Node 18 (stable in 20) |
|
| Node 22 (experimental) |
|
Permission Model ( | Node 20 (experimental) | Runtime capability restrictions |
Single Executable Applications | Node 20 (stable in 21) | pkg, nexe for bundling into a binary |
| Node 15 (fully integrated in 18) | Custom cancellation tokens |
| Node 17 |
|
| Node 22 | Manual async iteration accumulation |
Fetch API
// Node 18+: fetch is a global — no npm install required:
const response = await fetch('https://api.example.com/users')
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const users = await response.json()
// With AbortController for timeout:
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)
try {
const res = await fetch('https://api.example.com/data', { signal: controller.signal })
const data = await res.json()
} finally {
clearTimeout(timeout)
}Built-in test runner (node:test)
// Node 18+ — no test framework dependency needed for simple suites:
import { test, describe, before, after } from 'node:test'
import assert from 'node:assert/strict'
describe('UserService', () => {
test('returns null for unknown user', async () => {
const result = await userService.findById('unknown')
assert.strictEqual(result, null)
})
test('creates a user', async () => {
const user = await userService.create({ name: 'Alice', email: 'alice@example.com' })
assert.ok(user.id)
assert.strictEqual(user.name, 'Alice')
})
})node --test # discover and run *.test.{js,ts} files
node --test --test-reporter=tap # TAP format
node --test src/services/user.test.tsstructuredClone — deep cloning
// Node 17+: deep clone without lodash or JSON round-trip:
const original = { a: 1, b: { c: [1, 2, 3] }, d: new Date() }
const clone = structuredClone(original)
clone.b.c.push(4)
console.log(original.b.c) // [1, 2, 3] — unaffected
console.log(clone.b.c) // [1, 2, 3, 4]
console.log(clone.d instanceof Date) // true — Date is properly cloned, not stringifiedAbortController and AbortSignal
// AbortController is now fully integrated across Node's built-in APIs:
import fs from 'node:fs/promises'
const controller = new AbortController()
const { signal } = controller
// Cancel a file read after 1 second:
setTimeout(() => controller.abort(), 1000)
try {
const content = await fs.readFile('large-file.txt', { signal })
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
console.log('Read cancelled')
} else {
throw err
}
}
// AbortSignal.timeout() — built-in timeout signal (Node 17.3+):
const res = await fetch('https://api.example.com', {
signal: AbortSignal.timeout(5000), // no manual setTimeout/clearTimeout needed
})Newer built-in utilities
// Object.hasOwn — safer than obj.hasOwnProperty (Node 16.9+):
const obj = { name: 'Alice' }
Object.hasOwn(obj, 'name') // true — works even if obj.__proto__ is null
// at() — positive and negative indexing on arrays and strings (Node 16.6+):
const arr = [1, 2, 3, 4, 5]
arr.at(-1) // 5 — last element
arr.at(-2) // 4
// Array.fromAsync() — collect an async iterable into an array (Node 22+):
async function* generate() { yield 1; yield 2; yield 3 }
const results = await Array.fromAsync(generate()) // [1, 2, 3]
// Promise.withResolvers() — create a Promise and its resolve/reject externally (Node 22+):
const { promise, resolve, reject } = Promise.withResolvers<string>()
setTimeout(() => resolve('done'), 1000)
const value = await promise // 'done'