Every dependency you delete is a supply chain risk you stop carrying, a lockfile conflict you stop resolving, and a npm audit line that stops appearing in your CI log.
Six packages in my projects became unnecessary because Node absorbed their job. Not experimental flags I read about once, the boring built-ins I now reach for by default. Versions below are the first release where the feature became usable, so check your engines field before deleting anything.
| Package | Replaced by · Available since | What you lose |
|---|---|---|
| dotenv |
node --env-file · Node 20.6 |
Nothing for most apps |
| uuid |
crypto.randomUUID() · Node 16.7 |
v1/v3/v5 formats |
| axios (simple use) | global fetch · Node 21 stable |
Interceptors, progress events |
| lodash.get | optional chaining · Node 14 | Deep-path string syntax |
| rimraf |
fs.rm({ recursive: true }) · Node 14.14 |
Nothing |
| jest |
node:test · Node 20 |
Snapshot tooling, ecosystem |
That is roughly 12 MB of node_modules and four transitive dependency trees gone from a typical service. The bigger win is that npm ci stops failing on a peer conflict caused by a testing library nobody on the team chose.
1. dotenv, replaced by --env-file
The runtime reads the file. No import, no dotenv/config in a test setup file.
node --env-file=.env server.js
Node 20.6 added the flag, and 22 made it stable enough for production. process.env is populated before your first line runs, which also fixes the ordering bug where a config module reads process.env before dotenv has loaded.
Two caveats. There is no variable expansion, so DATABASE_URL=${HOST}:5432 stays literal. And a missing file is an error, which is what you want in CI and annoying on a fresh clone, where --env-file-if-exists is the flag you actually want.
2. uuid, replaced by crypto.randomUUID()
import { randomUUID } from "node:crypto";
const id = randomUUID(); // 36 chars, v4, RFC 4122
Node 16.7 and later, no dependency, and it is faster than the package for the common v4 case.
You lose v1 (timestamp-ordered) and v5 (namespace hashing). If you need sortable IDs, randomUUID is the wrong tool anyway; a ULID or a database sequence is a better answer than uuid v1.
One production note: if your IDs land in a URL or a log, randomUUID is fine, but do not assume it is unguessable for authorization decisions. It is random, not a secret.
3. axios, replaced by fetch for the simple cases
Node 21 shipped a stable global fetch, and 22 is what most services run now.
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(5_000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
AbortSignal.timeout is the part people miss. Without it, a hung upstream pins your request until the load balancer gives up. That single line replaced most of what I used axios interceptors for.
What you give up: interceptors, upload progress, and automatic JSON parsing on error responses. If your codebase leans on those, keep axios. If it wraps every call in a helper that adds a timeout and throws on non-2xx, the helper is now ten lines and the dependency is optional.
4. lodash.get, replaced by optional chaining
const city = order?.customer?.address?.city ?? "unknown";
Node 14, no import, and type checkers understand it, which _.get(obj, "a.b.c") never did.
The string-path form still wins when the path is dynamic: _.get(obj, userProvidedPath) cannot be expressed as ?.. That case is rare, and when it appears it usually deserves a validation step rather than a silent undefined.
The same logic applies to lodash.merge and lodash.cloneDeep. structuredClone handles the clone case natively since Node 17, with the caveat that functions, symbols, and class prototypes do not survive the copy.
5. rimraf, replaced by fs.rm
import { rm } from "node:fs/promises";
await rm("dist", { recursive: true, force: true });
Node 14.14. Cross-platform, no extra binary, and force: true covers the "directory does not exist" case that made rimraf popular in the first place.
The one place I still see rimraf in scripts is Windows path-length problems on deeply nested node_modules. fs.rm handles long paths on modern Node, but if you support Node 16 on Windows CI, test before deleting.
6. jest, replaced by node:test
For a service with 40 to 200 tests, the built-in runner is enough.
import { test } from "node:test";
import assert from "node:assert/strict";
test("rejects an empty city", async () => {
await assert.rejects(() => searchHotels({ city: "" }), /city is required/);
});
Run it with node --test. Node 20 made the runner stable, and the API covers describe/it, hooks, mocking with mock, and coverage with --experimental-test-coverage.
What you actually lose: snapshot testing, the Jest ecosystem (jsdom environment, dozens of matchers), and the muscle memory of your team. For a React app with component tests, keep Jest or move to Vitest. For a backend with pure functions and an HTTP client, node:test plus assert is enough, and your CI starts in 300ms instead of 8 seconds.
Honest caveat: I moved two services and kept Jest in a third, because the third had 600 tests and a snapshot suite that would have cost a week to port. Deleting a dependency is not worth a week of churn.
The dependency I would not delete, but would stop writing
There is one category where the package is not the problem. It is the pattern of writing a client library for every third-party API.
I had three travel projects, and each one grew the same four files: an auth wrapper, a retry layer, a response mapper, and a pile of error strings. The hotel API changed its response shape once and all three broke on the same Tuesday.
The fix was not a better SDK. It was moving the integration to an MCP server, where the schema lives on the server and my code only consumes it. The whole config is a JSON block:
{
"mcpServers": {
"rollinggo-hotel": {
"type": "http",
"url": "https://mcp.rollinggo.ai/mcp",
"headers": { "Authorization": "Bearer YOUR_API_KEY" }
}
}
}
Three tools, searchHotels, getHotelDetail, getHotelSearchTags, replaced about 1,900 lines across those projects. The server is free with no call cap, covers 2M+ properties, and the vendor reports 2,000+ developers and agents connected. Whether you use this one or another, the principle holds: delete the client library, keep the interface. The developer site has the key request and the schema.
Before you delete
Three checks that saved me from a bad afternoon.
Read the engines field. --env-file on Node 18 fails, and the error is not obvious in a container built from an older base image.
Grep for the edge case, not the common one. Nobody uses _.get with a dynamic path until the one place that does.
Delete one at a time. A six-package PR is unreviewable. Six one-line PRs take an hour and each one is trivially revertible.
The goal is not a dependency-free project. It is a project where every dependency is a decision someone can defend, instead of an install that happened in 2021 and nobody has looked at since.
Top comments (0)