Issue №134 Summer 2026A journal of dev tools, libraries & small ideasUpdated weekly
devops · 5 min read
devopsJuly 20, 2026 · 5 min

npm-bloat-detector

npm-bloat-detector is a tiny command-line tool that shows you which dependencies are actually taking up the most space in your project. Instead of guessing from registry estimates, it walks your installed `node_modules`, measures the real on-disk bytes of every declared dependency (resolving pnpm an

npm-bloat-detector — live demo screenshot
01
devops

I built a CLI to find the dependencies eating my bundle

Every few months I open a project, run du -sh node_modules, and wince. Half a gig. Where did it all go? The honest answer is I never really knew — I'd npm ls, squint at a tree with 900 lines in it, and give up. The tree tells you what is installed. It doesn't tell you what's heavy.

So I built a small tool that answers exactly that question: npm-bloat-detector. You run it in a project and it prints the five dependencies taking up the most space on disk, as a bar chart, in about a second.

Top 5 of 47 dependencies by installed size
████████████████████   9.4 MB  next
██████████░░░░░░░░░░   4.8 MB  typescript
████████░░░░░░░░░░░░   3.9 MB  @swc/core
███░░░░░░░░░░░░░░░░░░   1.6 MB  react-dom
██░░░░░░░░░░░░░░░░░░░   1.1 MB  date-fns
Total: 38.2 MB across 47 deps

That's the whole pitch. But a couple of the design decisions turned out to be more interesting than I expected, so here's the write-up.

Real bytes, not estimates

There are great tools that estimate dependency size from the registry — Bundlephobia is the one everybody knows. But registry numbers answer a different question ("how big is the published tarball / minified bundle?"), and they need a network round-trip. I wanted the answer to "how big is this thing right now, on my machine," and I wanted it offline and instant.

So the tool doesn't estimate anything. It reads your package.json, and for each declared dependency it walks the actual files in node_modules and adds up their sizes. What you see is what's on disk.

The gotcha: symlinks

Here's the part that makes node_modules sizing trickier than "recursively sum a folder."

Modern package managers don't copy packages into every node_modules. pnpm keeps a single content-addressed store and symlinks each package into place; npm hoists and sometimes links too. If you naively follow every symlink and sum, two bad things happen:

  1. You double-count. A dependency shared by ten packages gets measured ten times.
  2. You can hit a cycle and walk forever.

My first instinct — du-style, follow everything — gave numbers that were both wrong and non-deterministic between runs. Not useful.

The fix is a two-part rule. When I measure a top-level package, I resolve its install symlink once with realpathSync, because the top-level entry in node_modules/<pkg> is itself a link into the store — I need the real directory it points at. But inside that directory, I skip symlinks entirely:

for (const entry of readdirSync(dir, { withFileTypes: true })) {
  const full = join(dir, entry.name);
  if (entry.isDirectory()) {
    total += directorySize(full);   // real subfolder → recurse
  } else if (entry.isFile()) {
    total += statSync(full).size;   // real file → count it
  }
  // a symlink? skip it — no double-counting, no cycles
}

The result is a stable "bytes this package contributes" number: the package's own files, every time, regardless of package manager. It's honest about what it is — not the full transitive closure, but the thing you can actually act on when you're deciding what to drop.

Making it useful in CI

Once you can measure sizes, two features fall out almost for free, and they're the ones I use most.

The first is a budget gate. Pass --budget 50mb and the tool prints its report and exits non-zero when the total blows past the limit. That's a one-line job in a CI pipeline that stops a careless pnpm add from quietly adding 80 MB.

The second is baseline diffing. Save a snapshot now:

npm-bloat-detector --save .bloat.json

…then after an upgrade, diff against it:

npm-bloat-detector --baseline .bloat.json

Every row picks up a delta — red when a dependency grew, green when it shrank, new for anything that wasn't there before. This is the feature that turns "the bundle got bigger, no idea why" into "oh, that minor version bump added 1.2 MB." I keep the baseline file in the repo and treat unexpected growth like a failing test.

Building it

It's a small, boring stack on purpose: TypeScript, commander for arg parsing, tsup for bundling, and Vitest for tests. The core is pure functions — measure a directory, read the manifest, build a report, render it — with all I/O injectable, so the whole thing is tested end-to-end against temp-directory fixtures at 100% coverage. The CLI layer is a thin shell that wires those functions to process.argv and an exit code.

One small touch I like: everything the CLI does is also exported, so you can import { analyze, buildReport, renderReport } and drop the same logic into your own scripts or a custom check.

Try it

pnpm add -g npm-bloat-detector
# or, no install:
pnpm dlx npm-bloat-detector

Run it in any project with an installed node_modules. It needs Node 18+.

What's next

A couple of ideas I'm sitting on: an optional transitive-size mode for people who want the full closure (with clear "shared" accounting), a --markdown reporter for pasting into PRs, and a tiny GitHub Action wrapper around the budget gate. But honestly the current version already earns its place in my toolbox — it turned a vague annoyance into a number I can look at. If you give it a spin and it surprises you (mine always finds one dependency I forgot I installed), I'd love to hear what it flags.


End of essay

About the author
Er An Khoo