Node.js built-ins that replaced npm packages

By

Twelve Node.js built-ins that replace axios, nodemon, dotenv, jest, chalk, glob and more, with their stability in Node 24 and one gotcha each.

~~~

Open a package.json from a few years ago and you’ll find the same names in almost every one. axios, nodemon, dotenv, jest, chalk, uuid, rimraf. Node.js now ships a built-in for each of them. Here are twelve of those built-ins, with the package each one replaces, the shortest example that runs, how stable it is in the current LTS line, and the one thing the old package still does that the built-in doesn’t.

Several of these changed status in the last year, so I checked every claim against the Node 24 API docs (24.21.0, the Active LTS line as of September 2026) and ran every example on Node 26.0.0, which is what node --version prints on my Mac. Node 22 is in maintenance until April 2027, Node 20 reached end of life on April 30, 2026, and Node 26 becomes the next LTS on October 28, 2026. Where Node 22 and 24 differ, I say so.

A quick reminder on how Node labels its APIs. Stability 2 is stable. Stability 1 is experimental, with sub-levels: 1.0 is early development, 1.1 is active development, 1.2 is a release candidate. Anything experimental can change or disappear in a minor release, so the number matters if you’re putting it in production.

1. node:sqlite replaces better-sqlite3

For years the way to use SQLite from Node was better-sqlite3, a native addon you had to compile (or download a prebuilt binary for) on every install. Node 22.5 added a SQLite driver to core. Like better-sqlite3, it’s synchronous, so the code reads top to bottom with no await.

import { DatabaseSync } from 'node:sqlite'

const db = new DatabaseSync('/Users/flavio/notes.db')
db.exec('CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, body TEXT)')

const insert = db.prepare('INSERT INTO notes (body) VALUES (?)')
insert.run('Buy espresso beans')

console.log(db.prepare('SELECT * FROM notes').all())

The module only exists under the node: prefix, a plain import 'sqlite' fails.

Status in Node 24: 1.2, release candidate, since 24.15.0. It prints no warning. In Node 22 it’s still 1.1, active development (the --experimental-sqlite flag went away in 22.13, the label didn’t), so a server on Node 22 is running an API the docs describe as “nearing minimum viability”.

Where it does less: better-sqlite3 has db.transaction(fn), which wraps a function in BEGIN, COMMIT and ROLLBACK for you, and node:sqlite has nothing like it. You write those three statements yourself in a try/catch. I show the pattern in node:sqlite: SQLite built into Node.js, and the free SQLite course covers transactions and indexes in more depth.

2. node:test and node:assert replace Jest and Mocha

Jest was the default test runner for a long time, and I have a whole post on it from 2018. Mocha plus Chai was the other common pair. Both are still fine. But since Node 20 the runner and the assertion library ship with Node, and the first test needs zero configuration.

import { test } from 'node:test'
import assert from 'node:assert/strict'

test('adds two numbers', () => {
  assert.equal(2 + 2, 4)
})

Run it with node --test, which finds files named like math.test.js on its own:

node --test

Status in Node 24: stable, since Node 20.0.0. node:assert has been stable for much longer.

I can vouch for this one. Every test script in this site’s package.json is a node --test invocation, from the course registry checks to the form security tests.

Where it does less: the edges are still experimental. Code coverage needs --experimental-test-coverage and is marked Stability 1. Module mocking (mock.module()) sits behind --experimental-test-module-mocks at 1.0, early development, and watch mode for tests is experimental too. Function mocks and spies through t.mock.fn() are stable, so plain unit tests are safe. If your suite leans on mocking whole modules, keep Jest for now. The full walkthrough is in the Node.js built-in test runner, and the free testing course starts from zero.

3. Global fetch() replaces axios and node-fetch

I wrote about axios in 2018, back when it was the first thing most Node projects installed to talk to an API. node-fetch was the alternative for people who wanted the browser API. Now the browser API is just there:

const res = await fetch('https://api.github.com/repos/nodejs/node')
const repo = await res.json()

console.log(repo.full_name, res.status)

Node’s implementation is based on undici, an HTTP/1.1 client written for Node. You also get Request, Response, Headers and FormData as globals, so code that uses the Fetch API in a browser or in a Cloudflare Worker works the same way in Node.

Status in Node 24: stable, since Node 21.0.0. It has been out from behind a flag since Node 18.

Where it does less: fetch() does not reject on a 404 or a 500. Axios throws on any non-2xx status, and a lot of code silently depends on that, so with fetch() you check res.ok yourself, every time. There are also no interceptors and no automatic retries, and proxies configured through HTTP_PROXY and HTTPS_PROXY are ignored unless you start Node with --use-env-proxy (or NODE_USE_ENV_PROXY=1), a flag added in 24.5.0 and still at 1.1. Timeouts are covered with AbortSignal.timeout(), which I explain in AbortController: how to cancel a fetch request:

const res = await fetch('https://flaviocopes.com/rss.xml', {
  signal: AbortSignal.timeout(3000),
})

4. node --watch replaces nodemon

nodemon restarts your process when a file changes. I was still writing nodemon tricks in 2023. Node has had the same thing built in since 18.11, and it’s one flag:

node --watch server.js

Edit server.js or anything it imports, and Node prints Restarting 'server.js' and starts over. Add --watch-preserve-output if you don’t want the terminal cleared on every restart.

Status in Node 24: stable, since 22.0.0 (and 20.13.0).

Where it does less: by default Node watches the entry file and the modules it imports, nothing else. A JSON file you read with fs.readFile(), a template, a .env file, none of those trigger a restart. You can add them with --watch-path=./config, but that option is only supported on macOS and Windows, so on a Linux box you’re back to watching imported modules only. And --watch can’t be combined with --run, so node --watch --run dev won’t do what you hope. nodemon watches whatever you point it at, on every platform, and lets you set extensions, ignore lists and delays. If your dev loop depends on that, keep it.

5. --env-file and process.loadEnvFile() replace dotenv

Loading a .env file was the job of dotenv, and I have a post on using it with import syntax from 2023. Node reads the file itself now.

Given this .env:

DATABASE_URL=postgres://localhost:5432/notes
PORT=3000

you start the app with the flag and the values are in process.env before your first line runs:

node --env-file=.env app.js

Or load it from code, which is closer to what dotenv.config() did:

process.loadEnvFile()

Both support quoted values, multiline values inside quotes, and # comments. The flag also parses NODE_OPTIONS from the file and applies it, which dotenv never could. process.loadEnvFile() runs too late for that, so NODE_OPTIONS in the file ends up in process.env but changes nothing.

Status in Node 24: stable, since 24.10.0 (and 22.21.0 on the maintenance line). Before those releases both the flag and the function were experimental, so an older 22.x on your server may still print a warning.

Where it does less: no variable expansion. Write URL=http://${HOST}:3000 and process.env.URL is the literal string with the dollar sign in it. dotenv-expand handles that. Two more things to know: --env-file throws if the file is missing (use --env-file-if-exists=.env for optional files), and a variable already set in your shell wins over the file, which is the same as dotenv without override: true. The details are in the full environment variables guide.

6. util.parseArgs() replaces yargs, commander and minimist

Command line parsing is the most reinstalled dependency I can think of. minimist for small scripts, yargs or commander for real CLIs. Since Node 18.3 there’s a parser in node:util:

import { parseArgs } from 'node:util'

const { values, positionals } = parseArgs({
  options: {
    port: { type: 'string', short: 'p', default: '3000' },
    verbose: { type: 'boolean', short: 'v' },
  },
  allowPositionals: true,
})

console.log(values, positionals)

Run it with node deploy.js --port 8080 -v production and you get { port: '8080', verbose: true } and ['production'].

Status in Node 24: stable, since Node 20.0.0.

Where it does less: type can only be 'string' or 'boolean'. There’s no 'number', you convert values.port yourself. It generates no --help output, has no subcommands, and by default it throws on an option you didn’t declare (set strict: false to allow them). commander gives you all of that plus validation and coercion, which is why I’d still install it for a CLI I ship to other people, while a deploy script with three flags gets parseArgs. I compare the approaches in Node, accept arguments from the command line.

7. util.styleText() replaces chalk

Coloring terminal output meant chalk. My own post on output to the command line recommended it, because the raw escape codes are unreadable. Node 20.12 added styleText() to node:util:

import { styleText } from 'node:util'

console.log(styleText('green', 'Deployed'))
console.log(styleText(['bold', 'red'], 'Failed'))

Pass an array to combine styles. The names are the ones in util.inspect.colors: bold, underline, red, bgYellow, dim and so on.

Status in Node 24: stable, since 22.13.0 (and 23.5.0).

Where it does less: it looks at the output stream before coloring. If process.stdout is not a terminal, because you piped the output to a file or another command, styleText() returns plain text with no escape codes. That’s the right default and it respects NO_COLOR and FORCE_COLOR, but it surprises people writing tests for CLI output. Pass { validateStream: false } to force the codes. And there’s no chained API. chalk.bold.red('x') becomes styleText(['bold', 'red'], 'x'), which is fine, but nested styles inside a template string are more work.

8. fs.glob() replaces glob

Finding files by pattern meant the glob package, or fast-glob if you cared about speed. In 2022 I wrote that glob was the best way I had found to list files recursively. Node 22 added glob to node:fs and node:fs/promises, and Node 24 made it stable.

import { glob } from 'node:fs/promises'

for await (const file of glob('src/posts/**/*.md', { exclude: ['src/posts/drafts/**'] })) {
  console.log(file)
}

There’s also a synchronous globSync() in node:fs that returns an array, and brace expansion like **/*.{md,mdx} works.

Status in Node 24: stable, since 24.0.0. In Node 22 the same functions exist but are marked experimental.

Where it does less: the promise version returns an async iterator, not an array. If you want the array, wrap it in Array.fromAsync(). The option to skip files is called exclude, not ignore, and it takes patterns or a function. Beyond cwd, exclude, withFileTypes and followSymlinks (added in 24.16.0) there’s not much else, while the glob package has absolute, dot, nodir, ignore and a dozen other options, plus a CLI. Build scripts rarely need any of those.

9. The WebSocket global replaces ws on the client side

ws is the WebSocket library for Node, and my WebSockets post installs it for both the server and the client. The client half is now covered by the browser WebSocket class, which Node exposes as a global:

const ws = new WebSocket('wss://echo.websocket.org')

ws.addEventListener('open', () => ws.send('hello from node'))
ws.addEventListener('message', (event) => {
  console.log(event.data)
  ws.close()
})

It’s the same API you’d use in a browser, events and all.

Status in Node 24: stable, since 22.4.0. It has been on by default since 22.0.0, and you can turn it off with --no-experimental-websocket if it collides with something.

Where it does less: it’s a client only. There is no WebSocketServer in Node core, and no node:ws module. If you’re accepting WebSocket connections, you still install ws (or use a framework that bundles it). The dependency disappears from a bot or a CLI that connects to someone else’s socket, and stays in every server.

10. crypto.randomUUID() replaces uuid

The uuid package was in an enormous number of projects for one call, uuidv4(). Node has had that call since 14.17:

console.log(crypto.randomUUID())
// 82b6778b-754f-4549-b5c3-f5adfcf0a43a

No import needed, crypto is a global. If you prefer being explicit, import { randomUUID } from 'node:crypto' gives you the same function.

Status in Node 24: stable. randomUUID() has been stable in node:crypto since it landed. The crypto global (the Web Crypto object it hangs off) was marked stable in Node 23, so on Node 22 the global works but still carries the experimental label in the docs.

Where it does less: it generates version 4 UUIDs, random ones, and nothing else. The uuid package also gives you v7, the time-ordered kind that makes a good database primary key because new rows sort after old ones, plus validate(), parse() and the name-based v5. Before you pick an ID format for a table, read UUID v4 vs v7. For a random ID in a log line or a filename, the built-in is all you need.

11. fs.rm() and fs.mkdir() with recursive replace rimraf and mkdirp

rimraf was rm -rf for Node, mostly so "clean": "rimraf dist" worked on Windows. mkdirp created nested directories in one call. Both jobs are options on the core functions now:

import { mkdir, rm } from 'node:fs/promises'

await mkdir('build/assets/images', { recursive: true })

await rm('build', { recursive: true, force: true })

recursive creates every missing parent, or deletes a directory with everything inside it. force makes rm() ignore a path that doesn’t exist, which is the -f in rm -rf. Both calls have sync versions in node:fs, so a clean script becomes:

{
  "scripts": {
    "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\""
  }
}

Status in Node 24: stable. mkdir({ recursive }) arrived in Node 10.12, fs.rm() in 14.14. The old fs.rmdir(path, { recursive: true }) is deprecated, use rm().

Where it does less: rm() takes one path, not a pattern. rimraf --glob 'dist/**/*.map' becomes fs.glob() plus a loop. And without recursive: true, calling rm() on a directory fails with ERR_FS_EISDIR, which is a safety feature but catches people who expected rimraf behavior by default.

12. TypeScript type stripping replaces ts-node and tsx

Running a .ts file directly meant ts-node, or the esbuild-based tsx. Most TypeScript projects installed one or the other. Node now strips the types itself and runs what’s left:

type User = { name: string; plan: 'free' | 'pro' }

const user: User = { name: 'Flavio', plan: 'pro' }

function greet(u: User): string {
  return `Hi ${u.name}, you are on the ${u.plan} plan`
}

console.log(greet(user))
node greet.ts

Nothing to install and nothing to configure. Node replaces the type annotations with whitespace and hands the result to V8. There’s no type checking at all, that stays with tsc, and because the positions don’t move, there are no source maps to manage.

Status in Node 24: stable, since 24.12.0. It has been on by default since 22.18 and 23.6, and stopped printing a warning in 24.3. The flag to turn it off was renamed from --no-experimental-strip-types to --no-strip-types at the same time. The old --experimental-strip-types is still accepted, it just does nothing extra.

Where it does less: it only handles syntax that can be erased. An enum, a namespace with runtime code, parameter properties in a constructor, or import x = require() need real JavaScript generated, and Node throws ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. In Node 24 you can add --experimental-transform-types for those, at Stability 1.2. Node 26 removed that flag, so on the next LTS the answer is tsx or a build step. Import specifiers must include the extension (./lib.ts, not ./lib), tsconfig.json paths are ignored, decorators fail, and .tsx files are unsupported. Set erasableSyntaxOnly: true in your tsconfig.json and tsc will flag the unsupported syntax before Node does. If you’re curious how another runtime does the same job, what happens when Bun runs a TypeScript file walks through Bun’s transpiler, and the free TypeScript course covers the language itself.

What this means for your package.json

Every built-in on this list does the common case and stops there, and the old package survives in the gap. fetch() has no interceptors, so a client that attaches an auth header to every request in one place keeps axios or writes a ten-line wrapper. ws stays in any server that accepts connections. A test suite that mocks whole modules should stay on Jest until mock.module() leaves 1.0. Before you add one of these packages back, read the gotcha for it and ask whether you actually hit it. Most projects don’t, and the dependency goes, along with its updates, its transitive tree and its install time.

The status I gave is for Node 24. On Node 22, node:sqlite and fs.glob() are experimental, --env-file was experimental until 22.21, and the crypto global still carries the label. Check what your deploy target runs before you remove a package, and pin it in engines so nobody has to guess. The Node core modules post lists everything else that ships in the box, and the free Node.js course is the place to start if most of this is new to you.

Tagged: Node.js · All topics

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about node: