How-to › Maintain libraries across languages

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.

Audience
Library maintainer
Level
intermediate
Topic
Choose and build small utilities
Languages
TypeScript
Verified

Read first: Decide whether to depend on, copy, or write a utility

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
Copying the code into your repoA finished function of twenty or so lines under a permissive licenseNo upstream fix reaches it, and no scanner or SBOM lists itThe code still changes upstream, or it sits on a security edge
esbuild metafile bundle analysisCode that ships to a browser or an edge runtime, where bytes and parse time countA bundler run per candidate, and numbers that mean little for a library that only runs on a serverThe library runs only in Node, where install size and maintenance matter more
npm pack and npm ls measurementsAny npm package, before it reaches the lockfileIt counts files that never load, such as sources, maps, and tests, so it overstates runtime costYou need runtime cost rather than disk and download size
Writing your ownBehavior you can state in one sentence and pin with one test tableEvery edge case becomes yours to find, and so does every choice of semanticsThe 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.

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

  1. The registry keeps both numbers as fileCount and unpackedSize in each version’s dist record, and its package metadata documentation 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. ↩︎ Back to text

  2. The release is the interesting part. gex reached 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 same gex.ts, line for line, moved into a src directory. A cadence count scores that release exactly as it would score a fix. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.