NodeJSModern Node.js Features

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

fetch() global

Node 18 (stable)

node-fetch, axios for simple requests

--watch mode

Node 18.11 (stable in 19+)

nodemon for auto-restart on file change

--env-file flag

Node 20.6

dotenv for loading .env files

Top-level await

Node 14.8 (ESM only)

Async IIFE at module top level

node:test built-in test runner

Node 18 (stable in 20)

jest, mocha for simple test suites

WebSocket client

Node 22 (experimental)

ws package for WebSocket connections

Permission Model (--allow-*)

Node 20 (experimental)

Runtime capability restrictions

Single Executable Applications

Node 20 (stable in 21)

pkg, nexe for bundling into a binary

AbortController / AbortSignal

Node 15 (fully integrated in 18)

Custom cancellation tokens

structuredClone()

Node 17

lodash.cloneDeep for deep object cloning

Array.fromAsync()

Node 22

Manual async iteration accumulation

Fetch API

TS
// 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)

TS
// 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')
  })
})

Bash
node --test                          # discover and run *.test.{js,ts} files
node --test --test-reporter=tap      # TAP format
node --test src/services/user.test.ts
structuredClone — deep cloning

TS
// 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 stringified
structuredClone handles Date, Map, Set, TypedArray, and circular references — unlike JSON.parse(JSON.stringify(x)) which loses types and throws on circular refs
`JSON.parse(JSON.stringify(obj))` is a common deep-clone hack but it has serious limitations: `Date` objects become strings, `undefined` values are dropped, `Map`/`Set` become empty objects, and circular references throw. `structuredClone` uses the structured clone algorithm (the same one used for postMessage, IndexedDB) and handles all of these correctly. It does NOT clone functions (they're dropped) or class prototypes (instances become plain objects). For cloning class instances with their prototype methods, you still need a library or a manual `clone()` method.
AbortController and AbortSignal

TS
// 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

TS
// 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'
Next
Deep dive into top-level await — using await at the module's root scope: [Top-Level await](/nodejs/top-level-await).