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:
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:
// slugify.js
export function slugify(text) {
return text
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
And a test file next to it:
// 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:
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.
// server.js
import { createServer } from 'node:http';
createServer((req, res) => {
res.end('Hello from Node.js');
}).listen(3000);
{
"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.
# .env
PORT=3000
API_BASE_URL=https://api.example.com
// app.js
console.log(`Starting on port ${process.env.PORT}`);
console.log(`Calling ${process.env.API_BASE_URL}`);
node --env-file=.env app.js
If you prefer to load it from code, which is closer to how dotenv works, call process.loadEnvFile():
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.
// 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:
[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.
// 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));
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.
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.
{
"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.
Top comments (1)
tr.ee/dev-to