
Every claim here has a test. Run on Node 22.22.2, 24.22.1 and 26.12.1. Install sizes measured October 24, 2026.
I installed 13 popular packages into an empty project. npm reported 389 packages and 59.7 MB in node_modules.
Then I wrote replacements using only what ships with Node, and tested each one. The result is a project with zero dependencies and a 50-test suite.
Not every swap is worth making. Some of these packages are tiny, and deleting them saves almost nothing on disk. I'll say which ones, and I'll point out the sharp edges in the built-ins too.
Baseline is Node 24, the current Active LTS. Node 26 is scheduled to enter LTS this month. When something needs a version newer than 22, the section says so.
The numbers
Each package was installed alone in a clean directory. "Packages" counts everything npm put in node_modules, including the package itself.
Package | Replace with | Packages | Size |
|---|---|---|---|
jest 30.5.2 |
| 278 | 36.5 MB |
tsx 4.23.15 | native type stripping | 3 | 12.0 MB |
mocha 12.0.3 |
| 25 | 11.2 MB |
rimraf 6.1.3 |
| 9 | 6.8 MB |
glob 13.0.6 |
| 7 | 6.5 MB |
axios 1.20.0 |
| 27 | 2.8 MB |
nodemon 3.1.14 |
| 28 | 1.4 MB |
lodash 4.18.1 |
| 1 | 1.4 MB |
mkdirp 3.0.1 |
| 1 | 107 KB |
uuid 14.0.3 |
| 1 | 66 KB |
chalk 6.0.1 |
| 1 | 57 KB |
minimist 1.2.8 |
| 1 | 55 KB |
dotenv 18.0.9 |
| 1 | 47 KB |
Installed together, all 13 land at 389 packages and 59.7 MB. Jest is most of that.
Jest also pulls in an old glob. On my install, npm printed a deprecation warning for [email protected], which arrives through test-exclude under Jest's transform package.
jest and mocha: use node:test
node:test has been stable since Node 20. It covers what most suites use: describe, it, hooks, mocks and fake timers.
import { describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
describe('what jest users reach for', () => {
it('mock.fn() records calls', () => {
const add = mock.fn((a, b) => a + b);
assert.equal(add(1, 2), 3);
assert.equal(add.mock.callCount(), 1);
});
it('fake timers', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const fn = t.mock.fn();
setTimeout(fn, 5000);
t.mock.timers.tick(5000);
assert.equal(fn.mock.callCount(), 1);
});
});Run it with an explicit pattern:
node --test "test/**/*.test.mjs"The quotes and the pattern both matter. By default Node runs every .js, .mjs and .cjs file under any directory named test, so your helpers and fixtures get executed as tests too.
Coverage works, but it's still behind a flag. The docs mark it Experimental on both Node 24 and 26, and you need --experimental-test-coverage. Mock timers went stable in Node 23.1.0.
I didn't port a large suite. If yours has thousands of tests built on Jest-specific matchers, moving is a project, not an afternoon.
nodemon: use --watch
node --watch server.mjsWatch mode has been stable since Node 22.0.0 and 20.13.0. My test starts a script, rewrites the file, and waits for a second boot message. On Node 26 the whole test, first boot and restart included, finished in about 300 ms.
nodemon has a config file, a restart delay and an exec mode for running non-Node commands. If you use any of those, keep it.
dotenv: use --env-file
node --env-file=.env --env-file=.env.local server.mjs--env-file was added in Node 20.6.0 and is no longer experimental as of 24.10.0. The tests pin down the behavior people usually ask about:
Quotes are stripped, inline
#comments are ignored, and anexportprefix works.Multi-line quoted values work.
A real environment variable beats the same key in the file.
With several files, the later one wins.
A missing file is an error. Use
--env-file-if-exists(Node 22.9+) to skip it.
In code, process.loadEnvFile() loads a file into process.env. util.parseEnv() parses a string and leaves process.env alone.
One gap: Node doesn't expand variables. A line like GREETING=hi ${APP_NAME} keeps the ${APP_NAME} text as is, and my test asserts exactly that. If you rely on expansion, keep dotenv-expand.
axios: use fetch
fetch has been in Node since 17.5 and stable since 21.0.0. This is the helper I'd write for the common case: JSON, a timeout, and retries with backoff on 5xx and 429.
import { setTimeout as sleep } from 'node:timers/promises';
export class HttpError extends Error {
constructor(status, url) {
super(`${status} ${url}`);
this.name = 'HttpError';
this.status = status;
}
}
const retryable = (status) => status === 429 || status >= 500;
export async function getJSON(url, { timeoutMs = 5000, retries = 2, baseDelayMs = 200 } = {}) {
for (let attempt = 0; ; attempt++) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
if (res.ok) return await res.json();
throw new HttpError(res.status, url);
} catch (err) {
const giveUp = attempt >= retries || (err instanceof HttpError && !retryable(err.status));
if (giveUp) throw err;
await sleep(baseDelayMs * 2 ** attempt);
}
}
}Three things bite people coming from axios, and each has a test against a local server.
fetch doesn't reject on a 404 or a 500, so you have to check res.ok. Timeouts come from AbortSignal.timeout(ms), which rejects with a TimeoutError. Posting JSON means setting the content-type header and calling JSON.stringify yourself.
There are no interceptors. If your codebase leans on them everywhere, keep axios until you've moved that logic into a wrapper like the one above.
tsx and ts-node: use type stripping
node server.tsNode strips the type annotations and runs what's left. It's on by default from 23.6.0 and from 22.18.0 on the 22 line, and it went stable in 24.12.0.
The limits are real, and every one is a test:
There's no type checking. My fixture assigns a string to a
numberand runs fine.enumand parameter properties likeconstructor(private x: number)fail withERR_UNSUPPORTED_TYPESCRIPT_SYNTAX.Importing a type without
import typecrashes at runtime withdoes not provide an export named 'Point'.Relative imports need the
.tsextension, and Node ignorestsconfig.json,pathsincluded.
Node 22 through 25 have --experimental-transform-types, which brings enums back. Node 26.0.0 removed that flag. If you're heading to Node 26, rewrite enums as plain objects.
Keep the typescript package for tsc --noEmit. The Node docs recommend TypeScript 5.8 or newer with erasableSyntaxOnly and verbatimModuleSyntax, so the compiler flags syntax Node can't run before you hit it.
chalk: use util.styleText
import { styleText } from 'node:util';
console.log(styleText(['bold', 'red'], 'build failed'));styleText has been stable since Node 22.13.0 and 23.5.0. By default it checks whether the stream is a terminal. My tests show plain text when output is piped, escape codes with FORCE_COLOR=1, and plain text again with NO_COLOR=1. Pass { validateStream: false } to force the codes.
Be honest about the size: chalk 6.0.1 is 57 KB with no dependencies. Deleting it saves nothing on disk.
The case for deleting it is your lockfile. On September 8, 2025, chalk 5.6.1 and debug 4.4.2 were among 18 packages published with malicious code, after maintainer Josh Junon fell for a phishing email from a lookalike npm domain. StepSecurity's write-up lists the affected versions. The payload went after crypto wallets and transactions in browser contexts.
Every package you don't install is one less maintainer account between you and your production server.
minimist and yargs: use util.parseArgs
import { parseArgs } from 'node:util';
const { values, positionals } = parseArgs({
allowPositionals: true,
options: {
port: { type: 'string', short: 'p', default: '3000' },
verbose: { type: 'boolean', short: 'v', default: false },
tag: { type: 'string', multiple: true },
},
});parseArgs landed in Node 18.3.0 and 16.17.0. It's strict by default, so an unknown flag throws ERR_PARSE_ARGS_UNKNOWN_OPTION. Values are never coerced, which means --port 8080 gives you the string '8080'.
--no-color style negation only works if you pass allowNegative: true (Node 22.4.0 and 20.16.0 or later).
You get values and positionals. There's no help text and no subcommands. For a real CLI, keep commander or yargs.
uuid: use crypto.randomUUID
import { randomUUID } from 'node:crypto';
randomUUID(); // v4
globalThis.crypto.randomUUID(); // same thing, no importMy test generates 10,000 and gets 10,000 unique values.
If you use v7 IDs, Node 24.16.0 added crypto.randomUUIDv7(). Two IDs generated 5 ms apart sort in creation order in my test. The function doesn't exist on Node 22, so that test skips there.
Keep uuid if you need v1, v3 or v5, or its parsing and validation helpers.
rimraf, mkdirp and cpy: use node:fs
import { mkdir, cp, rm } from 'node:fs/promises';
await mkdir('a/b/c', { recursive: true });
await cp('a', 'copy', { recursive: true });
await rm('copy', { recursive: true, force: true });mkdir with recursive: true returns the first directory it created, or undefined if everything already existed. fs.cp has been stable since Node 22.3.0.
Add force: true to rm if you want rm -rf behavior. Without it, a missing path throws ENOENT, and my test checks that too.
rimraf 6.1.3 brings 9 packages and 6.8 MB for this.
glob: use fs.glob
import { glob } from 'node:fs/promises';
for await (const file of glob('src/**/*.md', { exclude: ['**/node_modules/**'] })) {
console.log(file);
}fs.glob is stable from Node 24.0.0, and it's experimental on 22. Glob patterns in exclude arrived in 22.14.0 and 23.7.0. Before that, exclude only took a function.
Two differences from the package. It returns an async iterator, not an array. And negation patterns like !foo.js aren't supported in exclude, as the docs say.
glob 13.0.6 installs 7 packages and 6.5 MB.
lodash: use structuredClone and Object.groupBy
const copy = structuredClone(config);
const byRole = Object.groupBy(users, (u) => u.role);structuredClone is a global since Node 17. It copies Date, Map, Set and circular references correctly.
It isn't a drop-in for _.cloneDeep, though. I ran both on the same inputs. structuredClone throws a DataCloneError on an object with a function property, where lodash keeps the function reference. It also drops class prototypes: a cloned Point is no longer instanceof Point, while lodash's copy still is.
Object.groupBy returns an object with a null prototype, unlike _.groupBy. Spread it ({ ...grouped }) before passing it to code that calls .hasOwnProperty. Map.groupBy handles non-string keys.
Swap the two or three functions you actually call. Don't pull lodash out of a codebase that leans on _.merge or _.debounce.
What I'd keep
node:sqlite is the tempting one. The docs list it at Stability 1.2, release candidate, since Node 24.15.0 (25.7.0 on the other line). A release candidate isn't stable, so I wouldn't replace better-sqlite3 on a production service yet. I didn't benchmark either.
I'd also keep TypeScript itself, dotenv-expand if you use variable expansion, and commander or yargs for anything with subcommands.
Removing a package properly
npm uninstall jest takes it out of package.json. It doesn't remove copies that another package still requires.
Run npm ls glob (or whatever you just deleted) after each removal. In my combined install, glob appeared in two versions: 13.0.6 and the deprecated 10.5.0 that Jest's tree carries.
Run it yourself
The download is delete-these-packages.zip. It has no dependencies, so there's no install step.
src/holds the two small modules from this post,http.mjsandargs.mjs.test/has one test file per section, 50 tests in total.measure/measure.shreproduces the install numbers, andmeasure/results.tsvhas mine.
Run npm test on Node 24 and all 50 pass. Node 22.22.2 passes 49 and skips the randomUUIDv7 test. Node 26.12.1 passes 49 and skips the transform-types test.
Start with the four swaps that cost nothing to try: --watch, --env-file, randomUUID and styleText.
Comments(0)
Join the discussion by logging into your account.
No comments yet. Be the first to comment!