Issue №127 Summer 2026A journal of dev tools, libraries & small ideasUpdated weekly
devops · 5 min read
devopsJune 8, 2026 · 5 min

bundle-cost-cli

CLI that prints bundle cost delta on every save

bundle-cost-cli screenshot
01
devops

I built a CLI that tells me my bundle cost before I commit it

The bug report is always the same shape. "The app feels slow on mobile." You open the network tab, and there it is: the main bundle has quietly grown by 80 KB over the last month. Nobody did anything obviously wrong. Somebody added a date library here, a "small" utility there, an icon set that pulls in the whole pack. Each change looked fine in isolation. The cost only became visible in aggregate, weeks later, to a user on a slow connection.

I wanted to move that feedback to the moment it actually happens — the save. So I built bundle-cost, a small command-line tool that prints the real over-the-wire size of your build output and the delta every time a file changes.

What it does

At its simplest, you point it at some files:

bundle-cost dist/index.js dist/index.cjs

and it prints an aligned table of raw, gzip, and (with --brotli) brotli sizes, plus a total. The interesting mode is --watch:

bundle-cost dist/index.js --watch

The first render is your baseline. After that, every save adds a Δ gzip column — green when the bundle shrank, red when it grew. Run it next to your bundler's own watch process and you get a live cost readout for free. The moment you type import { everything } from 'some-lib', you see +71 KB before the diff ever leaves your machine.

There's a --limit flag for CI:

bundle-cost dist/index.js --limit 50kb

If the total gzip size is over budget, it prints a red and exits 1, so your pipeline fails instead of silently shipping the regression. And --json gives you machine-readable output to store as an artifact or diff between runs.

Two decisions I'm happy with

Measure bytes, don't estimate them. There's no minifier and no bundler hook inside this tool. It reads each file as-is and runs it through Node's built-in zlibgzipSync and brotliCompressSync. That's deliberate. gzip and brotli are what a CDN actually serves, so compressing the real file is the most honest number I can give you, and it keeps the tool down to a single runtime dependency (the argument parser). It measures what you ship, not a model of it.

Make all the I/O injectable. A CLI is mostly side effects: it writes to stdout, reads the filesystem, and — in watch mode — sets up an OS-level file watcher that never naturally returns. That's exactly the stuff that's painful to test and easy to leave uncovered. So the core run(argv, deps) function takes its log, error, cwd, env, and watch as injected dependencies, defaulting to the real ones only at the very edge.

That one choice is what got the project to 100% test coverage without spawning a single subprocess or touching the real terminal. The watcher is the clearest example. Instead of calling fs.watch directly inside the command, the watch logic takes a tiny interface:

export type WatchImpl = (path: string, onChange: () => void) => { close(): void };

The real implementation wraps fs.watch; the tests pass a fake that hands back the onChange callback so I can "trigger a save" synchronously and assert on the delta that gets rendered. I can simulate a file changing, a file being deleted mid-watch, and a clean shutdown — all deterministically, in milliseconds. The production path still uses the real watcher, and a separate test swaps in a mock of node:fs to cover that wiring too.

The same idea covers the annoying corners: forcing NO_COLOR to check the no-color branch, feeding a bogus --limit to hit the parse-error path, pointing at a missing file to exercise the read-error handler. None of it needs the real environment, because the real environment is just the default argument.

Try it

# install globally
pnpm add -g bundle-cost-cli

# or run ad-hoc without installing
pnpm dlx bundle-cost-cli dist/index.js

It's TypeScript, built with tsup into ESM and CJS, ships its types, and exports the same building blocks (measureFile, buildReport, renderReport) as a library if you'd rather wire bundle cost into your own scripts. Node 18+, MIT licensed.

What's next

A few things I want to add: glob support so you can pass dist/**/*.js without your shell expanding it, an optional comparison against a saved baseline file (so the delta survives across sessions, not just within one watch run), and a --gzip-level knob for people who tune their CDN compression. If you try it and hit a rough edge, I'd genuinely like to hear about it — that's the fastest way the tool gets better.

The whole thing is a small idea: make the cost visible at the moment you create it. Turns out that's most of the battle.


End of essay

About the author
Er An Khoo