Replacing Heavy Utility Dependencies with Native APIs
Remove utility dependencies a Node 20 and modern-browser baseline has made redundant, with equivalence tests, engines alignment, and the replacements not worth making.
A dependency list accumulated over several years usually contains three or four entries that exist because the platform lacked something in 2019. On a Node.js 20 and modern-browser baseline several of them are now one line of standard library, and each removal deletes an entire subtree from every consumer’s install:
node_modules/uuid 49 packages removed
node_modules/node-fetch 12 packages removed
node_modules/lodash.isequal 1 package removed
The judgement is not “fewer dependencies is better” — it is “a dependency that encodes no knowledge you do not already have is pure cost”. This guide separates the two cases.
Root Cause
Utility dependencies are adopted when the platform is missing an API, and they are almost never revisited when the platform gains one. The manifest keeps the entry because nothing forces a review, the transitive tail keeps growing upstream, and consumers keep paying for a capability their runtime has had for years.
The reverse mistake is just as real. A dependency that looks trivial from the outside frequently encodes edge cases — timezone arithmetic, unicode segmentation, prototype-safe deep comparison — that a three-line replacement silently drops. The distinction to make is whether the package supplies knowledge or merely syntax.
Minimal Reproduction
{
"dependencies": {
"uuid": "^9.0.1",
"node-fetch": "^3.3.2",
"lodash.isequal": "^4.5.0",
"date-fns": "^3.6.0"
},
"engines": { "node": ">=20.11.0" }
}
import { v4 as uuidv4 } from "uuid";
import fetch from "node-fetch";
import isEqual from "lodash.isequal";
import { formatISO } from "date-fns";
export const newId = (): string => uuidv4();
export const get = (url: string) => fetch(url);
export const same = (a: unknown, b: unknown): boolean => isEqual(a, b);
export const stamp = (d: Date): string => formatISO(d);
Three of those four are redundant on the declared baseline. The fourth is not, and knowing which is which is the whole exercise.
Which Dependencies Encode Knowledge
Step-by-Step Fix
1. Confirm the baseline supports the replacement
node -p "process.versions.node"
node -e "console.log(typeof crypto.randomUUID, typeof fetch, typeof structuredClone)"
20.11.0
function function function
Check against the declared floor rather than your local version. If engines.node says >=18, verify on 18 — Object.groupBy, for instance, is not available there, and using it would break consumers you have promised to support.
2. Replace the syntax wrappers
- import { v4 as uuidv4 } from "uuid";
- import fetch from "node-fetch";
-
- export const newId = (): string => uuidv4();
- export const get = (url: string) => fetch(url);
+ export const newId = (): string => crypto.randomUUID();
+ export const get = (url: string): Promise<Response> => fetch(url);
"dependencies": {
- "uuid": "^9.0.1",
- "node-fetch": "^3.3.2",
"date-fns": "^3.6.0"
}
Expected result: the sandbox footprint drops by the size of both subtrees, and the public API is unchanged — newId() still returns a string in the same format, get() still returns a promise of a response.
3. Write an equivalence test before removing anything subtle
The deep-equality case is the one worth testing rather than assuming, because a naive replacement differs on exactly the inputs that matter.
// test/equivalence.test.ts — runs while BOTH implementations exist
import { describe, expect, it } from "vitest";
import isEqualDep from "lodash.isequal";
const isEqualNative = (a: unknown, b: unknown): boolean => {
if (Object.is(a, b)) return true;
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
const ka = Object.keys(a), kb = Object.keys(b);
if (ka.length !== kb.length) return false;
return ka.every((k) => isEqualNative((a as never)[k], (b as never)[k]));
};
const CASES: Array<[unknown, unknown]> = [
[{ a: 1 }, { a: 1 }],
[{ a: 1 }, { a: 2 }],
[[1, [2, 3]], [1, [2, 3]]],
[null, undefined],
[NaN, NaN],
[new Date(0), new Date(0)], // the case a naive version gets wrong
[{ a: undefined }, {}], // and this one
];
describe("deep equality equivalence", () => {
for (const [a, b] of CASES) {
it(`matches for ${JSON.stringify(a)} vs ${JSON.stringify(b)}`, () => {
expect(isEqualNative(a, b)).toBe(isEqualDep(a, b));
});
}
});
✓ matches for {"a":1} vs {"a":1}
✓ matches for {"a":1} vs {"a":2}
× matches for "1970-01-01T00:00:00.000Z" vs "1970-01-01T00:00:00.000Z"
expected false to be true
That failure is the useful outcome: the replacement is wrong for Date instances, and the honest options are to handle the case explicitly or to keep the dependency. Shipping the naive version because the other six cases passed is how a subtle bug enters a library.
4. Align engines with what the replacements require
HAZARD PREVENTION
Symptom: After removing a polyfill dependency, consumers on an older Node version report
TypeError: crypto.randomUUID is not a function— in a patch release.Root cause: The replacement assumed a baseline higher than the one declared in
engines, andenginesis advisory in npm, so nothing blocked the install.Fix: Raise
engines.nodein the same change, publish it as a major release, and add a CI matrix job on the declared floor so the assumption is tested rather than assumed.
Verification
# 1. the replacements work on the declared floor, not just locally
node --version
npx -y [email protected] -e "console.log(typeof crypto.randomUUID, typeof fetch)"
# 2. the removed packages are genuinely gone from the tree
d=$(mktemp -d); npm pack --silent | xargs -I{} mv {} "$d/"
cd "$d" && npm init -y >/dev/null && npm install ./*.tgz --silent
npm ls uuid node-fetch 2>&1 | grep -q "empty" && echo "ok: removed" || npm ls uuid node-fetch
# 3. footprint moved in the right direction
du -sm node_modules
Expected: both APIs reported as function on the floor version, no uuid or node-fetch in the installed tree, and a smaller footprint than the recorded baseline.
Edge Cases / Gotchas
cryptois global in Node 19+ but needs importing in 18. On an 18 floor,import { randomUUID } from "node:crypto"is the portable form; the bare global is not.- Browser and Node baselines differ.
structuredCloneandfetchare available in both, but Node-only APIs cannot be used in code that reaches a browser build — check the condition the file is served under before replacing anything. - A removed dependency may still be in the lockfile transitively. Something else may depend on it, so the tree does not shrink; the win in that case is one fewer direct dependency and one fewer version constraint, not disk space.
- Type-only usage counts. Removing
node-fetchalso removes its types, so a public signature mentioning itsResponsetype must switch to the globalResponse, which is a public API change even though nothing at runtime differs. - Bundled polyfills defeat the purpose. If your build injects a polyfill for the very API you switched to, the dependency is gone from the manifest and the code is still in the bundle; check the output rather than the manifest.
Frequently Asked Questions
How do I know a native API is safe on my supported baseline?
Check it against the engines.node floor you declare and the browser targets your build compiles for, not the runtime on your laptop. A replacement that throws on the oldest version you claim to support is a breaking change shipped as a chore commit.
Which replacements are usually not worth making?
Anything where the dependency encodes real domain complexity: timezone-aware date arithmetic, deep equality over non-plain objects, semver parsing, unicode-aware string handling. Those trade a small install saving for a class of subtle bugs.
Does removing a dependency need a major release?
Not if the observable behaviour is identical and the dependency was not a peer — it is an internal change. It becomes breaking when it raises the engines floor, changes edge-case behaviour, or removes a peer consumers relied on.
How can I be confident the replacement behaves the same?
Write an equivalence test running both implementations over a shared table of inputs including the awkward cases, keep the dependency as a dev dependency while that test exists, and remove both together once it is green across the supported matrix.
What about polyfills for older consumers?
A library should not ship polyfills. Declare the baseline, use the native API, and let consumers who need older environments apply their own at the application level, where one copy serves the whole bundle.
Related
- Reducing Dependency Weight in Published Packages — the wider footprint strategy.
- Auditing Transitive Dependency Size Before Publish — finding which entries are worth this attention.
- package.json Fields for Distribution — the
enginessemantics these replacements depend on.