When I was building APILens my API observability SaaS I needed a way to capture every HTTP request and response flowing through an Express or Fastify app. Not just the status code. The full picture: method, path, headers, body, response time, errors. I needed this to work transparently as middleware, add zero overhead, and ship as a standalone npm package called auto-api-observe.
The first version had three dependencies. By the time I published v1.0.0, it had zero. This post explains the architectural decisions that got me there, and why I'd make the same call again.
Why Zero Dependencies?
Every dependency you ship is a maintenance contract you didn't sign consciously. It's a security surface. It's a breaking-change risk on someone else's release schedule. For a middleware package code that runs on every single HTTP request in production this risk is multiplied by every downstream project that installs you.
- Security surface: each dep is a potential CVE.
npm auditbecomes your nightmare. - Bundle size: middleware installs into Node.js server code, but developers care about what they're pulling into
node_modules. - Version conflicts: if you depend on
uuidv9 and someone's app already usesuuidv7, you're either fine or you're debugging a subtle mismatch at 2am. - Peer pressure to maintain: a dep that goes unmaintained doesn't just slow you down it signals to users that your package is stale.
- Runtime startup cost: every
require()at module load adds milliseconds. Not many. But they add up.
The honest question to ask before adding any dependency: "Could I write this myself in under 100 lines?" For the things auto-api-observe needs generating unique IDs, timing requests, capturing body streams the answer was yes.
The Core Problem: Request-Scoped Context
The hardest part of API observability middleware isn't capturing the request that's straightforward. The hard part is correlating the request with the response and anything that happens in between (database queries, downstream HTTP calls, thrown errors) without threading a `req` object through every function in your codebase.
The old pattern for this was to attach a context object to req and pass req everywhere or worse, use a module-level global. Both are ugly. The modern solution is AsyncLocalStorage, introduced in Node.js 12.17.0 and stabilized in Node.js 16.
AsyncLocalStorage: Thread-Local Storage for Node.js
Node.js is single-threaded, but it handles many concurrent requests via the event loop. AsyncLocalStorage lets you store data that's scoped to a specific asynchronous execution context like a request and retrieve it from anywhere in that context without passing it explicitly. Think of it as thread-local storage, except for async call chains.
The key insight: AsyncLocalStorage is a single module-level instance, but each call to requestContext.run(store, callback) creates an isolated store for that callback's entire async subtree. Anything called inside that subtree awaited Promises, setTimeout callbacks, event handlers inherits the same store.
Intercepting Express Without Monkey-Patching
The standard approach for Express middleware is clean: you get (req, res, next). The trick is that you need to capture the response which means intercepting res.end() and res.json() without breaking anything.
The res.end intercept pattern is the standard way to capture response data in Express. Do NOT use res.on('finish', ...) if you need to read the response body the stream is already consumed by then. For response body capture, intercept res.write and res.end both, collecting chunks.
Fastify Is Different: Use Hooks, Not Middleware
Fastify has a proper plugin/hook system that makes observability cleaner. Instead of intercepting response methods, you register lifecycle hooks: onRequest for setup and onResponse for teardown.
I'm using requestContext.enterWith(ctx) in Fastify hooks instead of run() because the hook's async context is already established by Fastify's lifecycle. Using run() here would create a child context that doesn't propagate back to the parent hook chain correctly.
The generateId Utility: Zero Dependencies
I needed unique request IDs. The obvious answer is uuid but that's a dependency for literally 20 lines of code. Node.js has had crypto.randomUUID() since v14.17.0 and it's cryptographically secure.
Publishing: tsup, Dual CJS/ESM, Semantic Versioning
The build setup took longer than the code. The npm ecosystem is still in a painful transition between CommonJS and ESM. Middleware packages need to support both Express projects are almost always CJS, while newer Fastify or standalone Node.js apps might be ESM.
The peerDependencies setup is important: Express and Fastify are optional peers because a user will have exactly one of them installed. Listing them as regular dependencies would mean shipping both in every install, which is absurd.
TypeScript Generics for the User-Facing API
One thing I got right: making the callback type generic so users can extend the context without losing type safety.
Lessons Learned
- Bundle size matters even for Node.js packages. Developers run
du -sh node_modulesand judge you. - AsyncLocalStorage is production-ready. I was skeptical about performance. Benchmarks showed < 0.5% overhead on 10k req/s. Non-issue.
- The `exports` field in package.json is not optional anymore. Bundlers and modern Node.js use it. Ship it correctly or deal with CJS/ESM import bugs.
- Peer dependencies > regular dependencies for middleware. Never bundle the framework your users already have.
- TypeScript generics at the API boundary pay off. The
enrichContextgeneric made the package useful in authenticated apps without any code changes from me. - Never let observer errors reach the user's response. Wrap every callback in try/catch. You are infrastructure code crashing the app because telemetry failed is unacceptable.
auto-api-observe ships with 0 runtime dependencies, works with Express 4 and 5, Fastify 3 and 4, requires Node.js 16+, and produces a 4KB CJS bundle and 3.8KB ESM bundle. Install it with npm install auto-api-observe and start capturing request data in three lines of middleware registration.