A pull request adds a package for one job, such as matching *.example.com or filling a message
template, and the review stalls on whether it is too big. One reviewer quotes the install size and
another the bundle size, which can differ several times over. Who will fix the package’s next bug
goes unasked, so the decision follows whoever argues longest.
What you get
You will end up with four numbers for each candidate package, a copy, and your own version, taken
with npm pack, npm ls, esbuild, and a dated snapshot. This is for you if you review dependency
changes to a library.
Short answer
Measure a small package with npm, esbuild, and a dated registry snapshot before it reaches the
lockfile. npm pack --dry-run and npm ls --all give what installs. An esbuild metafile gives
the bytes it adds to a bundle and the source a runtime parses. The snapshot gives releases, open
issues and pull requests, and the Scorecard Maintained check. Measure a copy and your own version
the same way, then weigh the numbers against who fixes the next bug.
You will need
Node 22 or later, and the npm that ships with it. Verified 2026-10-21 against Node 22.22.0,
npm 10.9.4, esbuild 0.28.2, gex 4.1.2, inks 3.0.1, and picomatch 4.0.7.
The sample measures six candidates for two jobs. For a host allowlist it takes gex, picomatch,
a copy of the twenty lines at the core of gex, and a matcher written from scratch. For a greeting
it takes inks and a template literal. Maintenance data comes from files saved in snapshot/, so
no command on this page makes a network call, and none of them prints an import time, which changes
from run to run.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Copying the code into your repo | A finished function of twenty or so lines under a permissive license | No upstream fix reaches it, and no scanner or SBOM lists it | The code still changes upstream, or it sits on a security edge |
esbuild metafile bundle analysis | Code that ships to a browser or an edge runtime, where bytes and parse time count | A bundler run per candidate, and numbers that mean little for a library that only runs on a server | The library runs only in Node, where install size and maintenance matter more |
| npm pack and npm ls measurements | Any npm package, before it reaches the lockfile | It counts files that never load, such as sources, maps, and tests, so it overstates runtime cost | You need runtime cost rather than disk and download size |
| Writing your own | Behavior you can state in one sentence and pin with one test table | Every edge case becomes yours to find, and so does every choice of semantics | The subject has a specification you would be rewriting, such as glob syntax |
Voxgig maintains gex and inks. This page measures them beside picomatch and a template literal.
The npm numbers are the cheapest to take and the furthest from runtime cost. The metafile comes
closer to what users pay, at the price of a bundler run per candidate. A copy measures small but
stops receiving fixes, because neither npm audit nor an SBOM can see a file that is not a package.
Your own version measures smallest, with every edge case left to you.
Count what installs with npm pack and npm ls
npm ls --all --omit=dev gives the tree your users install, and --long adds the directory each
package sits in. Running npm pack --dry-run on each of those directories reads back the files and
bytes the registry published.1 install.ts adds them up per direct dependency:
/** Every installed copy under a package, itself included, by the directory npm put it in. */
function installed(node: Node, out = new Set<string>()): Set<string> {
if (!node.path) return out
out.add(node.path)
for (const sub of Object.values(node.dependencies ?? {})) installed(sub, out)
return out
}
/** Files and bytes on disk for one installed copy, read back with npm pack. */
function packed(dir: string) {
const [info] = npm('pack', '--dry-run', '--ignore-scripts', dir)
return { files: info.entryCount as number, bytes: info.unpackedSize as number }
}
export function measureInstall(name: string, shipped: Record<string, Node>) {
const copies = [...installed(shipped[name])]
const sizes = copies.map((dir) => packed(dir))
return {
name,
version: shipped[name].version ?? '',
packages: copies.length,
files: sizes.reduce((sum, s) => sum + s.files, 0),
bytes: sizes.reduce((sum, s) => sum + s.bytes, 0),
}
}
Packing the directory rather than the name matters when one package nests a copy of another
package. That copy can be a different version from the one at the top of node_modules, and only
its own directory holds the bytes your users get.
node --experimental-strip-types measure.ts install
package version packages files unpacked bytes
gex 4.1.2 1 9 19,589
inks 3.0.1 2 60 74,666
picomatch 4.0.7 1 10 93,759
npm ls --all without --omit=dev: 31 entries, 27 of them esbuild, 25 of those not installed
inks counts as two packages, because it depends on @hapi/hoek. The bytes overstate what runs:
gex ships its TypeScript source, a source map, and a build-info file beside the compiled file
that loads. The last line shows why --omit=dev matters: esbuild alone fills 27 entries, most of
them builds for other platforms that npm lists without installing.
Measure the bundle and the parse with an esbuild metafile
A metafile attributes every byte of a bundle to the input that produced it. bundle.ts builds one
entry per candidate, each doing the same job, and reads the minified bytes the entry adds along
with the input files and source bytes a runtime has to parse.
export async function measureBundle(name: string) {
const result = await esbuild.build({
entryPoints: [`entries/${name}.ts`],
bundle: true,
minify: true,
format: 'esm',
platform: 'browser',
target: 'es2022',
metafile: true,
write: false,
logLevel: 'silent',
})
const [output] = Object.values(result.metafile.outputs)
const inputs = Object.entries(result.metafile.inputs)
return {
name,
bytes: output.bytes,
files: inputs.length,
source: inputs.reduce((sum, [, input]) => sum + input.bytes, 0),
packages: [...new Set(inputs.map(([path]) => packageOf(path)).filter(Boolean))].sort(),
text: result.outputFiles[0].text,
}
}
node --experimental-strip-types measure.ts bundle
entry bundle bytes input files source bytes packages
copy 1,699 2 1,940 -
gex 2,144 2 3,385 gex
own 194 2 625 -
picomatch 24,545 7 62,459 picomatch
inks 17,096 27 40,228 @hapi/hoek, inks
template 82 1 162 -
inks brings 25 files of @hapi/hoek into the bundle to call two of its functions, reach and
escapeRegex. The library’s CommonJS index requires every module, directly or through another, and
esbuild’s tree shaking does not work on CommonJS, so
all of it comes along. The template literal does the same job in 82 bytes. Most of the copy’s 1,699
bytes are its license notice, the subject of the second failure below. Source bytes stand in for
import time, which node --cpu-prof
measures on your machine, but which varies between runs.
Read maintenance from a dated snapshot
upkeep.ts reads saved files rather than the network: npm view <package> time --json for every
release date, and the deps.dev API for the open issue count and the
OpenSSF Scorecard result.
export function upkeep(pkg: string, registryFile: string, projectFile: string) {
const times: Record<string, string> = read(registryFile)
const releases = Object.entries(times)
.filter(([version]) => version !== 'created' && version !== 'modified')
.map(([, at]) => Date.parse(at))
const end = Date.parse(taken)
const project = read(projectFile)
const maintained = project.scorecard?.checks.find((c: { name: string }) => c.name === 'Maintained')
return {
pkg,
releases: releases.filter((at) => end - at <= 730 * DAY).length,
last: new Date(Math.max(...releases)).toISOString().slice(0, 10),
// GitHub counts an open pull request as an issue, and deps.dev passes that count on
openIssuesAndPulls: project.openIssuesCount as number,
maintained: maintained ? `${maintained.score} of 10` : 'no score',
scored: project.scorecard?.date?.slice(0, 10) ?? '-',
}
}
node --experimental-strip-types measure.ts upkeep
snapshot taken 2026-10-20
package releases in 2 years last release issues and PRs Maintained scored on
gex 1 2026-04-28 0 0 of 10 2026-08-24
inks 0 2024-06-07 9 0 of 10 2026-08-24
@hapi/hoek 4 2026-07-31 3 7 of 10 2026-08-24
picomatch 7 2026-08-24 62 10 of 10 2026-08-24
picomatch releases often, carries 62 open issues and pull requests, and scores 10. gex scores 0,
with one release in two years and no open issues or pull requests.2 The
Maintained check gives its
top score for a commit a week over 90 days, and its own documentation notes that smaller utility
functions do not normally need maintaining. A zero on a hundred-line matcher and a zero on a parser
are different findings.
The issue count comes from GitHub, which
counts every pull request as an issue, so the nine
for inks are Dependabot pull requests rather than issues. The Scorecard score comes from the last
run deps.dev stored, eight weeks before this snapshot, which is why the table prints its date.
Check it worked
Six tests pin what the tables imply. The one that speaks to the trade-off asks npm for an SBOM:
test('an SBOM lists every installed package and nothing for the copy', () => {
const env = { ...process.env, npm_config_update_notifier: 'false' }
const sbom = JSON.parse(execFileSync('npm', ['sbom', '--omit=dev', '--sbom-format=cyclonedx'], { encoding: 'utf8', env }))
const names = sbom.components.map((c: { name: string }) => c.name).sort()
assert.deepEqual(names, ['@hapi/hoek', 'gex', 'inks', 'picomatch'])
})
node --experimental-strip-types --test measure.test.ts
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1462.56875
npm sbom lists four packages. The copied
function is not a package, so no scanner that reads the SBOM can report an advisory against it. The
other tests check that the copy matches exactly what gex matches, that the hand-written matcher
keeps its chosen semantics, and that inks and the template literal agree. Two more check that the
copied license notice survives minification and that a nested copy is measured where npm installed
it.
When it goes wrong
The dependency count includes your build tools. npm ls --all without --omit=dev reported 31
entries for this sample, 27 of them for esbuild. Of those, 25 are builds for other platforms, which
npm lists without installing. Measure with --omit=dev, which matches what your users install.
The copied license notice disappears from the bundle. With minify on, esbuild drops comments and
keeps only legal comments: those that start with
/*! or //!, or contain @license or @preserve. The MIT license asks for its notice in all
copies, so the sample opens the copied header with /*!:
node --experimental-strip-types pitfall.ts
notice opens with bundle bytes notice in bundle
/*! 1697 true
/* 311 false
The notice costs 1,386 bytes per bundle, more than seven times the hand-written matcher. That is the price of copying rather than writing.
The snapshot goes stale. Its release counts describe the day it was taken, and its deps.dev figures
can be older, as the Scorecard date shows. Take the snapshot again at each upgrade, and keep its
date beside it, as snapshot/meta.json does.
When not to do this
Do not measure a package you have no real alternative to. A cryptographic library, or a parser for a specified format, earns its place on correctness, and these numbers cannot weigh that.
Do not let these numbers choose between gex and picomatch for a path router. gex lets * cross
/, while picomatch stops at it, which settles a router before any byte count does.
Do not copy only to shorten the dependency list. The copy still needs its license notice, its tests, and someone who watches the original for fixes.
Do not read the inks row as a verdict on template engines. It measures one package and one
transitive dependency, and a template literal only competes when the template lives in your code
rather than in data.
Related how-tos
Last verified
Verified 2026-10-21 against Node 22.22.0, npm 10.9.4, esbuild 0.28.2, gex 4.1.2, inks 3.0.1, and
picomatch 4.0.7. Every output block is what the command preceding it printed, run in the page’s
code directory after npm ci. The maintenance figures come from the registry and deps.dev, saved
the day before, not from a live call. The same calls made on the day of verification returned the
same figures. The inks pull requests were read on GitHub. Import times were not measured.
Footnotes
-
The registry keeps both numbers as
fileCountandunpackedSizein each version’sdistrecord, and its package metadata documentation dates them from February 2018. The nine versions ofgexpublished before then carry neither. Packing the installed copy ofgexgave the same 9 files and 19,589 bytes that the registry reports for 4.1.2, which is a small confirmation that the tarball installs as published. ↩︎ Back to text -
The release is the interesting part.
gexreached npm on March 3, 2011, and its registry record shows 4.1.2 published on April 28, 2026, nearly four years after 4.0.1. The two versions carry the samegex.ts, line for line, moved into asrcdirectory. A cadence count scores that release exactly as it would score a fix. ↩︎ Back to text