Reducing Dependency Weight in Published Packages
Cut the install footprint of a published library: peer vs regular dependencies, externalising at build time, auditing transitive weight, and replacing helpers with native APIs.
Bundle size gets all the attention, but a library’s dependency list is what a consumer actually installs — and a 12 kB package that drags in 40 MB of transitive dependencies costs them cold-start time, CI minutes, Docker layer size, and audit noise that no amount of tree-shaking will touch. This guide covers the four levers that matter: classifying dependencies correctly, keeping them external to your build output, measuring the transitive tail before it ships, and removing the helpers your supported runtime baseline has made redundant. It assumes a Node.js 20 baseline and a package that already ships real ESM behind its import condition.
Prerequisites
Canonical Configuration Block
The manifest below shows all four classifications in one place, which is the fastest way to see what each one means to a consumer.
{
"name": "@scope/my-library",
"dependencies": {
"tslib": "^2.6.0"
},
"peerDependencies": {
"react": ">=18",
"zod": ">=3.22"
},
"peerDependenciesMeta": {
"zod": { "optional": true }
},
"optionalDependencies": {
"@scope/native-accelerator": "^1.0.0"
},
"devDependencies": {
"tsup": "^8.0.0",
"typescript": "^5.6.0",
"vitest": "^2.0.0"
}
}
dependenciesare installed automatically and always cost the consumer. Keep this list as short as you can defend.peerDependenciesmust be supplied by the consumer, and there will be exactly one copy — the right choice for anything with shared state or identity (a framework, a validation library whose schemas cross the boundary).peerDependenciesMeta.optionalmarks a peer as not required, which is how a library declares “I integrate with this if you have it”.optionalDependenciesinstall if they can and are skipped on failure — appropriate only for genuine accelerators with a working pure-JavaScript fallback.devDependenciescost consumers nothing. Anything used only at build or test time belongs here, and moving one out ofdependenciesis the cheapest win available.
Step-by-Step Implementation
Step 1 — Measure before deciding anything
Reading your own dependency list tells you what you declared; installing the tarball tells you what a consumer receives.
d=$(mktemp -d); npm pack --silent | xargs -I{} mv {} "$d/"
cd "$d" && npm init -y >/dev/null && npm install ./*.tgz --silent
du -sh node_modules
npm ls --all --parseable | wc -l
du -sh node_modules/* | sort -rh | head -10
38M node_modules
412
14M node_modules/typescript
6.2M node_modules/@aws-sdk
2.1M node_modules/lodash
That output is the whole diagnosis. A compiler in a consumer’s install means something is misclassified; a large SDK means a dependency that should be a peer or an optional integration; a general-purpose utility library means a handful of functions are costing megabytes.
Step 2 — Classify each dependency deliberately
HAZARD PREVENTION
Symptom: Consumers report
Invalid hook callorCannot read properties of null (reading 'useState')after installing your component library.Root cause: React was declared in
dependenciesinstead ofpeerDependencies, so the consumer’s install contains two copies and your components use a different React instance than the application does.Fix: Move every framework and any package with shared internal state to
peerDependencieswith a permissive range (>=18), and mark the build external so no copy is bundled either. This is the same class of problem as the dual-package hazard, reached through a different route.
Step 3 — Externalise dependencies in the build
A library build should transform your own source and leave dependencies as import statements, so the consumer’s package manager resolves and de-duplicates them.
// tsup.config.ts
import { defineConfig } from "tsup";
import pkg from "./package.json" with { type: "json" };
export default defineConfig({
entry: ["src/index.ts", "src/date.ts"],
format: ["esm", "cjs"],
dts: true,
clean: true,
// Everything the consumer will also have: never inline it.
external: [
...Object.keys(pkg.dependencies ?? {}),
...Object.keys(pkg.peerDependencies ?? {}),
/^node:/,
],
});
# prove nothing was inlined
grep -c "createRequire\|__toESM" dist/index.mjs || true
grep -rn "lodash" dist/ | head -3 || echo "no dependency source inlined"
Expected result: dependency names appear only in import statements in the output, never as inlined source.
Step 4 — Remove helpers the runtime baseline has replaced
With a Node.js 20 baseline and modern browsers, several long-standing dependencies are now redundant. Each removal deletes an entire subtree from every consumer’s install.
// before: three dependencies for three one-liners
import isEqual from "lodash.isequal";
import { v4 as uuid } from "uuid";
import fetch from "node-fetch";
// after: all three are native on the supported baseline
const isEqual = (a: unknown, b: unknown): boolean =>
JSON.stringify(a) === JSON.stringify(b); // for plain data; use a real deep-equal otherwise
const id: string = crypto.randomUUID();
const res: Response = await fetch("https://example.com/api");
The judgement call is real: a hand-rolled deep-equal is a maintenance liability if your data is not plain, and replacing a well-tested dependency with a worse implementation is not a win. The removals worth making are the ones where the native API is strictly equivalent — crypto.randomUUID, structuredClone, Object.groupBy, AbortController, global fetch — not the ones where you are re-implementing behaviour.
Step 5 — Gate the footprint so it cannot drift
#!/usr/bin/env bash
set -euo pipefail
MAX_MB=8
MAX_PACKAGES=25
d=$(mktemp -d); trap 'rm -rf "$d"' EXIT
npm pack --silent | xargs -I{} mv {} "$d/"
cd "$d" && npm init -y >/dev/null && npm install ./*.tgz --silent
mb=$(du -sm node_modules | cut -f1)
count=$(npm ls --all --parseable | wc -l)
echo "footprint: ${mb}MB across ${count} packages"
[ "$mb" -le "$MAX_MB" ] || { echo "FAIL: over ${MAX_MB}MB budget"; exit 1; }
[ "$count" -le "$MAX_PACKAGES" ] || { echo "FAIL: over ${MAX_PACKAGES} packages"; exit 1; }
Tooling Validation
# what the consumer's tree actually looks like
npm ls --all --depth=3
# which of your dependencies are only used at build time
npx depcheck
# duplicate copies of the same package in a consumer install
npm ls --all | grep -E "deduped|UNMET" | head
# published size, before and after a change
npm pack --dry-run --json | node -e '
const t = JSON.parse(require("fs").readFileSync(0,"utf8"))[0];
console.log(`tarball ${(t.size/1024).toFixed(1)} kB, unpacked ${(t.unpackedSize/1024).toFixed(1)} kB`);
'
Sample passing output:
footprint: 3MB across 11 packages
tarball 14.8 kB, unpacked 56.1 kB
Compatibility Matrix
| Behaviour | npm 10 | pnpm 9 | Yarn 4 | Bun |
|---|---|---|---|---|
| Missing required peer | Warning, install succeeds | Error by default | Warning | Warning |
| Optional peer absent | Silent | Silent | Silent | Silent |
| Hoisted access to transitive deps | Allowed | Blocked (strict layout) | Blocked (PnP) | Allowed |
optionalDependencies build failure |
Install continues | Install continues | Install continues | Install continues |
| Auto-install of peer dependencies | Yes (npm 7+) | Configurable | No | Yes |
The second row is the one that surfaces as a bug report against your package: under pnpm’s strict layout a consumer cannot import a package you depend on unless they declared it themselves, so documentation that assumes a hoisted node_modules fails for them specifically.
Version Ranges and the Compatibility Window
A dependency’s range is as consequential as its section in the manifest, because it decides how many copies end up in a consumer’s tree and how often they get resolution conflicts. Three rules cover almost every case.
Peer ranges should be as wide as you can honestly support. "react": ">=18" accepts every version from 18 onwards, including majors that do not exist yet. That sounds reckless and is usually correct: the alternative, "react": "^18.0.0", produces an unmet-peer error the day React 19 ships, for a library that would have worked fine. If a future major genuinely breaks you, publish a patch narrowing the range — a fast fix that affects only new installs, rather than a wall of warnings for everyone.
Regular dependency ranges should be caret by default. "^2.6.0" accepts compatible updates and lets the package manager de-duplicate your copy with any other package that depends on the same library. A pinned exact version ("2.6.0") guarantees a second copy whenever anything else in the tree wants a different patch, which is a footprint cost paid by every consumer to solve a problem you could have solved with a lockfile in your own repository.
Never use a range you have not tested against. Declaring ">=18" means claiming the library works on React 19 and 20. Where the claim matters, back it with a CI matrix that installs the oldest and newest supported versions and runs the test suite against both.
strategy:
matrix:
peer: ['18.2.0', 'latest']
steps:
- run: npm ci
- run: npm install react@${{ matrix.peer }} react-dom@${{ matrix.peer }}
- run: npm test
The matrix is what converts a range from an assertion into a tested claim, and it is the difference between a compatibility window and a guess.
There is one asymmetry worth internalising. Widening a range later is a minor change: nothing that worked stops working. Narrowing it is a major change, because consumers who were within the old range and outside the new one lose the ability to install your package at all. Ranges therefore want to start conservative in the direction that is cheap to fix — begin with the versions you test, widen as you extend the matrix, and treat any narrowing as a semver event with its own changelog entry.
When Bundling a Dependency Is the Right Call
The default advice — keep dependencies external — has three genuine exceptions, and knowing them prevents both the mistake of inlining everything and the opposite mistake of shipping a dependency list longer than the library.
A tiny dependency with no shared state. A 400-byte utility that does one thing, has no runtime identity, and would otherwise appear in every consumer’s tree as a separate package with its own manifest, licence and integrity entry. Inlining it removes a resolution step and a directory for a few hundred bytes. The threshold most authors settle on is roughly one kilobyte and one file.
A dependency you are actively migrating away from. Vendoring the two functions you still use, with a comment naming the upstream package and version, is a legitimate intermediate step that removes the dependency from consumers immediately while you delete the last usages internally.
A dependency with a broken or absent ESM build. If a package ships only CommonJS, every consumer of yours who bundles for the browser pays an interop shim and loses elimination inside that module. Bundling it into your ESM output — where your build can convert it once — is sometimes strictly better for consumers than passing the problem along, provided the licence permits redistribution.
Against those, the reasons not to bundle are worth stating because they apply to almost everything else:
- Duplication. A bundled dependency cannot be de-duplicated with the consumer’s own copy, so a common library ends up in the tree twice.
- Security updates. A vulnerability in a bundled dependency requires you to publish, and every consumer to upgrade your package. Left external, their package manager fixes it without you.
- Identity. Anything with instances, registries, or
instanceofchecks breaks when two copies exist — the same failure mode as the dual-package hazard, arriving through the dependency graph. - Licence and attribution. Redistributing someone else’s code carries obligations that an external dependency does not.
// tsup.config.ts — bundle one small helper, externalise everything else
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm", "cjs"],
noExternal: ["tiny-invariant"], // deliberately inlined, ~200 bytes
external: [/^node:/, "react", "react-dom"],
});
Whichever way a decision goes, record it. A one-line comment in the build config naming why a package is in noExternal prevents the next maintainer from reverting it for tidiness, and gives a reviewer the context to challenge it if the reasoning has expired.
Finally, remember that the consumer sees only the aggregate. A library with eleven small, carefully-justified dependencies still shows up in an audit as eleven packages, eleven licences, and eleven potential advisories, and no amount of individual justification changes how that reads to a security team evaluating adoption. Periodically ask the aggregate question rather than the per-dependency one: if the whole list had to fit in five entries, which five would survive, and what would replacing the rest actually cost? The answer is often that three of them were one-function helpers whose removal costs an afternoon, and that one of the remaining large entries should have been an optional peer all along — installed by the consumers who use that integration, and by nobody else. Asking the question once per release cycle is enough to keep the list from growing quietly, which is the only way dependency lists ever grow.
It helps to put the current numbers somewhere a reader will see them without asking: the tarball size, the unpacked size, the install footprint, and the dependency count, printed by the release script and pasted into the README. Consumers evaluating two similar libraries will compare those four numbers whether or not you publish them, and a package that publishes them honestly — including the ones that are not flattering — tends to get the benefit of the doubt over one that publishes a badge and nothing else. Numbers that are checked by a script in the release pipeline also stay true, which a hand-written claim in a README reliably does not after the third release.
The Four Numbers Worth Publishing
Consumers compare libraries on these whether or not you make them easy to find, so the honest move is to measure them each release and put them in the README.
Only the third and fourth rows change when you fix a classification mistake, which is why a size badge alone tells a consumer very little about the cost of adding your package.
Guides in This Section
- Choosing Peer Dependencies vs Dependencies for Libraries — the decision rule, the range syntax, and what each choice costs consumers.
- Externalizing Dependencies in Library Bundles — build configuration that keeps dependencies out of your published files.
- Auditing Transitive Dependency Size Before Publish — measuring the tail your consumers inherit, and gating it in CI.
- Replacing Heavy Utility Dependencies with Native APIs — which removals are safe on a modern baseline, and which are false economies.
Related
- Optimizing Bundle Size for Frontend Libraries — the bundle-bytes half of the cost model above.
- Implementing the sideEffects Flag Correctly — making the code you do ship eliminable.
- package.json Fields for Distribution — the manifest semantics behind each dependency section.
- Validating Packages Before Publish — where the sandbox install described here belongs in a release pipeline.