A JavaScript dependency audit should not start with a security dashboard. It should start with a smaller question: what code is actually needed to build, run, and consume this package? A 2026 study of 33,087 JavaScript projects found that dependency reclassification is not rare maintenance noise. It happens in 79.1% of the projects studied, with removals, runtime to development moves, and peer dependency corrections showing up long after the first install.
That matches my experience. Most messy manifests were not created by one careless install. They grew from reasonable choices that became stale after a framework upgrade, a bundler migration, a test runner swap, or a package turning into a plugin surface.
The audit I would run before shipping
I use this as a production-build gate, not as a quarterly cleanup ritual. The timing matters. Right before a release, the dependency graph is fresh in memory and the build artifact gives you evidence.
The goal is not to make package.json aesthetically pure. The goal is to stop the manifest from lying about runtime requirements.
pnpm install --frozen-lockfile
pnpm build
pnpm --filter web --prod deploy /tmp/web-prod
node /tmp/web-prod/server.js
That is the smallest useful shape for a pnpm monorepo app: install from the lockfile, build, create a production deploy directory for one package, and boot the result. For npm, the equivalent check is usually a clean install that omits development dependencies, then the same boot smoke test.
If the production artifact starts without a package, that package probably does not belong in runtime dependencies. If it crashes because a package is missing, the manifest may be under-declaring runtime needs.
Classify by who needs the package
I like a boring three-question test.
| Question | Likely role |
|---|---|
| Does production code import it at runtime? | dependencies |
| Is it only used to lint, test, build, generate, or type-check? | devDependencies |
| Is this package a plugin that expects the host app to provide the main library? | peerDependencies |
npm's package docs describe peer dependencies as a compatibility contract with a host tool or library, especially plugin-style packages. That is the part teams often miss. A design-system package that renders React components should usually treat React as a peer. A Next.js app that directly runs React at runtime needs React as an app dependency.
The mistake is treating these fields as ownership labels. They are installation contracts.
Check the production install, not your laptop
Local development hides too much. The workspace root has everything. The lockfile knows everything. Your editor has transitive packages in memory. A dev server can pass while the deploy image is wrong.
npm's omit option is useful here because omitted dependency types are still resolved into the lockfile, but they are not placed on disk. That distinction is important. A production install can remain reproducible while still excluding dev-only packages from the actual runtime tree.
pnpm has its own sharp edge in monorepos. pnpm prune --prod removes dev dependencies, but the docs call out that prune does not currently support recursive execution across a monorepo. For a deployable workspace package, pnpm deploy --prod is often a cleaner test because it creates a portable directory with that package and its required dependencies.
This is also why I would link this audit to any runtime hardening work. If I already care about the Node permission model, I should care about what gets installed beside the process. I wrote more about that mindset in my Node.js permission model field guide.
Do not move everything to devDependencies
The lazy version of this audit is not a mass migration. Moving every build-looking package into devDependencies can break server-side rendering, code generation at startup, CLIs invoked by runtime hooks, and packages that load adapters dynamically.
A few examples I would leave alone until proven otherwise:
- A database client imported by API routes belongs in
dependencies. - A schema library used for runtime request validation belongs in
dependencies. - A code generator used only during
pnpm buildbelongs indevDependencies. - A React plugin package should usually declare React as a peer dependency, not bundle its own copy.
The test is evidence from the production artifact. Guessing from package names is how teams create the same problem in the opposite direction.
Add one small CI check
I would not add a new dependency scanner just for this. The useful check is a production boot smoke test that fails when the manifest lies.
For an app, that can be as small as:
- Build from a clean install.
- Create a production-only install or deploy directory.
- Start the app with
NODE_ENV=production. - Hit one health route or render one page.
For a library, pack the package and install it into a tiny fixture app. Then import the public API and verify that peer dependencies fail clearly when missing.
This pairs well with build-tool changes because bundlers can hide accidental runtime imports. When I compared Vite and Turbopack for React developers, the practical lesson was not that one tool removes dependency discipline. It was that faster feedback makes bad boundaries show up sooner if you give the build a realistic production shape.
Review removals differently from moves
The 2026 study's most useful warning is that dependency role changes are not final. Some projects remove packages and later bring them back. Others switch roles repeatedly. That is a signal that the team is guessing or that the package crosses a real boundary.
So I review changes in two buckets.
Removals should answer: what import, script, generated file, or transitive workaround disappeared?
Role moves should answer: which environment still needs this package after the move?
That keeps the review concrete. A diff that moves zod from dependencies to devDependencies is suspicious if API handlers validate requests with it. A diff that moves eslint the other way is suspicious unless production truly invokes it.
The takeaway
Dependency fields are executable documentation. They tell package managers what to install, tell consumers what they must provide, and tell deploy systems what can be left behind. Treating them as a live contract is a small habit, but it pays off every time a production image gets smaller, a plugin avoids duplicate hosts, or a release catches a missing runtime package before users do.