Building

How I Built a Zero-Dependency npm Package from Scratch

March 15, 20268 min readby Rahul Patel

The story behind auto-api-observe a zero-dependency Express/Fastify observability middleware. Architecture decisions, AsyncLocalStorage, and why I said no to every dependency.

Node.jsnpmOpen SourceAPILens
Share:

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 audit becomes 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 uuid v9 and someone's app already uses uuid v7, 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.

src/context.ts
import { AsyncLocalStorage } from "node:async_hooks";

export interface RequestContext {
  requestId: string;
  method: string;
  path: string;
  startTime: number;
  statusCode?: number;
  responseTime?: number;
  userAgent?: string;
  ip?: string;
}

// One instance, module-level. Safe  each request gets its own store value.
export const requestContext = new AsyncLocalStorage<RequestContext>();

export function getContext(): RequestContext | undefined {
  return requestContext.getStore();
}

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.

src/express.ts
import { AsyncLocalStorage } from "node:async_hooks";
import type { Request, Response, NextFunction } from "express";
import type { RequestContext } from "./context";
import { requestContext } from "./context";
import { generateId } from "./utils";

export function createExpressMiddleware(
  onRequest: (ctx: RequestContext) => void
) {
  return function observeMiddleware(
    req: Request,
    res: Response,
    next: NextFunction
  ): void {
    const requestId = generateId();
    const startTime = Date.now();

    const ctx: RequestContext = {
      requestId,
      method: req.method,
      path: req.path,
      startTime,
      userAgent: req.headers["user-agent"],
      ip: req.ip ?? req.socket.remoteAddress,
    };

    // Intercept res.end to capture response data
    const originalEnd = res.end.bind(res);

    // @ts-expect-error  overriding a typed method
    res.end = function (...args: Parameters<typeof res.end>) {
      ctx.statusCode = res.statusCode;
      ctx.responseTime = Date.now() - startTime;

      // Restore and call original immediately
      res.end = originalEnd;
      const result = originalEnd(...args);

      // Fire callback with completed context
      try {
        onRequest(ctx);
      } catch {
        // Never let observer errors bubble into the app
      }

      return result;
    };

    // Run next() inside the AsyncLocalStorage context so all downstream
    // code in this request can call getContext() and find the store.
    requestContext.run(ctx, () => next());
  };
}
Warning

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.

src/fastify.ts
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import type { RequestContext } from "./context";
import { requestContext } from "./context";
import { generateId } from "./utils";

export interface FastifyObserveOptions {
  onRequest: (ctx: RequestContext) => void;
}

export async function fastifyObserve(
  fastify: FastifyInstance,
  options: FastifyObserveOptions
): Promise<void> {
  fastify.addHook("onRequest", async (request: FastifyRequest) => {
    const ctx: RequestContext = {
      requestId: generateId(),
      method: request.method,
      path: request.url,
      startTime: Date.now(),
      userAgent: request.headers["user-agent"],
      ip: request.ip,
    };

    // Attach to request object for the response hook to find it
    // This avoids needing AsyncLocalStorage for Fastify's sync hook chain
    (request as FastifyRequest & { _observeCtx: RequestContext })._observeCtx =
      ctx;

    // Also set AsyncLocalStorage so downstream async code can use getContext()
    // Fastify hooks are called in the same async context as the handler
    requestContext.enterWith(ctx);
  });

  fastify.addHook(
    "onResponse",
    async (request: FastifyRequest, reply: FastifyReply) => {
      const ctx = (
        request as FastifyRequest & { _observeCtx?: RequestContext }
      )._observeCtx;
      if (!ctx) return;

      ctx.statusCode = reply.statusCode;
      ctx.responseTime = Date.now() - ctx.startTime;

      try {
        options.onRequest(ctx);
      } catch {
        // Swallow observer errors
      }
    }
  );
}
Note

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.

src/utils.ts
import { randomUUID } from "node:crypto";

/**
 * Generate a unique request ID.
 * Falls back to a timestamp+random combo for environments where
 * crypto.randomUUID isn't available (Node.js < 14.17.0).
 */
export function generateId(): string {
  if (typeof randomUUID === "function") {
    return randomUUID();
  }
  // Fallback: timestamp + 4 random hex segments
  const ts = Date.now().toString(36);
  const rand = Math.random().toString(36).slice(2, 10);
  return `${ts}-${rand}`;
}

/**
 * Format bytes to human-readable string.
 * No Number.toLocaleString()  consistent output across locales.
 */
export function formatBytes(bytes: number): string {
  if (bytes < 1024) return `${bytes}B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}

/** High-resolution timing for request duration */
export function hrNow(): bigint {
  return process.hrtime.bigint();
}

export function hrDiff(start: bigint): number {
  return Number(process.hrtime.bigint() - start) / 1_000_000; // ms
}

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.

tsup.config.ts
import { defineConfig } from "tsup";

export default defineConfig({
  entry: ["src/index.ts"],
  format: ["cjs", "esm"],
  dts: true,          // Generate .d.ts type declarations
  splitting: false,   // Single file output per format
  sourcemap: true,
  clean: true,
  minify: false,      // Don't minify  source maps + debuggability matter for middleware
  treeshake: true,
  target: "node16",  // Match our minimum Node.js version
  outDir: "dist",
  // Keep these node: imports as-is  don't bundle them
  external: ["node:async_hooks", "node:crypto", "node:stream"],
});
package.json (exports field)
{
  "name": "auto-api-observe",
  "version": "1.0.0",
  "description": "Zero-dependency Express/Fastify observability middleware",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.js"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    }
  },
  "files": ["dist"],
  "engines": { "node": ">=16.0.0" },
  "peerDependencies": {
    "express": ">=4.0.0",
    "fastify": ">=3.0.0"
  },
  "peerDependenciesMeta": {
    "express": { "optional": true },
    "fastify": { "optional": true }
  }
}

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.

src/index.ts
export interface ObserveOptions<TContext extends RequestContext = RequestContext> {
  /**
   * Called after each request completes.
   * Runs asynchronously  errors here will not affect the response.
   */
  onRequest: (ctx: TContext) => void | Promise<void>;

  /**
   * Extend the context before it's stored.
   * Use this to add custom fields from req (e.g., userId, tenantId).
   */
  enrichContext?: (req: unknown, ctx: RequestContext) => TContext;

  /**
   * Skip observing specific routes. Return true to skip.
   */
  shouldSkip?: (method: string, path: string) => boolean;
}

// Usage with custom context:
// createExpressMiddleware<{ userId?: string }>({
//   enrichContext: (req, ctx) => ({ ...ctx, userId: (req as any).user?.id }),
//   onRequest: (ctx) => console.log(ctx.userId, ctx.responseTime),
// });

Lessons Learned

  • Bundle size matters even for Node.js packages. Developers run du -sh node_modules and 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 enrichContext generic 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.

more from the notebook

March 1, 2026Technical

AsyncLocalStorage in Node.js: The Complete Guide with Real Examples

Everything you need to know about AsyncLocalStorage from basic context tracking to building a full request-scoped loggi…

January 15, 2026Building

589 MCP Tools in One Monorepo: How I Built CMS MCP Hub

Architecture decisions behind CMS MCP Hub a Turborepo monorepo of MCP servers for 12 CMS platforms. Zod validation, uni…

December 10, 2025Opinion

You Don't Need 50 Dependencies Build Your Own Framework

Why I'm building a Node.js framework with zero external dependencies. The real cost of npm install, and what happens whe…