# How to measure what a small dependency costs before you add it

> Take four numbers for a one-purpose package, install size, bundle bytes, parse cost, and maintenance, and set them beside a copy and a version of your own.

Source: https://voxgig.com/howto/measure-what-a-small-dependency-costs

- Audience: library-maintainer
- Level: intermediate
- Languages: typescript
- Verified: 2026-10-21
- Published: 2026-10-21

## 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](https://opensource.org/license/mit) | 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](https://esbuild.github.io/api/#metafile) | 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](https://docs.npmjs.com/cli/v10/commands/npm-pack/) | 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](https://nodejs.org/docs/latest-v22.x/api/test.html) | 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:

```ts title="install.ts"
/** 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.

```bash
node --experimental-strip-types measure.ts install
```

```text output
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.

```ts title="bundle.ts"
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,
  }
}
```

```bash
node --experimental-strip-types measure.ts bundle
```

```text output
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](https://esbuild.github.io/api/#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`](https://nodejs.org/docs/latest-v22.x/api/cli.html#--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](https://docs.deps.dev/api/v3/) for the open issue count and the
[OpenSSF Scorecard](https://scorecard.dev/) result.

```ts title="upkeep.ts"
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) ?? '-',
  }
}
```

```bash
node --experimental-strip-types measure.ts upkeep
```

```text output
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](https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained) 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](https://docs.github.com/en/rest/issues/issues), 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:

```ts title="measure.test.ts"
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'])
})
```

```bash
node --experimental-strip-types --test measure.test.ts
```

```text output
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1462.56875
```

[`npm sbom`](https://docs.npmjs.com/cli/v10/commands/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](https://esbuild.github.io/api/#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 `/*!`:

```bash
node --experimental-strip-types pitfall.ts
```

```text output
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

- [Package one utility for Node, Deno, Bun, and browsers](/howto/package-a-utility-for-node-deno-and-browsers)

- [Judge whether an unofficial SDK is safe to depend on](/howto/judge-an-unofficial-sdk-before-adoption)

- [Write a CVE response runbook for an SDK](/howto/write-a-cve-response-runbook-for-an-sdk)

- [Decide whether to depend on, copy, or write a utility](/howto/depend-copy-or-write-a-small-utility)

## 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.

[^1]: The registry keeps both numbers as `fileCount` and `unpackedSize` in each version's `dist`
record, and its
[package metadata documentation](https://github.com/npm/registry/blob/main/docs/responses/package-metadata.md)
dates them from February 2018. The nine versions of `gex` published before then carry neither.
Packing the installed copy of `gex` gave 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.

[^2]: The release is the interesting part. `gex` reached npm on March 3, 2011, and its
[registry record](https://registry.npmjs.org/gex) shows 4.1.2 published on April 28, 2026, nearly
four years after 4.0.1. The two versions carry the same `gex.ts`, line for line, moved into a `src`
directory. A cadence count scores that release exactly as it would score a fix.