# Node.js built-ins that replaced npm packages

> 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.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-09-20 | Topics: [Node.js](https://flaviocopes.com/tags/node/) | Canonical: https://flaviocopes.com/node-builtins/

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`.

```js
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](https://flaviocopes.com/node-sqlite/), and the free [SQLite course](https://flaviocopes.com/courses/sqlite/) 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](https://flaviocopes.com/jest/) 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.

```js
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:

```bash
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](https://flaviocopes.com/node-test-runner/), and the free [testing course](https://flaviocopes.com/courses/testing/) starts from zero.

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

I wrote about [axios](https://flaviocopes.com/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:

```js
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](https://undici.nodejs.org), 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](https://flaviocopes.com/abortcontroller/):

```js
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](https://flaviocopes.com/restarting-a-node-process-without-file-changes/) in 2023. Node has had the same thing built in since 18.11, and it's one flag:

```bash
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](https://flaviocopes.com/how-to-use-env-files-in-nodejs-with-import-syntax/) from 2023. Node reads the file itself now.

Given this `.env`:

```text
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:

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

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

```js
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](https://flaviocopes.com/node-environment-variables/).

## 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`:

```js
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](https://flaviocopes.com/node-cli-args/).

## 7. `util.styleText()` replaces `chalk`

Coloring terminal output meant `chalk`. My own post on [output to the command line](https://flaviocopes.com/node-output-to-cli/) recommended it, because the raw escape codes are unreadable. Node 20.12 added `styleText()` to `node:util`:

```js
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](https://flaviocopes.com/nodejs-get-all-files-folder/) to list files recursively. Node 22 added `glob` to `node:fs` and `node:fs/promises`, and Node 24 made it stable.

```js
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](https://flaviocopes.com/node-websockets/) 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:

```js
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:

```js
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](https://flaviocopes.com/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:

```js
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:

```json
{
  "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:

```ts
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))
```

```bash
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](https://flaviocopes.com/what-happens-when-bun-runs-typescript/) walks through Bun's transpiler, and the free [TypeScript course](https://flaviocopes.com/courses/typescript/) 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](https://flaviocopes.com/node-core-modules/) post lists everything else that ships in the box, and the free [Node.js course](https://flaviocopes.com/courses/nodejs/) is the place to start if most of this is new to you.
