Node.js Developer Experience (DX) Web Performance Signal vs Noise

The Dependency-Count Audit: Every Package Is a Bet, Not a Free Tool

Static Signal
A vast dark steampunk warehouse filled with rows of towering brass shelving units, each shelf holding hundreds of glowing glass jars connected by tangled copper wiring; in the foreground a single workbench under a hanging amber lamp where a brass-handled magnifying lens hovers over an open ledger, with several jars set aside in a small crate marked for removal, the rest of the warehouse fading into atmospheric fog and neon teal accent lighting along the floor tracks, cinematic shallow depth of field, moody editorial composition, no text, no people

The first thing I do when I inherit a JavaScript project is open package.json and count the dependencies. Not skim — count. The number is almost always larger than the person who hired me believes. A site that “just renders some content” has eighty-seven runtime packages. A marketing landing page has a hundred and forty. An internal tool has two hundred and thirty.

None of those numbers are intrinsically wrong. Some packages do real work and earn their place. The problem isn’t the count — it’s that nobody has audited the count in years. Each dependency was added for a reason that made sense at the time, and then the project moved on, and now the dependency sits there accruing security advisories, install time, bundle size, and maintenance debt because removing it feels riskier than leaving it.

This essay is the audit method I run on every project I take over. It takes a day for a medium-sized codebase and it almost always cuts the dependency count in half. Some of what comes out is dead code paths. Some of it is functionality the runtime now provides natively. Some of it is wrappers around three lines of code that the original author imported because they were the kind of person who imports things.

The point isn’t to chase a low number. The point is that every package in your package.json is a bet, and most projects have never asked whether the bet still pays.


What Each Dependency Actually Costs

Engineers tend to think of a dependency as free unless it has obvious bundle-size implications. That mental model is wrong, and getting it right is the prerequisite to taking the audit seriously.

A dependency costs you, at minimum, five things:

Install time. Every package in package.json (and every transitive dependency it pulls) has to be resolved, downloaded, extracted, and linked on every npm install. On a CI pipeline that runs install on every PR, a hundred-package node_modules adds seconds; a thousand-package one adds minutes. Multiply by the number of builds per day across the team.

Disk and inode footprint. A typical node_modules for a mature project is between 300MB and 2GB. That’s not a problem on a laptop with a terabyte of SSD. It is a problem on a CI runner with limited disk, on a Docker image that has to be cached and pulled across the world, and on a developer machine that’s juggling forty projects.

Bundle size, sometimes. Build-time dependencies don’t ship to the browser. Runtime dependencies do. The distinction matters and we’ll come back to it. But for the ones that do ship, every kilobyte is real — bundle size is the single largest controllable factor in Lighthouse scores on content sites, and the median page is now hauling around two megabytes of JS that does almost nothing visible.

Security surface. Every package is a potential vector. The npm ecosystem has had high-profile supply-chain incidents almost every year for the last decade — typosquats, hijacked maintainer accounts, intentionally malicious updates. Each dep you keep is one more surface that has to be patched when an advisory lands. Dependabot will tell you when, but it won’t tell you whether the dep needed to exist in the first place.

Cognitive overhead. Every dependency is an API the team has to remember exists, an upgrade path that has to be tracked, a breaking change that has to be absorbed when the maintainer ships a major version. A package.json with a hundred deps is a hundred relationships the team is maintaining whether they realize it or not.

If you internalize these five costs, the audit gets much easier. You stop asking “does this package break anything if I remove it?” and start asking “is this package paying enough rent to keep its room?”


The Four Buckets

Every dependency in your project falls into one of four buckets. The audit is a process of sorting each one into a bucket and then making a decision based on the bucket.

Bucket 1: Load-bearing infrastructure. Your framework, your build tool, your test runner. Next.js, Astro, React, Vite, Vitest, TypeScript, ESLint. These are non-negotiable in the sense that ripping them out is its own project, not part of this audit. Note them and move on.

Bucket 2: Earning its keep. Packages that do substantial, hard-to-replicate work and are used heavily across the codebase. A markdown processor like remark for a content site. A date library like date-fns if you’re doing serious date math. An ORM like drizzle if you’ve genuinely built around it. Used in many files, replacing them would take weeks, and the cost is justified by the value. Keep.

Bucket 3: Solving a problem the platform now solves. This is the largest bucket on most older projects and the one with the biggest cleanup wins. node-fetch, dotenv, glob, lodash (mostly), uuid, classnames, nanoid, body-parser, cors middleware on Express, half the polyfills, most date-formatting helpers. The runtime, the framework, or the standard library has caught up. The package is still there because nobody noticed.

Bucket 4: Imported once for three lines. A dep used in exactly one file, for a function the developer could have written inline. is-odd. left-pad. array-flatten on a Node version that has .flat(). A “shortcut” package that exports a one-liner. These are easy wins — grep for the import, copy the package’s source into a local utility, uninstall.

Most projects, in my experience, end up with about 20% of deps in bucket 1, 25% in bucket 2, 40% in bucket 3, and 15% in bucket 4. The 55% in buckets 3 and 4 is where the audit pays for itself.


The Tools That Make the Audit Tractable

You can’t do this by reading package.json and guessing. You need data. Four tools cover ninety percent of what the audit requires:

npm ls --depth=0 lists the top-level dependencies as they’re actually installed, which sometimes differs from package.json in subtle ways (hoisted peer deps, optional deps). Start here.

depcheck scans your source code and tells you which declared dependencies aren’t imported anywhere. It has false positives — packages used in scripts, packages loaded by config files, packages imported in CSS — but it’s an extremely fast filter for bucket 4 candidates. Run it first; investigate everything it flags.

npx depcheck

npm-check or npx npm-check -u is the interactive version: it shows unused deps, outdated versions, and lets you toggle each one on a TUI. Useful if you prefer to make decisions one at a time rather than batch.

Your bundler’s analyzer. For Next.js it’s @next/bundle-analyzer; for Vite it’s rollup-plugin-visualizer; for Webpack it’s webpack-bundle-analyzer. These show what’s actually shipping to the browser — which is what matters for the runtime-vs-build-time distinction. A package can be in dependencies but still not ship if it’s only used in server code; a package can be in devDependencies and still ship if you accidentally imported it from a client component. The analyzer is the source of truth.

# Next.js example
ANALYZE=true npm run build

The audit method is: run depcheck for the dead weight, run the analyzer for what actually ships, then walk the remaining list manually with the four-bucket framework. For most projects this is a few hours of focused work, not a sprint.


What’s In Bucket 3 Right Now (May 2026)

The bucket-3 list changes year over year as runtimes and standards advance. Here’s what’s on it in mid-2026, for a typical Node 22+ project on a modern framework:

node-fetch, isomorphic-fetch, cross-fetch. Node has fetch globally since 18. The browser has had it forever. These wrappers existed to paper over the missing-on-server problem that no longer exists.

dotenv. Node 20 added --env-file=.env natively. Next.js, Vite, and most modern frameworks load .env files automatically. The package is now in the “running it adds nothing” category on most projects.

glob (the standalone package). Node 22 added fs.glob and fs.globSync. Most scripts that imported glob can drop it.

uuid. crypto.randomUUID() has been globally available in Node since 19 and in browsers since 2022. The uuid package only earns its place if you specifically need v3, v5, or namespace-based UUIDs.

classnames / clsx. Honest case here — clsx is 240 bytes and classnames isn’t much bigger. They’re not a huge win to remove. But on a modern Tailwind project with the tailwind-merge ecosystem, you may already have a class-composition helper that does what these do, and shipping both is waste.

lodash. This one needs nuance. lodash as a whole is large and most of it is replaceable by language built-ins (Array.prototype.flat, Array.prototype.flatMap, optional chaining, nullish coalescing, structured clone). If you import the whole package or even lodash-es and only use _.debounce and _.cloneDeep, you’re shipping a lot of dead weight. The fix is either tree-shakable per-method imports (lodash/debounce) or replacing the few methods you actually use with small local utilities. Audit which lodash methods are used, not just whether lodash is used.

moment.js. I still see it. It’s been on its own deprecation page since 2020. date-fns, dayjs, or Temporal (when it’s broadly available) all replace it at a fraction of the size.

request, axios on the server. request was deprecated in 2020. axios is fine but rarely necessary on the server when fetch exists. On the browser, axios has a legitimate case for interceptors and richer error handling. On Node, it’s usually inertia.

body-parser, cookie-parser, cors (with Express). Express 5 (released 2024) folds body-parser back into the core. Most Express middleware that was separate packages a decade ago has been consolidated. Express is itself a bucket-3 candidate on most green-field projects in 2026 — Fastify, Hono, or framework-native routing have largely replaced it for new work — but that’s a bigger conversation than a line-item audit.

Most polyfills. core-js, regenerator-runtime, whatwg-fetch. Check your browserslist target. If you’re targeting modern evergreen browsers, you can remove most of these and let your bundler tree-shake the rest. If you’re not, you’re probably shipping kilobytes of polyfills for browsers that account for less than 1% of your traffic.

This list is not “remove these without thinking.” It’s a starting set for the audit. Each candidate needs a one-minute investigation: grep -r "from 'package-name'" to see what it’s used for, then a decision based on the four buckets.


Build-Time Deps Are a Different Conversation

The most important distinction in this whole audit is between dependencies that ship to the browser and dependencies that don’t.

Build-time tooling — TypeScript, ESLint, Prettier, your test runner, your bundler — doesn’t go in your production bundle. It costs you install time, disk, and security surface, but not bundle size. The audit can be more permissive here because the consequences are bounded. If eslint-plugin-import adds two seconds to install but catches real bugs, it stays.

Runtime dependencies — anything imported by code that runs in the browser or in your serverless function — has the full cost stack. The bundle analyzer is the tool that distinguishes which is which, and it’s the one that produces actionable line items.

A useful mental shortcut: a package.json with 200 devDependencies and 30 dependencies is healthier than one with 100 of each. The shape of the dependency tree matters at least as much as the size.

If depcheck says a package isn’t imported anywhere, double-check whether it’s referenced in package.json scripts, in next.config.js, in tailwind.config.js, or in some other config file before deleting. Build configs are the most common source of false positives.


What an Actual Audit Looks Like, End to End

The shape of an audit on a typical Next.js content site I did last quarter:

Starting state: 142 total dependencies (114 in dependencies, 28 in devDependencies). Install time: 47 seconds cold. node_modules size: 612MB. Built bundle: 218KB gzipped first load.

Step 1: depcheck. Flagged 23 unused packages. After investigating, 19 were actually unused and four were used in build configs (false positives). Removed the 19. Bundle size: unchanged (they weren’t shipping). Install time: 41 seconds.

Step 2: Replace the bucket-3 obvious ones. node-fetch, dotenv, uuid, glob. Five packages removed, three direct imports rewritten, two scripts updated to use Node’s native env file flag. About 90 minutes of work. Install time: 38 seconds. Bundle size: down 4KB gzipped (mostly uuid).

Step 3: Lodash audit. The codebase imported lodash (full bundle) and used: _.debounce, _.throttle, _.cloneDeep, _.uniqBy, _.groupBy. Replaced four of the five with small local utilities (15 lines total) and kept _.cloneDeep because the use case was complex. Switched from lodash to lodash/cloneDeep (single-method import). Bundle size: down 28KB gzipped — the biggest single win in the audit.

Step 4: Moment.js. One component used moment for relative time formatting (“3 hours ago”). Replaced with dayjs (already a transitive dep) and the project’s bundle dropped another 64KB gzipped. Total bundle size after this step: 122KB. Almost halved from the starting state.

Step 5: Wrapper packages. Found three single-use wrapper packages (is-plain-object, array-uniq, path-is-absolute). Each was used in one file, each was three lines of code, each is now a local utility. Three packages removed.

Ending state: 89 total dependencies (down 53). Install time: 32 seconds (down 32%). node_modules size: 419MB (down 31%). Built bundle: 122KB gzipped (down 44%).

The whole audit took a day and a half of focused work. The Lighthouse performance score on the home page went from 78 to 94. The deploy time dropped by a minute and a half. The “security advisories” badge on the GitHub repo went from twelve to two.

None of that came from new features or clever optimizations. It came from removing things that weren’t pulling their weight.


What This Doesn’t Look Like

A few anti-patterns I’ve seen on dependency audits, mostly self-inflicted:

Chasing a vanity number. The goal isn’t to brag about a single-digit package.json. Some projects legitimately need fifty packages. The goal is to remove what isn’t earning its place, not to reach a threshold.

Replacing well-maintained libraries with hand-rolled equivalents. If a package does something genuinely complex — date math across time zones, markdown parsing, parsing JSON safely — keep the package. Your hand-rolled version will have subtle bugs the maintained library has already fixed. The audit is about removing low-value packages, not romanticizing self-reliance.

Doing it as a giant PR. A dependency audit that touches forty files and changes thirty imports in a single PR is unreviewable and prone to subtle regressions. Break it into batches: one PR for depcheck cleanup, one for runtime polyfills, one for the lodash migration, one for moment.js, one for wrappers. Each is small, reviewable, and reversible.

Doing it once and never again. New deps accrete. The audit needs to be repeated periodically — annually for most projects, quarterly for projects under active development. Add a CI check that compares the current dep count against the previous PR’s count and flags increases for explicit review. It doesn’t have to block; it just has to make growth visible.


The Boring Conclusion

Most JavaScript projects are wearing a backpack full of rocks they picked up over the years and forgot to put down. None of the rocks were a bad idea individually. Together, they’re slowing the project down — at install time, at build time, at page load, on the security dashboard, in the team’s head.

The audit isn’t glamorous work. There’s no new framework to learn, no demo to ship, no Twitter thread to write. It’s two hours of grep, two hours of decisions, two hours of replacing packages with local utilities, and an afternoon of testing that nothing broke. The reward is a faster, smaller, more secure project that the team will appreciate every day without remembering why.

The version of you that adds dependencies isn’t the version of you that maintains them. The audit is the conversation between those two versions, and most projects haven’t had it in years.

Open package.json. Count. Then look up.