Auditing Transitive Dependency Size Before Publish
Measure what a library really costs to install: sandbox measurement, per-dependency attribution, duplicate detection, and a CI budget that fails the build.
A library’s own files are usually the smallest part of what a consumer installs. The number that matters is the whole subtree, and it is invisible from inside the repository:
npm notice package size: 14.8 kB
npm notice unpacked size: 56.1 kB
du -sh node_modules # in a consumer's project, after installing that same package
38M node_modules
Fifty-six kilobytes published, thirty-eight megabytes installed. This guide covers measuring that number reliably, attributing it to specific dependencies, and turning it into a budget that fails a build rather than a retrospective.
Root Cause
npm pack reports what you ship. Everything else in a consumer’s node_modules arrives because your manifest asked for it, transitively, and nothing in the publish output mentions it. The gap is widest for packages that depend on a general-purpose SDK, a compiler, or a utility library that itself has a trailing dependency chain.
Measuring inside your own repository does not close the gap either — that tree contains dev dependencies, workspace links and hoisted packages a consumer never sees. The only reliable measurement installs the packed tarball into an empty project, which reproduces the consumer’s tree exactly.
Minimal Reproduction
{
"name": "@scope/my-library",
"version": "1.0.0",
"dependencies": {
"chalk": "^5.3.0",
"@aws-sdk/client-s3": "^3.600.0",
"lodash": "^4.17.21"
}
}
The manifest lists three dependencies. A consumer’s install contains several hundred packages, dominated by one entry that arrived transitively and was never a deliberate choice.
Where the Footprint Comes From
Step-by-Step Fix
1. Measure the real tree in a disposable sandbox
#!/usr/bin/env bash
set -euo pipefail
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
echo "footprint: $(du -sm node_modules | cut -f1) MB"
echo "packages: $(npm ls --all --parseable | wc -l)"
du -sm node_modules/* node_modules/@*/* 2>/dev/null | sort -rn | head -8
footprint: 38 MB
packages: 412
31 node_modules/@aws-sdk
5 node_modules/lodash
1 node_modules/tslib
2. Attribute the cost by removing and re-measuring
The tree view tells you which top-level directory is large; it does not tell you which of your dependencies is responsible, because transitive packages sit at the same level. Removing one dependency and re-measuring gives the true attributed cost.
for dep in chalk @aws-sdk/client-s3 lodash; do
d=$(mktemp -d)
node -e '
const fs = require("fs");
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
delete pkg.dependencies[process.argv[1]];
fs.writeFileSync(process.argv[2] + "/package.json", JSON.stringify(pkg));
' "$dep" "$d"
(cd "$d" && npm install --silent >/dev/null 2>&1 || true)
echo "$dep costs $(( 38 - $(du -sm "$d/node_modules" 2>/dev/null | cut -f1 || echo 38) )) MB"
rm -rf "$d"
done
Expected result: one dependency accounts for most of the number, and the decision about it is worth more than every other optimisation combined.
3. Find duplicates the consumer will also get
npm ls --all 2>/dev/null | grep -E "^\S+@|deduped" | \
awk -F'@' '{print $1}' | sort | uniq -c | sort -rn | head
Multiple non-deduped copies of the same package usually mean a pinned range somewhere in your tree. Where the pin is yours, widening it to a caret range lets the package manager collapse the copies.
4. Record a baseline and gate it
{
"megabytes": 6,
"packages": 24
}
#!/usr/bin/env bash
set -euo pipefail
base=$(node -p "JSON.stringify(require('./footprint.json'))")
max_mb=$(node -p "require('./footprint.json').megabytes")
max_pkgs=$(node -p "require('./footprint.json').packages")
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)
pkgs=$(npm ls --all --parseable | wc -l)
echo "measured ${mb}MB / ${pkgs} packages (baseline ${base})"
[ "$mb" -le "$max_mb" ] && [ "$pkgs" -le "$max_pkgs" ] || {
echo "FAIL: footprint regressed — update footprint.json deliberately if this is intended"
exit 1
}
HAZARD PREVENTION
Symptom: The footprint gate passes locally and fails in CI with a much larger number.
Root cause: The local sandbox reused a warm npm cache that already had platform-specific optional dependencies resolved, or the local platform skipped an optional native package that CI installs.
Fix: Measure with
--no-optionaldisabled but pin the platform in CI, and treat the CI number as authoritative — it is closer to what a consumer on a clean machine receives.
Verification
# the audit reproduces on a clean cache
npm cache clean --force >/dev/null 2>&1 || true
bash scripts/footprint.sh
# no unexpected new top-level packages since the last release
npm ls --all --parseable | sed 's|.*/node_modules/||' | sort -u > /tmp/now.txt
diff <(sort -u dependencies.lock.txt) /tmp/now.txt || echo "tree changed — review above"
Expected: a footprint within a megabyte of the recorded baseline, and an empty diff against the recorded package list.
Edge Cases / Gotchas
- pnpm’s store makes
dumisleading. Its content-addressable store hard-links packages, so disk usage under pnpm understates what an npm consumer would download; measure with npm for the published number even if you develop with pnpm. - Optional native dependencies vary by platform. A package with prebuilt binaries per platform installs one on your machine and a different one in CI, and the sizes can differ substantially.
npm ls --allcounts deduped entries. The package count it reports is closer to “distinct packages resolved” than “directories on disk”; use it as a trend indicator rather than an absolute.- A workspace changes the shape entirely. Measuring a package inside a monorepo picks up hoisted siblings; always pack and install rather than measuring in place.
- Type packages are footprint too. A dependency on a large
@types/*package costs consumers who install it, and many are far larger than the runtime package they describe.
Frequently Asked Questions
Why measure in a sandbox rather than in the repository?
A repository’s node_modules contains dev dependencies, workspace links and hoisted packages a consumer never installs, which overstates the footprint by an order of magnitude. Installing the packed tarball into an empty project reproduces exactly what a consumer receives.
Is the unpacked size reported by npm the same as the install footprint?
No. Unpacked size covers only your own files; the footprint includes every transitive dependency. A 50 kB package with a 30 MB footprint is common, and the second number is the one consumers feel.
Which number should a budget be set against?
Two: total megabytes and total package count. Size catches one large dependency, count catches the slow accumulation of small ones, and either regression deserves a conversation in the pull request that caused it.
How do I attribute footprint to a specific dependency?
Measure with it present, remove it from the manifest, re-install and measure again. The difference is its true cost including the transitive tail only it pulls in.
Do optional and peer dependencies count toward the footprint?
Peers do not — the consumer installs them for their own reasons. Optional dependencies do when they install successfully, which makes them easy to overlook when a native accelerator ships a large prebuilt binary.
Related
- Reducing Dependency Weight in Published Packages — the decisions this audit informs.
- Measuring Bundle Impact with Bundlephobia and webpack analyzer — the bundle-bytes measurement that complements this one.
- Replacing Heavy Utility Dependencies with Native APIs — what to do about the entries this audit names.