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

Syntax wrappers versus knowledge packages Syntax wrappers such as uuid, node-fetch and simple deep-equal helpers are replaceable by native APIs. Knowledge packages such as timezone-aware date libraries, semver parsers and unicode segmenters encode domain complexity that a short replacement will get wrong. Remove the first column, keep the second syntax wrappers — replaceable uuid → crypto.randomUUID() node-fetch → global fetch deep-clone → structuredClone() groupBy → Object.groupBy() the API is the whole package nothing is lost by removing it knowledge packages — keep timezone-aware date arithmetic semver range parsing unicode-aware segmentation deep equality over class instances the edge cases are the package a short replacement drops them

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

Baseline decides what can be removed A Node 18 floor makes fetch and randomUUID available. A Node 20 floor adds more. A Node 22 floor adds Object.groupBy. Each step up removes more dependencies but excludes more consumers. Each step up removes dependencies and consumers together engines >=18 global fetch crypto.randomUUID structuredClone widest audience engines >=20 everything above, plus stable test runner, glob and file-system helpers the common choice today engines >=22 everything above, plus Object.groupBy require of ESM modules excludes LTS holdouts

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, and engines is advisory in npm, so nothing blocked the install.

Fix: Raise engines.node in 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

  • crypto is 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. structuredClone and fetch are 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-fetch also removes its types, so a public signature mentioning its Response type must switch to the global Response, 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.



↑ Back to Reducing Dependency Weight in Published Packages