Run npm install on a freshly scaffolded Express app. Then run npm ls --all 2>/dev/null | wc -l. Go ahead, I'll wait.
On a recent project I inherited, that number was 847. Eight hundred and forty-seven lines of dependency tree. The app itself was a REST API with six endpoints. It stored data in Postgres and sent emails. That's it.
Somewhere in the last decade, installing 200 transitive dependencies for a TODO app became the default. I want to talk about why that's a problem, and what I've been doing about it.
The Real Costs Not the Ones You Think
Bundle size is the obvious one, and the one nobody actually fixes. But the costs I care about are more insidious.
Security vulnerabilities you didn't write
A while back I ran npm audit on a production Node.js API at a client's request. The report came back with 47 high-severity vulnerabilities. I spent about 20 minutes going through each one. Not a single vulnerability was in code I had written. Every single one was buried in transitive dependencies packages that packages that packages that I actually needed had pulled in.
Try explaining that to a client. "Yes, there are 47 high-severity vulnerabilities. No, I didn't write any of the vulnerable code. Yes, it's still my problem."
Every dependency you add is a dependency you implicitly vouch for. You're trusting not just the package, but every package that package trusts, and every package *those* packages trust. You have no idea who most of those people are.
The left-pad, colors.js, node-ipc problem
You've heard about left-pad. A developer unpublished a tiny string-padding utility and broke half the internet's CI pipelines overnight. That was 2016. We didn't learn.
In 2022, the maintainer of colors.js and faker.js deliberately corrupted his own packages to protest unpaid open source labor. Thousands of projects pulled in infinite loops and broken output because they trusted a string that npm install printed.
Also in 2022, node-ipc a package with 1 million weekly downloads had malicious code added that would delete files if it detected you were in Russia or Belarus. It was in a *minor* version bump.
These aren't edge cases. They're the supply chain when you've decided that "batteries included" means pulling in 200 packages to avoid writing 50 lines of code.
Cognitive overhead
This is the one that actually slows down development the most, and nobody talks about it. Every dependency is a surface area your team has to understand. When something breaks, you have to know whether the bug is in your code, in a library, or in the interaction between two libraries that don't quite agree on how to handle an edge case.
I've watched junior developers spend three hours debugging something because they didn't understand how passport.js serializes sessions because they don't need to understand it, it "just works." Until it doesn't.
"Zero Dependencies" Is a Spectrum
When I say zero dependencies, I don't mean "never use npm." That's not pragmatism, that's theater. I mean: every dependency you add should be a deliberate decision you can justify, not a default.
- Zero deps nothing in
dependencies. Runtime needs only Node.js built-ins. Best for libraries and middleware. - Minimal deps a handful of well-chosen packages. Zod for validation, date-fns for dates, a real test runner. Every one is justified.
- Framework-level you're building on a framework (Next.js, NestJS). You're accepting a large dependency graph in exchange for a solved infrastructure problem.
The framework case is legitimate. But a lot of apps that import a framework also import 40 more packages on top of it, each solving a problem that the framework already solves, or that the developer didn't need to solve at all.
Building Your Own HTTP Router Is Not That Hard
Here's what a usable HTTP router looks like in pure Node.js. Not "production-ready" in the sense of handling every edge case, but production-ready in the sense of shipping real apps:
That's 40 lines. It handles GET /users/:id, POST /api/orders, nested paths, async handlers, and 404s. No express, no fastify, no koa. You own every line of it.
You'll need to add body parsing, query string parsing, and middleware support before this is truly production-ready. Each of those is another 20-40 lines and you'll understand exactly what's happening at every step.
Building Your Own Validation Is Not That Hard Either
I'm not saying don't use Zod. Zod is excellent and I use it. But if you can't use it (CLI tool, library with zero-dep requirement, size-constrained environment), here's a type-safe validator that covers 80% of what most apps actually need:
Usage looks like this, with full TypeScript inference:
What You Should Still Use Dependencies For
I want to be clear: this isn't a manifesto against packages. Some things you should always reach for a library for:
- Cryptography
node:cryptois there, but bcrypt, argon2, and jose exist because cryptographic implementations need eyes on them that most developers don't have. Don't roll your own password hashing. - Date parsing
date-fnsorTemporal(native, arriving soon). Timezone edge cases will ruin your weekend if you try to handle them yourself. - Schema validation at scale Zod for anything user-facing. The DX is worth the dependency.
- Test runners Vitest or Node's built-in test runner. Both are fine. Testing infrastructure isn't worth building yourself.
- ORM/Query builders Drizzle ORM if you want type safety without magic. Writing raw SQL with a
pgpool is also fine and underrated.
The pattern: reach for a dependency when the problem domain is genuinely hard and the package is well-maintained by people who specialize in that domain. Don't reach for a dependency to avoid writing 30 lines of code.
What Building auto-api-observe Taught Me
When I built auto-api-observe the npm package that powers APILens I made a hard rule: zero runtime dependencies. The package uses Express/Fastify middleware to capture request/response data and stream it to the APILens dashboard.
The reason wasn't ideology. It was practical: I was building a monitoring tool. If my monitoring middleware itself had a vulnerability or pulled in a broken transitive dep, I'd be creating the exact problem I was trying to solve. A zero-dependency middleware is auditable in 10 minutes.
What I didn't expect was what the constraint taught me. I had to actually understand AsyncLocalStorage to track request context across async operations. I had to understand how Node.js HTTP response streams work to capture response bodies without buffering them. I had to understand how Express middleware chains execute.
None of that knowledge came for free when I was importing morgan and winston and letting them handle it. The constraint forced the understanding.
“Every abstraction you depend on is knowledge you're outsourcing. Sometimes that's the right call. But if you've never looked at what's underneath, you don't actually know what you're building on.”
The Framework I'm Building
I'm building a Node.js HTTP framework from scratch. Not because Express is bad Express is fine. Because I want to understand what a modern Node.js framework looks like when you design it from the ground up with TypeScript, async/await, and Node.js 22+ in mind. No legacy baggage.
Decisions I've made so far:
- Zero runtime dependencies in the core. Everything in
node:http,node:crypto,node:stream,node:url. - TypeScript-first not "supports TypeScript." The router types infer path parameters from string literals.
route('/users/:id')gives youparams.idasstringwith full IDE autocomplete. - Middleware is just async functions
(req, res, next) => Promise<void>. No magic objects, no monkey-patching. - Context objects over globals request-scoped context via
AsyncLocalStorage, notreq.user = ...mutation. - No decorators I've used NestJS extensively. The decorator pattern is clever until you're debugging why your
@Injectable()isn't injecting. I want plain functions.
Is this going to replace Fastify? No. Fastify is battle-tested and faster than anything I'll write. But it's teaching me things I couldn't learn any other way.
The Nuance: Be Intentional, Not Dogmatic
The point is not "never use npm." The point is: treat every `npm install` as a decision, not a reflex.
Before adding a package, ask:
- 1.What does this package actually do? Could I implement the part I need in under 100 lines?
- 2.How many transitive dependencies does it add? (
npm install --dry-run) - 3.When was it last updated? How responsive is the maintainer?
- 4.What happens to my app if this package gets abandoned, corrupted, or goes malicious?
- 5.Is this in my
dependenciesordevDependencies? Many things that end up independenciesshould be indevDependencies.
If you can answer those questions and still want the package, install it. If you can't answer them, you've already found out more about your own dependency hygiene than npm audit ever will.
The best codebase I've ever worked in had 12 direct dependencies and 43 total transitive ones. Everything worked, everything was understandable, and npm audit came back clean every time. It was a deliberate, years-long effort by one architect who cared about this. It's achievable.