# 7 Node.js Built-in Features That Can Replace npm Packages

*Fewer dependencies, faster installs, and less third-party code to trust.*

Every package you install is also a package you have to update, audit and trust, along with everything that package depends on. Many popular packages were created to fill gaps that Node.js had at the time. Some of those gaps have since closed.

This article covers seven built-in features that handle the most common jobs of well-known packages. For each one you get the package it replaces, a short working example, and the cases where the package is still the better choice.

**Which Node.js version?** The examples target Node.js 24 (LTS). Node.js 26 enters LTS in October 2026, and Node.js 22 stays in maintenance until April 30, 2027. When a feature differs on Node.js 22, I say so.

**A quick note on stability.** Node.js marks every API with a stability index. Stability 2 means stable. Stability 1 means experimental, which means it can change in a minor release. I only picked features that are stable on recent Node.js 24 releases (24.12 or later).

* * *

## 1\. `fetch()` instead of `axios` or `node-fetch`

For years, making an HTTP request in Node.js meant installing a package. Now the same `fetch()` you use in the browser is available as a global:

```js
const res = await fetch('https://api.github.com/repos/nodejs/node', {
  headers: { Accept: 'application/vnd.github+json' },
  signal: AbortSignal.timeout(5000), // give up after 5 seconds
});

if (!res.ok) {
  throw new Error(`Request failed with status ${res.status}`);
}

const repo = await res.json();
console.log(repo.full_name, repo.stargazers_count);
```

**Where the package still wins:** `fetch()` does not throw on a 404 or 500 response, so you have to check `res.ok` yourself, as in the example above. There is also no built-in equivalent of `axios` interceptors or automatic retries. If you attach an auth header to every request in one place, a small wrapper function or `axios` is still convenient.

**Version note:** stable since Node.js 21.0.0.

* * *

## 2\. `node:test` instead of Jest or Mocha

Node.js ships with a test runner and an assertion library, so your first test needs no config file and no install.

Suppose you have a small helper:

```js
// slugify.js
export function slugify(text) {
  return text
    .toLowerCase()
    .trim()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '');
}
```

And a test file next to it:

```js
// slugify.test.js
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { slugify } from './slugify.js';

describe('slugify', () => {
  it('lowercases text and joins words with dashes', () => {
    assert.equal(slugify('Hello Node World'), 'hello-node-world');
  });

  it('removes leading and trailing separators', () => {
    assert.equal(slugify('  --Hi!--  '), 'hi');
  });
});
```

Run it with:

```bash
node --test
```

Node.js finds files named like `*.test.js` on its own. (These examples use ES modules, so set `"type": "module"` in your `package.json` or use the `.mjs` extension.)

**Where the package still wins:** code coverage is enabled with the `--experimental-test-coverage` flag, and mocking whole modules with `mock.module()` is still experimental (Stability 1.0) and needs the `--experimental-test-module-mocks` flag. If your test suite depends heavily on module mocking, keep Jest for now.

**Version note:** stable since Node.js 20.0.0.

* * *

## 3\. `node --watch` instead of `nodemon`

Restarting your server every time you save a file is one of the oldest reasons to install `nodemon`. Node.js now does this with one flag.

```js
// server.js
import { createServer } from 'node:http';

createServer((req, res) => {
  res.end('Hello from Node.js');
}).listen(3000);
```

```json
{
  "scripts": {
    "dev": "node --watch server.js"
  }
}
```

Run `npm run dev`, edit `server.js`, and the process restarts automatically.

**Where the package still wins:** by default, Node.js watches the entry file and the modules it imports. A JSON file, a template or a `.env` file that you read at runtime will not trigger a restart. The `--watch-path` flag lets you choose which paths are watched, but it replaces the default module watching instead of adding to it. The official docs also list `--watch-path` as supported only on macOS and Windows, so test it on your platform. `--watch` also needs a file to run, so it cannot be combined with `--run`. `nodemon` lets you configure extensions, ignore lists and delays.

**Version note:** stable since Node.js 22.0.0 (and 20.13.0).

* * *

## 4\. `--env-file` instead of `dotenv`

Loading a `.env` file used to be the job of `dotenv`. Node.js can read it before your code even starts.

```bash
# .env
PORT=3000
API_BASE_URL=https://api.example.com
```

```js
// app.js
console.log(`Starting on port ${process.env.PORT}`);
console.log(`Calling ${process.env.API_BASE_URL}`);
```

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

If you prefer to load it from code, which is closer to how `dotenv` works, call `process.loadEnvFile()`:

```js
process.loadEnvFile(); // reads ./.env by default
```

Remember to add `.env` to your `.gitignore`.

**Where the package still wins:** Node.js does not expand variables inside values, so `URL=http://${HOST}:3000` stays a literal string. `--env-file` throws an error if the file is missing (use `--env-file-if-exists` for optional files). And a variable that is already set in your shell takes priority over the file.

**Version note:** stable since Node.js 24.10.0 (and 22.21.0). Before those releases, the flag and the function were still marked experimental.

* * *

## 5\. `util.parseArgs()` instead of `minimist`, `yargs` or `commander`

Small scripts often need two or three command line flags. You do not need a package for that.

```js
// cli.js
import { parseArgs } from 'node:util';

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

console.log(values, positionals);
```

Running `node cli.js --port 8080 -v build` prints:

```text
[Object: null prototype] { port: '8080', verbose: true } [ 'build' ]
```

(`values` is an object without a prototype, which is why Node.js prints that prefix.)

**Where the package still wins:** option types can only be `'string'` or `'boolean'`, so you convert the port with `Number(values.port)` yourself. `parseArgs` only parses: it does not generate `--help` output or handle subcommands for you. By default an unknown option throws an error, unless you set `strict: false`. For a CLI that other people will install, `commander` is still worth it. For your own script with three flags, `parseArgs` is enough.

**Version note:** stable since Node.js 20.0.0.

* * *

## 6\. TypeScript type stripping instead of `ts-node` or `tsx`

You can now run a `.ts` file directly. Node.js removes the type annotations and runs the JavaScript that is left.

```ts
// tasks.ts
type Task = { id: number; title: string; done: boolean };

const tasks: Task[] = [
  { id: 1, title: 'Write the article', done: false },
  { id: 2, title: 'Publish on dev.to', done: false },
];

function pending(list: Task[]): Task[] {
  return list.filter((task) => !task.done);
}

console.log(pending(tasks));
```

```bash
node tasks.ts
```

One important point: **Node.js only strips the types, it does not check them.** Type checking stays with the TypeScript compiler, so keep running `tsc --noEmit` in your editor or CI.

**Where the package still wins:** only syntax that can simply be erased is supported. Features that need real JavaScript to be generated, such as `enum`, `namespace` with runtime code and constructor parameter properties, fail with an error. Import specifiers need the file extension (`./lib.ts`, not `./lib`), `tsconfig.json` settings such as `paths` are ignored, and `.tsx` files are not supported. The Node.js docs recommend TypeScript 5.8 or newer, and setting `erasableSyntaxOnly: true` in `tsconfig.json` makes `tsc` warn you about unsupported syntax before Node.js does.

**Version note:** stable in Node.js 24.12.0 and 25.2.0, and enabled by default since 22.18.0 (and 23.6.0).

* * *

## 7\. `fs.glob()` instead of `glob`

Finding files by pattern used to mean installing `glob` or `fast-glob`. Now it lives in `node:fs`.

```js
import { glob } from 'node:fs/promises';

const markdownFiles = await Array.fromAsync(
  glob('content/**/*.md', { exclude: ['content/drafts/**'] })
);

console.log(markdownFiles);
```

There is also a synchronous `globSync()` in `node:fs` that returns an array, and brace patterns like `**/*.{md,mdx}` work.

**Where the package still wins:** the promise version returns an async iterator, which is why the example wraps it in `Array.fromAsync()`. The option for skipping files is named `exclude`, not `ignore`, and negation patterns such as `!foo.js` are not supported in it. In Node.js 24, the documented options are `cwd`, `exclude`, `withFileTypes` and, from 24.16.0, `followSymlinks`. If you rely on advanced features of the `glob` package, check that they exist here before switching. For typical build scripts, the built-in is enough.

**Version note:** stable since Node.js 24.0.0 and 22.17.0. Earlier 22.x releases still mark it experimental. Passing an array of patterns to `exclude` needs Node.js 22.14.0 or newer (or 23.7.0+); on older versions, pass a function instead.

* * *

## Quick reference

| Package | Built-in replacement | Stable since |
| --- | --- | --- |
| `axios`, `node-fetch` | `fetch()` | 21.0.0 |
| `jest`, `mocha` | `node:test` + `node:assert` | 20.0.0 |
| `nodemon` | `node --watch` | 22.0.0 (20.13.0) |
| `dotenv` | `--env-file`, `process.loadEnvFile()` | 24.10.0 (22.21.0) |
| `minimist`, `yargs`, `commander` | `util.parseArgs()` | 20.0.0 |
| `ts-node`, `tsx` | TypeScript type stripping | 24.12.0 / 25.2.0 |
| `glob` | `fs.glob()` | 24.0.0 (22.17.0) |

## Final thoughts

Removing a dependency is not about purity. Each built-in above covers the common case and then stops, and the old package is still useful for whatever the built-in leaves out. Before you remove a package, read the "where the package still wins" part and ask whether you actually hit that case. In many projects you will not, and the dependency goes away together with its updates and its install time.

One last tip: pin the runtime so nobody has to guess which features are available.

```json
{
  "engines": {
    "node": ">=24.12"
  }
}
```

A few more built-ins worth exploring in the Node.js docs: `util.styleText()` (a `chalk` alternative), `crypto.randomUUID()` (instead of `uuid`), the `WebSocket` global (the client side of `ws`), and the `recursive` option of `fs.rm()` and `fs.mkdir()` (instead of `rimraf` and `mkdirp`).

**Which dependency did you remove from your last project?** Tell me in the comments.
