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

Declared dependencies versus installed tree Three declared dependencies expand into an installed tree where one SDK contributes the overwhelming majority of both package count and disk size, while the other two contribute very little. Three declared entries, one dominant cost @aws-sdk/client-s3 31 MB lodash 4.9 MB chalk 0.1 MB your files 0.06 MB Optimising the bottom row first is the classic mistake

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

Gating the install footprint A pull request triggers a sandbox install, which measures megabytes and package count and compares both against a committed baseline file. Within budget the check passes; over budget it fails and names the dependency responsible. Two numbers, one committed baseline sandbox install the packed tarball measure MB and package count compare footprint.json in the repo within budget baseline updated in the same PR over budget fails, naming the new packages
{
  "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-optional disabled 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 du misleading. 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 --all counts 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.



↑ Back to Reducing Dependency Weight in Published Packages