autocannon

Activity, over time

Daily observations from the public event archive.

Attention

Daily stars & forks

Loading attention chart

Star accumulation

Cumulative stars

Loading star accumulation chart

Pull requests

Daily opens & merges

Loading pull requests chart

Conversations

Daily issues & comments

Loading conversations chart

Community

Daily accounts & reviews

Loading community chart

Releases

Daily releases

Loading releases chart

Mentions

2posts · 30d
Nov 30 posts
  1. DEV · · @fabwaseem

    MCP vs API: When You Need Which, With Real Benchmarks I built the same small service as a REST API and as a stateless MCP server and measured latency, throughput, bytes and the tokens a model has to read. The original, with any updates, lives on fabwaseem.com. MCP vs API is not really a choice between two rival technologies. An API (usually REST) is how software talks to your service: your code calls known endpoints. MCP (Model Context Protocol) is how an AI application discovers and calls tools at runtime, and almost every MCP server sits on top of an API. The real question is who is calling: your code, or a model. I built the same five-operation projects service both ways on one machine, as a REST API and as a stateless MCP server with the official TypeScript SDK, and measured what each costs. I measured latency, throughput, bytes on the wire and, most important for agents, the tokens a model has to read. I also measured the MCP server that runs this site. Every number below comes from those runs. TL;DR: MCP or API, which should I use? Use a REST API when your own code is the caller: web and mobile apps, cron jobs, webhooks, service-to-service calls and fixed agent pipelines you control. Add an MCP server when AI clients you don't control (Claude, ChatGPT, Cursor, VS Code, Copilot) should use your service, or when the model must pick tools at runtime. Most products need both, with the MCP server wrapping the API. | | REST API | MCP server | | --- | --- | --- | | Caller | Your code, written ahead of time | An AI model inside an MCP client | | Discovery | Docs or an OpenAPI file a developer reads | tools/list at runtime | | Wire format | HTTP verbs, paths, status codes | JSON-RPC 2.0 over Streamable HTTP or stdio | | Auth | Whatever you choose (keys, OAuth, sessions) | OAuth 2.1 profile defined by the spec, or bearer tokens | | Latency in my test (p50) | 76 µs | 424 µs (848 µs when it wraps the REST API) | | Throughput, 1 Node process | 66,486 req/s | 4,595 req/s | | What it costs a model | Nothing until you write function schemas | Every tool definition, as input tokens | | Testing | Deterministic unit and contract tests | Protocol tests plus prompt-dependent evals | What is the difference between MCP and an API? An API is a contract for programs: fixed endpoints, documented ahead of time, called by code a developer wrote. MCP is a contract for AI applications: a server lists its tools with names, descriptions and JSON Schemas, and a model decides at runtime which one to call. MCP doesn't replace your API. It describes a curated slice of it in a form a model can use. MCP was open-sourced by Anthropic in November 2024. In December 2025 Anthropic donated it to the Agentic AI Foundation under the Linux Foundation, alongside OpenAI's AGENTS.md. The announcement put the count at "more than 10,000 published MCP servers" and listed Claude, Cursor, Microsoft Copilot, Gemini, VS Code and ChatGPT as adopters. The MCP tools specification is explicit about who is in charge: "Tools in MCP are designed to be model-controlled, meaning that the language model can discover and invoke tools automatically." That one sentence is the whole difference. With an API, a developer decides what to call. With MCP, the model does. Is an MCP server just an API wrapper? Usually, yes, and that's fine. In practice an MCP server is a thin adapter: each tool validates its arguments and then calls your existing service or REST API. What MCP adds is a standard way to list tools, a standard call format, tool annotations (read-only, destructive, idempotent), and an OAuth 2.1 flow every MCP client already understands. It does not add business logic. Here is the same operation exposed both ways, sharing one Zod schema: // store.ts: one schema, used by REST validation, OpenAPI and the MCP inputSchema export const SlugInput = z.object({ slug: z.string().describe('Project slug, e.g. project-42'), }) // REST: GET /api/projects/:slug if (req.method === 'GET' && slug) { SlugInput.parse({ slug }) const p = ops.get(slug) return p ? json(res, 200, { project: p }) : json(res, 404, { error: 'not_found' }) } // MCP: the same operation as a tool (@modelcontextprotocol/server 2.3.1) server.registerTool( 'get_project', { description: 'Get one project by slug.', inputSchema: SlugInput, annotations: { readOnlyHint: true }, }, async ({ slug }) => { const p = ops.get(slug) return p ? { content: [{ type: 'text', text: JSON.stringify({ project: p }) }], structuredContent: { project: p } } : { content: [{ type: 'text', text: `No project with slug ${slug}` }], isError: true } }, ) Notice the error path. REST returns a 404 your code branches on. MCP returns isError: true with a sentence, because the reader is a model that will decide what to try next. How does MCP work compared to REST? REST uses many URLs and HTTP verbs. MCP uses one endpoint and JSON-RPC: every call is a POST /mcp with a method such as tools/list or tools/call. Under the 2026-07-28 spec MCP is stateless, so each request carries its protocol version and client capabilities in _meta, plus Mcp-Method and Mcp-Name headers. The same lookup, both ways: # REST curl http://127.0.0.1:3201/api/projects/project-42 # MCP (2026-07-28, stateless, JSON response mode) curl http://127.0.0.1:3202/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -H 'MCP-Protocol-Version: 2026-07-28' \ -H 'Mcp-Method: tools/call' -H 'Mcp-Name: get_project' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{ "name":"get_project","arguments":{"slug":"project-42"}, "_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28", "io.modelcontextprotocol/clientInfo":{"name":"curl","version":"8"}, "io.modelcontextprotocol/clientCapabilities":{}}}}' The REST response was 334 bytes on the wire. The MCP response was 758 bytes, because the result carries the data twice: once as a JSON string in a text block and once as structuredContent. That's by design. The spec says a tool that returns structured content "SHOULD also return the serialized JSON in a TextContent block" for backwards compatibility. !MCP vs API architecture: with a REST API your app code calls the endpoint directly (76 µs p50); with MCP an AI host and MCP client call tools/call on an MCP server that wraps the same REST API (424 µs direct, 848 µs wrapping REST) How I benchmarked MCP vs REST 1. Service. 1,000 in-memory projects with five operations: list (search and tag filter), get, create, update and delete. Both servers share the same store and the same Zod 4.6.7 schemas, so only the protocol layer differs. 2. REST. Plain node:http with Zod validation, port 3201. 3. MCP, direct. @modelcontextprotocol/server 2.3.1 with createMcpHandler in JSON response mode, a fresh McpServer per request (the SDK's documented stateless pattern), and tools calling the store in-process. 4. MCP, wrapping REST. The same MCP server, but each tool calls the REST API over HTTP with fetch. This is the common real-world setup. 5. Runs. Node.js 24.22.0 on an 8 vCPU Intel Xeon VM with 15 GB RAM, everything on localhost. Latency: 20,000 sequential requests per case after 2,000 warm-up requests, timed with process.hrtime. Throughput: autocannon 8.0.0 for 10 seconds after a 3-second warm-up, with 1 and 50 connections. All responses were 200 with no errors. 6. Tokens. Counted with OpenAI's o200k_base tokenizer (tiktoken), on compact JSON. Other models tokenize differently, so treat these as relative sizes. Localhost removes network time on purpose. On a real network both protocols pay the same round trip, so the gap below is pure protocol and framework overhead. Is MCP slower than a REST API? Yes, per request, by a lot in relative terms and very little in absolute terms. A get_project call took 424 µs at the median over MCP against 76 µs over REST, about 5.6 times slower. At 50 connections one Node process served 66,486 REST requests per second against 4,595 MCP requests per second, about 14 times fewer. When the MCP tool called the REST API instead of the store, the median doubled again to 848 µs. | Case (get_project) | p50 | p90 | p99 | Mean | | --- | --- | --- | --- | --- | | REST GET /api/projects/:slug | 76 µs | 122 µs | 274 µs | 95 µs | | MCP tools/call (direct) | 424 µs | 709 µs | 1,164 µs | 500 µs | | MCP tools/call (wraps REST) | 848 µs | 1,216 µs | 1,878 µs | 936 µs | | Throughput, one Node process | 1 connection | 50 connections | Response size | | --- | --- | --- | --- | | REST get | 30,944 req/s | 66,486 req/s | 334 B | | MCP get (direct) | 3,216 req/s | 4,595 req/s | 758 B | | MCP get (wraps REST) | 1,622 req/s | 3,116 req/s | 758 B | | REST list (10 results) | 27,566 req/s | 48,022 req/s | 1,707 B | | MCP list (10 results) | 2,733 req/s | 3,285 req/s | 3,700 B | Where does the time go? A CPU profile of the MCP server under load showed 16.7% of samples in the SDK's request pipeline, 11.5% in Zod (parsing plus JSON Schema conversion), and most of the 25% in Node internals spent converting Node requests into Web Request objects and back. Building the per-request McpServer itself was cheap: 13.4 µs for five tools. Does that matter? For agents, no. A model turn takes hundreds of milliseconds to seconds, so 0.35 ms of protocol overhead is noise. For a high-traffic API that your own frontend calls thousands of times a second, it matters a lot. That's one more reason not to route your app's own traffic through MCP. How many tokens do MCP tools cost? The real cost of MCP is context, not CPU. Clients usually send every tool definition, with name, description and JSON Schema, to the model as input tokens on each request. My five tools cost 591 tokens as returned by tools/list, and 488 tokens once reshaped into plain function-calling definitions. The MCP server behind this site exposes 21 tools that add up to 5,949 tokens. !MCP vs REST benchmark: REST served 66,486 req/s vs 4,595 for MCP on one Node.js 24 process; 5 tools cost 488-591 tokens, the same operations as OpenAPI 933 tokens, and the 21-tool fabwaseem.com MCP server 5,949 tokens | What the model reads | Tokens (o200k_base) | | --- | --- | | 5 tools, raw tools/list result | 591 | | Same 5 tools as function-calling definitions | 488 | | OpenAPI 3.1 document for the same 5 operations (compact) | 933 | | Same OpenAPI document, pretty-printed | 1,717 | | fabwaseem.com MCP server, 21 tools | 5,949 | | Its largest tool, update_post | 1,027 | | Its smallest tool, get_site_info | 89 | | One get_project result: REST body | 46 | | One get_project result: full MCP result envelope | 140 | Three things stand out. First, an OpenAPI document is a poor thing to paste into a prompt: it describes responses, status codes and servers the model doesn't need, and for the same operations it was 1.6 times the raw tool list and 1.9 times the function definitions. Second, one rich tool can cost more than ten small ones. update_post alone is 17% of my site's tool budget because its schema lists every editable field. Third, results are cheap if the client is sensible: the text block and the structuredContent were 46 tokens each, the same as the REST body. Large catalogs get expensive fast. Anthropic's engineering team described one setup where loading tool definitions up front took 150,000 tokens. Letting the agent load only the definitions it needed through code execution with MCP cut that to 2,000 tokens, "a time and cost saving of 98.7%." Clients are adapting too. In Claude Code, tool search is on by default, so "only tool names and server instructions load at session start." When should I use MCP instead of an API? Use MCP when the caller is an AI client you don't build, or when the model must choose actions at runtime. Use a plain API, called from your own code, when the workflow is known in advance. A rule of thumb: if you could write the sequence of calls as a function today, write the function. If a person would phrase the task differently every time, give a model MCP tools. | Scenario | Use | Why | | --- | --- | --- | | Your web or mobile app's frontend | REST API (or server actions) | Fast, cacheable, no model involved | | Stripe-style webhooks, cron jobs, ETL | REST API | Deterministic, testable | | Let users manage your product from Claude, ChatGPT or Cursor | MCP server | Every MCP client can connect with no custom plugin | | Your own agent with a fixed set of 3-5 actions | Function calling against your API | Same tokens as MCP, one less hop | | Internal tools shared by several agents and IDEs | MCP server | Write once, every client discovers it | | Teaching an agent how to use those tools | An agent skill on top of MCP or a CLI | Instructions load only when needed | That last row is why "MCP vs API vs Skills" posts keep trending. They answer different questions. The API is the capability, MCP is how a model reaches it, and a skill is the instruction manual for using it well. How do I add an MCP server to an existing REST API? Wrap the API, don't rewrite it. Keep your REST endpoints as the source of truth, then expose a curated set of tools that call them. Reuse your validation schemas, return errors as sentences, and mark read-only and destructive tools with annotations. Start with read-only tools, then add writes behind a narrower OAuth scope. // MCP tool that wraps an existing REST endpoint const API = process.env.API_URL // e.g. https://api.example.com/api/projects server.registerTool( 'update_project', { description: 'Change fields on a project. Only the fields in patch are updated.', inputSchema: UpdateInput, annotations: { idempotentHint: true }, }, async ({ slug, patch }, ctx) => { const r = await fetch(`${API}/${slug}`, { method: 'PATCH', headers: { 'content-type': 'application/json', // forward the caller's own token so REST authorization still applies authorization: `Bearer ${ctx.http?.authInfo?.token}`, }, body: JSON.stringify(patch), }) if (r.status === 404) return { content: [{ type: 'text', text: `No project with slug ${slug}` }], isError: true } const data = await r.json() return { content: [{ type: 'text', text: JSON.stringify(data) }], structuredContent: data } }, ) Design rules I follow, learned from running an MCP server for this site's content: - Expose tasks, not endpoints. One publish_post tool beats making the model chain three PATCH calls. - Keep descriptions short and front-loaded. Claude Code reads only part of a long description when it matches tool search, so put the key words first. - Use scopes. This site issues per-app tokens with scopes like posts:read and posts:write, so a client with a read-only token can't call write tools. - Treat annotations as hints. The spec says clients "MUST consider tool annotations to be untrusted unless they come from trusted servers." Enforce permissions on the server. - Keep a human in the loop for destructive tools. The same spec says there "SHOULD always be a human in the loop with the ability to deny tool invocations." For a full build, see my guide to building an MCP server in Next.js with route handlers and bearer auth. Does MCP replace REST APIs? No. MCP depends on APIs. Your frontend, mobile apps, partners and background jobs still need fast, cacheable, versioned HTTP endpoints, and MCP gives them nothing. What MCP replaces is the per-client plugin: instead of writing a ChatGPT plugin, a Cursor extension and a Claude integration, you write one MCP server and every compliant client can use it. Common mistakes when choosing between MCP and an API - Generating one MCP tool per endpoint. A 60-endpoint OpenAPI file becomes 60 tools and thousands of tokens on every request. Curate. - Routing your own app's traffic through MCP. In my test that cut single-process throughput about 14-fold for nothing. - Pasting OpenAPI specs into prompts. The same operations cost 1.6 to 1.9 times more tokens as compact OpenAPI than as tool definitions. - Forgetting the double payload. Text plus structuredContent roughly doubles response bytes. Keep results small and paginated. - Trusting annotations for security. readOnlyHint is a hint. Check scopes on the server for every call. - Building on session state. Since the 2026-07-28 spec there are no sessions. Pass explicit handles in tool arguments instead; see stateless MCP servers. Related: if you do expose MCP, lock it down: MCP security in 2026 covers the OAuth SDK CVEs and per-tool scopes. Choosing a framework for the REST side? I benchmarked Hono vs Express vs Fastify on Node 24, Node 26 and Bun. Key takeaways - An API is for code that knows what to call; MCP is for AI models that discover tools at runtime and decide what to call. - Most MCP servers should wrap an existing API, reusing its validation schemas and authorization. - In my Node.js 24 test, an MCP tool call took 424 µs at the median against 76 µs for the same REST call, and 848 µs when it wrapped the REST API. - One Node process served 66,486 REST requests per second against 4,595 over MCP, so keep your app's own traffic on REST. - Tokens are MCP's real cost: 5 tools took 488-591 tokens, and this site's 21-tool MCP server takes 5,949 tokens. - The same 5 operations cost 933 tokens as compact OpenAPI, 1.6 to 1.9 times the tool definitions, so don't paste API specs into prompts. - Expose a small set of task-level tools, scope them per client, and use agent skills to teach the workflow. FAQ What is the difference between MCP and an API? An API is a contract for programs: fixed endpoints that code written by a developer calls. MCP, the Model Context Protocol, is a contract for AI applications: a server lists tools with names, descriptions and JSON Schemas, and a model decides at runtime which tool to call. Most MCP servers call an existing API underneath. Is an MCP server just an API wrapper? Usually, yes. A typical MCP server is a thin adapter that validates tool arguments and calls your existing service or REST API. MCP adds standard tool discovery through tools/list, a standard call format, tool annotations such as readOnlyHint, and an OAuth 2.1 flow MCP clients understand. It should not duplicate your business logic. Is MCP slower than REST? Per request, yes. In my test on Node.js 24.22 a tool call over MCP took 424 microseconds at the median against 76 microseconds for the same REST call, and one process served 4,595 MCP requests per second against 66,486 REST requests. For agents this is noise, because a model turn takes hundreds of milliseconds or more. How many tokens do MCP tools use? It depends on the schemas. In my test, five tools cost 591 tokens as returned by tools/list and 488 as plain function definitions, counted with the o200k_base tokenizer. The MCP server behind fabwaseem.com exposes 21 tools totalling 5,949 tokens, and its largest tool, update_post, costs 1,027 tokens by itself. When should I use MCP instead of an API? Use MCP when AI clients you don't build, such as Claude, ChatGPT, Cursor or VS Code, need to use your service, or when a model must choose actions at runtime. Use a plain API called from your own code when the sequence of calls is known in advance, for example in frontends, webhooks, cron jobs and data pipelines. Does MCP replace REST APIs? No. MCP depends on APIs. Frontends, mobile apps, partners and background jobs still need fast, cacheable, versioned HTTP endpoints. What MCP replaces is the per-client plugin: instead of building separate integrations for each AI app, you publish one MCP server that every compliant client can discover and call. Should I convert my OpenAPI spec into MCP tools? Use it as a starting point, not a one-to-one mapping. One tool per endpoint inflates the tool list the model reads on every request. In my test the same five operations cost 933 tokens as compact OpenAPI versus 488 to 591 as tool definitions. Curate a small set of task-level tools with short descriptions instead. How do MCP, APIs and agent skills fit together? They answer different questions. The API is the capability your service offers. MCP is how a model discovers and calls that capability from any compatible client. An agent skill, a SKILL.md file, teaches the agent the workflow and rules for using those tools, and it loads into context only when a task needs it. --- Originally published on fabwaseem.com.

    1 reaction · 4 comments
  2. NestJS vs Express vs Fastify in 2026: a practical comparison Originally published on devanddeliver.com. Introduction Raw throughput benchmarks make NestJS look slow - but 17,000 requests per second is 1.5 billion requests a day, and the real question is whether your codebase can handle your team. We ran the benchmarks ourselves and made the case for NestJS as the professional default for Node.js backends in 2026. Versions covered: Express 5.2.1 · Fastify 5.8.5 · NestJS 11.1.24 Benchmarks: Node.js 24.15, Apple Silicon, 100 connections, 20s Last verified: May 2026 The short version NestJS is the right default for professional backend work in Node.js in 2026. It is the only one of the three that gives you a complete application framework - dependency injection, module system, guards, interceptors, lifecycle hooks, and a testable architecture out of the box. Every serious team building a multi-module API in TypeScript will be better served by NestJS than by assembling the same things manually from Express middleware. Fastify is the right choice when raw throughput is a first-class requirement - IoT ingestion, public APIs handling tens of thousands of requests per second per instance, or anything where cold start time matters (serverless, edge). It is an excellent HTTP server. It is not an application framework. Express is the right choice when you are prototyping, maintaining an existing codebase, or need a thin layer with zero opinion. It is the foundation the Node.js ecosystem was built on. In 2026 it is still the right tool for small, focused services. The mistake is comparing all three on throughput benchmarks and concluding NestJS is the slowest. NestJS trades a portion of raw req/s for something more valuable at scale: a consistent, testable, team-friendly application architecture. If your server is handling 20,000 requests per second, it is serving over 1.7 billion requests a day. The question is not whether NestJS can handle your load. It almost certainly can. The question is whether your codebase can handle your team. First, understand what each one is Express is a routing layer with middleware. That is almost all it does - deliberately. It gives you (req, res, next) and gets out of the way. Express v5 (the current stable) arrived in late 2024 after years in beta and fixed the most common footgun: unhandled promise rejections now propagate to your error middleware automatically. Everything else is largely the same as it has been for a decade. That is partly a strength: the Express mental model is universal, its middleware ecosystem is enormous, and there are no surprises. Fastify optimises for throughput. Its two architectural choices - a compiled JSON serializer and AJV-based schema validation baked into the router - make it the fastest mainstream Node.js HTTP framework. It uses a plugin system with encapsulation rather than Express's flat middleware chain. The result is a framework that is opinionated about performance and relatively unopinionated about everything else: you still compose your own structure, your own testing patterns, your own cross-cutting concerns. NestJS is an application framework that runs on top of Express or Fastify and solves the problems those frameworks leave to you. It brings Angular-style architecture to the backend: a dependency injection container, a module system, decorators for controllers and services, guards for auth, interceptors for cross-cutting logic, exception filters for consistent error handling, and a TestingModule API that makes unit testing injectable services trivial. NestJS does not compete with Express or Fastify on throughput. It uses them as its HTTP layer and adds everything a production application actually needs on top. NestJS v11 defaults to the Express v5 adapter. It supports the Fastify adapter natively via @nestjs/platform-fastify. Framework snapshots | | Express 5.2.1 | Fastify 5.8.5 | NestJS 11.1.24 | | --- | --- | --- | --- | | Node.js minimum | 18+ | 20+ | 20+ | | TypeScript | Optional | Optional | First-class | | DI container | ❌ | ❌ | ✔️ | | Module system | ❌ | ❌ | ✔️ | | Built-in validation | ❌ | JSON Schema (AJV) | class-validator / Pipes | | Auth pattern | Middleware | Plugin hook | Guard (composable, testable) | | OpenAPI / Swagger | Manual / plugin | Manual / plugin | Auto-generated from DTOs | | Testing utilities | None built-in | None built-in | TestingModule built-in | | Microservices support | ❌ | ❌ | (gRPC, Kafka, RabbitMQ) | | Weekly npm downloads | 107M¹ | 7.9M | 10.7M | | GitHub stars | ~69k | ~33k | ~67k | ¹ Express downloads are heavily inflated by transitive dependencies - NestJS itself pulls Express under the hood. The intentional install numbers are closer to 10-15M per week. What NestJS gives you that the others do not Before the code comparison and benchmarks, it is worth being explicit about what you are actually getting with NestJS. These are not marginal conveniences - they are the reason teams choose it. DEPENDENCY INJECTION AND TESTABILITY In Express or Fastify, services depend on each other through imports, globals, or manual wiring. In NestJS, every service declares its dependencies and the DI container wires them at startup. The payoff is in testing: // Test a service in complete isolation - no real database, no HTTP server const module = await Test.createTestingModule({ providers: [ UsersService, { provide: PrismaService, useValue: { user: { findUnique: jest.fn().mockResolvedValue(mockUser) } }, }, ], }).compile(); const usersService = module.get<UsersService>(UsersService); Three lines to swap the real Prisma client for a mock. This pattern works across your entire application - any service, any dependency, no special wiring required. Achieving equivalent test isolation in Express means passing dependencies through constructors manually everywhere or introducing a third-party DI library yourself. A MODULE SYSTEM THAT SCALES WITH YOUR TEAM NestJS organises code into modules with explicit import/export boundaries. UsersModule exports UsersService. AuthModule imports UsersModule to access it. If AuthModule does not import UsersModule, it cannot access UsersService - the DI container enforces the boundary at startup. This matters less on a solo project. It matters a lot when four developers are working on the same codebase and "which service can I use from this module" has a clear, enforced answer instead of "just import the file and hope nothing breaks." GLOBAL CROSS-CUTTING CONCERNS - WRITE ONCE, APPLY EVERYWHERE // main.ts - configure once app.useGlobalPipes(new ValidationPipe({ whitelist: true })); app.useGlobalGuards(new JwtGuard()); app.useGlobalFilters(new AllExceptionsFilter()); app.useGlobalInterceptors(new LoggingInterceptor()); Every route in the application now validates input, enforces JWT auth, formats errors consistently, and logs requests - without touching a single route handler. In Express, the equivalent requires adding middleware to every router, trusting that someone remembered, and hoping the chain is in the right order. Individual routes can opt out or add their own guards without disturbing the global defaults: @Get('health') @Public() // custom decorator to skip JwtGuard on this route healthCheck() { return { status: 'ok' }; } OPENAPI DOCUMENTATION GENERATED FROM YOUR DTOS Install @nestjs/swagger, add a few decorators to your DTOs, and NestJS generates full OpenAPI documentation automatically. No separate schema file, no manual sync, no risk of docs drifting from the actual API: import { ApiProperty } from '@nestjs/swagger'; export class CreateUserDto { @ApiProperty({ example: 'Alice' }) @IsString() name: string; @ApiProperty({ example: 'alice@example.com' }) @IsEmail() email: string; } The same DTO that validates incoming requests also documents the API endpoint. One source of truth. THE BROADER ECOSYSTEM NestJS ships first-party packages for the things every real backend eventually needs: @nestjs/config (configuration with environment validation), @nestjs/schedule (cron jobs), @nestjs/bull (queues via BullMQ), @nestjs/event-emitter (in-process events), @nestjs/microservices (gRPC, Kafka, RabbitMQ, NATS with the same Guards/Interceptors pattern as HTTP). These are all maintained by the NestJS core team, follow the same conventions, and require no architectural rethinking to introduce into an existing app. Mental model: how a request moves through each Express: request → middleware A → middleware B → route handler → response Everything is a function with (req, res, next). You own the chain. Fastify: request → onRequest hooks → JSON Schema validation → handler → serialization → response Validation runs at the framework level before your handler. Invalid bodies are rejected automatically. NestJS: request → middleware → guards → interceptors (before) → pipes (transform + validate) → controller → service → interceptors (after) → exception filters → response Each layer has one job. Guards decide whether a request is allowed through. Pipes transform and validate the body. Interceptors handle logging, caching, response shaping. Exception filters handle every possible error in one place. You configure each layer once globally, then override per-route only when you need to. Code comparison: CRUD + validation The same endpoint in each framework - create a user, list users, get by ID, delete by ID. TypeScript throughout. EXPRESS V5 + ZOD // app.ts import express, { Request, Response, NextFunction } from 'express'; import { z } from 'zod'; const app = express(); app.use(express.json()); const users: { id: number; name: string; email: string }[] = []; let nextId = 1; const CreateUserSchema = z.object({ name: z.string().min(1), email: z.string().email(), }); app.get('/users', (_req: Request, res: Response) => { res.json(users); }); app.post('/users', (req: Request, res: Response) => { const result = CreateUserSchema.safeParse(req.body); if (!result.success) { return res.status(400).json({ errors: result.error.flatten() }); } const user = { id: nextId++, ...result.data }; users.push(user); res.status(201).json(user); }); app.get('/users/:id', (req: Request, res: Response) => { const user = users.find(u => u.id === Number(req.params.id)); if (!user) return res.status(404).json({ message: 'User not found' }); res.json(user); }); app.delete('/users/:id', (req: Request, res: Response) => { const idx = users.findIndex(u => u.id === Number(req.params.id)); if (idx === -1) return res.status(404).json({ message: 'User not found' }); users.splice(idx, 1); res.status(204).send(); }); // Express v5: async errors now propagate to error middleware automatically app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { res.status(500).json({ message: err.message }); }); app.listen(3000); FASTIFY V5 (BUILT-IN JSON SCHEMA) // server.ts import Fastify from 'fastify'; const app = Fastify({ logger: false }); const users: { id: number; name: string; email: string }[] = []; let nextId = 1; const bodySchema = { type: 'object', required: ['name', 'email'], properties: { name: { type: 'string', minLength: 1 }, email: { type: 'string', minLength: 3 }, }, additionalProperties: false, }; app.get('/users', async () => users); app.post('/users', { schema: { body: bodySchema } }, async (request, reply) => { const body = request.body as { name: string; email: string }; const user = { id: nextId++, ...body }; users.push(user); return reply.status(201).send(user); }); app.get<{ Params: { id: string } }>('/users/:id', async (request, reply) => { const user = users.find(u => u.id === Number(request.params.id)); if (!user) return reply.status(404).send({ message: 'User not found' }); return user; }); app.delete<{ Params: { id: string } }>('/users/:id', async (request, reply) => { const idx = users.findIndex(u => u.id === Number(request.params.id)); if (idx === -1) return reply.status(404).send({ message: 'User not found' }); users.splice(idx, 1); return reply.status(204).send(); }); app.setErrorHandler((error, _request, reply) => { reply.status(error.statusCode ?? 500).send({ message: error.message }); }); app.listen({ port: 3000 }); NESTJS V11 (CLASS-VALIDATOR + VALIDATIONPIPE) // users/dto/create-user.dto.ts import { IsEmail, IsString, MinLength } from 'class-validator'; export class CreateUserDto { @IsString() @MinLength(1) name: string; @IsEmail() email: string; } // users/users.controller.ts import { Controller, Get, Post, Delete, Param, Body, HttpCode } from '@nestjs/common'; import { CreateUserDto } from './dto/create-user.dto'; import { UsersService } from './users.service'; @Controller('users') export class UsersController { constructor(private readonly usersService: UsersService) {} @Get() findAll() { return this.usersService.findAll(); } @Post() create(@Body() dto: CreateUserDto) { return this.usersService.create(dto); } @Get(':id') findOne(@Param('id') id: string) { return this.usersService.findOne(+id); } @Delete(':id') @HttpCode(204) remove(@Param('id') id: string) { return this.usersService.remove(+id); } } // main.ts - register ValidationPipe globally once; every route gets it automatically import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalPipes(new ValidationPipe({ whitelist: true, // strips unknown properties automatically forbidNonWhitelisted: true, // returns 400 on unknown properties })); await app.listen(3000); } bootstrap(); What the comparison shows: Express and Fastify require you to choose and wire validation per route or per router. NestJS's ValidationPipe with DTOs applies to every endpoint globally. As the application grows from 4 routes to 40, this difference compounds. No new developer can forget to add validation to a new route - the framework prevents it. Code comparison: JWT auth EXPRESS - MIDDLEWARE import { Request, Response, NextFunction } from 'express'; import jwt from 'jsonwebtoken'; export const jwtMiddleware = (req: Request, res: Response, next: NextFunction) => { const token = req.headers.authorization?.split(' ')[1]; if (!token) return res.status(401).json({ message: 'Unauthorized' }); try { (req as any).user = jwt.verify(token, process.env.JWT_SECRET!); next(); } catch { res.status(401).json({ message: 'Invalid token' }); } }; FASTIFY - PLUGIN HOOK import fastifyJwt from '@fastify/jwt'; app.register(fastifyJwt, { secret: process.env.JWT_SECRET! }); app.get('/protected', { preHandler: async (request, reply) => { try { await request.jwtVerify(); } catch (err) { reply.send(err); } } }, async (request) => { return { user: request.user }; }); NESTJS - COMPOSABLE GUARD // auth/jwt.guard.ts import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common'; import * as jwt from 'jsonwebtoken'; @Injectable() export class JwtGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const req = context.switchToHttp().getRequest(); const auth = req.headers['authorization']; if (!auth?.startsWith('Bearer ')) throw new UnauthorizedException(); try { req.user = jwt.verify(auth.slice(7), process.env.JWT_SECRET!); return true; } catch { throw new UnauthorizedException(); } } } // Composing multiple guards on a single route is one line: // @UseGuards(JwtGuard, RolesGuard) // @Roles('admin') // @Delete(':id') // remove(@Param('id') id: string) { ... } // Unit testing a guard requires no HTTP server: // const guard = new JwtGuard(); // const context = createMockExecutionContext({ authorization: 'Bearer valid-token' }); // expect(guard.canActivate(context)).toBe(true); The NestJS guard model pays off when you need to compose auth logic: JWT check, role check, rate limit check, feature flag check. In Express, that is four middleware functions you must apply in the right order to the right routes and remember to test each combination. In NestJS, it is @UseGuards(JwtGuard, RolesGuard) and each guard is independently unit-testable. Performance benchmarks These numbers were run on Apple Silicon, Node.js 24.15.0, using autocannon, 100 concurrent connections, 20-second runs. No external I/O, in-memory data. GET / - BARE JSON RESPONSE | Framework | req/s | p50 | p99 | | --- | --- | --- | --- | | Fastify 5.8.5 | 100,301 | <1 ms | 1 ms | | Express 5.2.1 | 53,400 | 1 ms | 3 ms | | NestJS 11 + Fastify adapter | 41,159 | 1 ms | 7 ms | | NestJS 11 + Express adapter | 33,344 | 2 ms | 11 ms | POST /VALIDATE - JSON BODY PARSE + FRAMEWORK-NATIVE VALIDATION Fastify: built-in JSON Schema (AJV). Express: manual field checks. NestJS: class-validator + ValidationPipe. | Framework | req/s | p50 | p99 | | --- | --- | --- | --- | | Fastify 5.8.5 | 42,720 | 1 ms | 7 ms | | Express 5.2.1 | 28,764 | 3 ms | 7 ms | | NestJS 11 + Fastify adapter | 19,693 | 4 ms | 14 ms | | NestJS 11 + Express adapter | 17,432 | 4 ms | 22 ms | POST /QUERY - VALIDATE + 10MS SIMULATED DATABASE CALL Same validation as above, with a 10ms async wait added to simulate a real indexed database query. This is what production throughput actually looks like. | Framework | req/s | p50 | p99 | | --- | --- | --- | --- | | NestJS 11 + Express adapter | 8,812 | 11 ms | 16 ms | | NestJS 11 + Fastify adapter | 8,929 | 11 ms | 13 ms | | Express 5.2.1 | 8,770 | 11 ms | 13 ms | | Fastify 5.8.5 | 8,612 | 11 ms | 16 ms | All four frameworks converge to the same throughput - within margin of error, in the same order as random noise. The theoretical ceiling with 100 connections and 10ms I/O is 10,000 req/s; every framework reaches ~88% of it. The 2.5× gap from the pure-CPU benchmark disappears completely. COLD START AND IDLE MEMORY | Framework | Cold start | Idle RSS | Idle heap | | --- | --- | --- | --- | | Express 5.2.1 | 83 ms | 62 MB | 8 MB | | Fastify 5.8.5 | 137 ms | 68 MB | 11 MB | | NestJS 11 (Express adapter) | 570 ms | 95 MB | 21 MB | | NestJS 11 (Fastify adapter) | 550 ms | 95 MB | 21 MB | READING THE NUMBERS HONESTLY The pure-CPU benchmarks (GET, POST /validate) show a real gap. Fastify handles 2.5× more requests per second than NestJS when there is nothing else to wait on. That number is accurate and worth knowing. It is also largely irrelevant to the decision. The moment you add a database - even a fast indexed query at 10ms - every framework converges to the same throughput. The bottleneck becomes I/O wait time, and the 2-4ms of framework overhead NestJS adds per request disappears into the noise. In production, behind a real database, NestJS and Fastify deliver the same throughput. NestJS at 17,000 req/s (pure CPU) is already handling 1.5 billion requests per day on a single server. With a real database in the picture, all four frameworks land around the same number, and that number is determined entirely by your database latency and connection pool, not by your choice of Node.js framework. What you are actually trading by choosing NestJS: some CPU-bound throughput headroom that your server will almost certainly never fully use, in exchange for DI, testability, module boundaries, composable guards, global validation, automatic Swagger generation, and a first-party ecosystem for queues, crons, microservices, and everything else a real backend eventually needs. The cold start story is different. NestJS's ~570ms startup is the DI container initialising, decorators resolving, and modules wiring. For a long-running API server it is paid once at deploy. For serverless or short-lived containers where cold start affects every request, it is a genuine cost. Fastify at 137ms and Express at 83ms are significantly better in those environments. Switching to the Fastify adapter inside NestJS gives you around 20% better throughput on CPU-bound routes. With I/O in the picture, that 20% gap is barely measurable. The Fastify adapter is still worth using on greenfield projects  but "for performance" is the wrong reason. The right reason is that Fastify is a better underlying HTTP engine. On benchmarks you will find elsewhere: One widely cited comparison claims NestJS outperforms Express on throughput. Our numbers - and the architectural reality - do not support that. NestJS's Express adapter runs on top of Express; it cannot be faster. If you see NestJS above raw Express in a benchmark, look closely at what is being measured. NestJS project structure A production NestJS project follows a consistent, predictable shape that any NestJS developer can navigate immediately: src/ main.ts # bootstrap - global pipes, guards, filters, interceptors app.module.ts # root module common/ filters/ # AllExceptionsFilter guards/ # JwtGuard, RolesGuard interceptors/ # LoggingInterceptor, TransformInterceptor decorators/ # @CurrentUser(), @Public(), @Roles() modules/ users/ users.module.ts users.controller.ts users.service.ts dto/ create-user.dto.ts update-user.dto.ts auth/ auth.module.ts auth.service.ts strategies/ # JWT strategy prisma/ prisma.service.ts # extends PrismaClient, implements OnModuleInit prisma.module.ts A new developer joining the project knows exactly where to look for any piece of logic. common/guards/ has the auth guards. modules/users/ has the users domain. The structure is the documentation. DATABASE: PRISMA (RECOMMENDED) VS TYPEORM In 2026 most new NestJS projects reach for Prisma: // prisma/prisma.service.ts import { Injectable, OnModuleInit } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit { async onModuleInit() { await this.$connect(); } } TypeORM remains common in established codebases and still works well with NestJS. The main 2026 consideration: TypeORM's synchronize: true setting auto-migrates schema at startup and should never be enabled in production. GLOBAL EXCEPTION FILTER // common/filters/all-exceptions.filter.ts import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common'; @Catch() export class AllExceptionsFilter implements ExceptionFilter { catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); const res = ctx.getResponse(); const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; const message = exception instanceof HttpException ? exception.getResponse() : 'Internal server error'; res.status(status).json({ statusCode: status, message }); } } // Register once in main.ts: app.useGlobalFilters(new AllExceptionsFilter()); Every unhandled exception in the application now returns a consistent JSON shape. No try/catch in individual route handlers, no risk of raw error objects leaking to the client. NestJS patterns worth knowing These are not gotchas - they are architectural decisions NestJS makes explicit that other frameworks leave implicit. Circular module dependencies surface at startup, not runtime If UsersModule imports AuthModule and AuthModule imports UsersModule, NestJS throws at bootstrap: Error: Nest can't resolve dependencies of UserService (?) This is NestJS catching a circular dependency before your application takes a single request. The fix is forwardRef(), or - better - rethinking whether the two modules should be split differently. Express would silently handle this until something breaks at runtime. class-validator and the validation performance trade-off class-validator uses TypeScript reflection metadata, which has runtime cost. Our benchmarks show it reduces throughput on a three-field POST from ~28k to ~17k req/s compared to Express with manual validation. What you gain is automatic, consistent, globally applied validation across every endpoint - no per-route Zod schema, no forgotten validation on a new route, no inconsistent error shapes. For most APIs the trade-off is straightforwardly worth it. If you hit a specific endpoint where validation is genuinely a throughput bottleneck, you can bypass ValidationPipe on that route and use a custom pipe backed by Zod or raw AJV instead. The framework supports this without requiring architectural changes. RequestScoped providers: a deliberate choice, not a default @Injectable({ scope: Scope.REQUEST }) export class RequestContextService {} Request-scoped providers recreate the injection subtree on every request, which is expensive. The default is singleton scope (the right choice for almost everything). Request scope exists for cases where you genuinely need per-request context - distributed tracing with request-scoped loggers, per-request tenancy in multi-tenant systems. Use it intentionally. Lifecycle hooks have a defined order OnModuleInit fires per-module during initialisation. OnApplicationBootstrap fires once after all modules are ready. If your Prisma service connects in OnModuleInit and another service depends on it during initialisation, use OnApplicationBootstrap in the dependent service to guarantee the connection is established first. The adapter choice: Express vs Fastify under NestJS | Workload | NestJS + Express | NestJS + Fastify | Gain | | --- | --- | --- | --- | | GET / | 33,344 req/s | 41,159 req/s | +23% | | POST /validate | 17,432 req/s | 19,693 req/s | +13% | | Cold start | 570 ms | 550 ms | negligible | | Idle RSS | 95 MB | 95 MB | same | The Fastify adapter consistently delivers 15-23% more throughput. The catch: some Express-specific middleware does not work under the Fastify adapter. Older Passport strategies, certain session packages, and any middleware that directly modifies the Express req/res objects will need Fastify equivalents (@fastify/passport, @fastify/session). For a new greenfield project with no Express dependencies: use the Fastify adapter, accept the marginal tradeoff in ecosystem breadth, and get the performance headroom. For an existing project with established middleware dependencies: stay on Express adapter and save the migration effort for something with more impact. Picking your framework NestJS is the right default for backend work in Node.js when: - You are building a real application - auth, modules, services, repositories, business logic - More than one developer will touch the codebase - You need unit-testable services - You want consistent patterns across every module, not just the ones the original author wrote - You are thinking beyond HTTP: queues, scheduled jobs, microservices, event-driven architecture - the NestJS ecosystem covers all of it with the same mental model Fastify is the right choice when: - Throughput is a primary constraint - IoT ingestion, high-frequency trading, event processing at scale - You are writing a single-purpose HTTP service with a small surface area - Cold start time matters (serverless, edge functions) - You want schema-validated endpoints with near-zero validation overhead Express is the right choice when: - You are prototyping or scoping - You are maintaining an existing Express codebase where the migration cost outweighs the benefit - You need deep compatibility with a specific Express-only library - The service is genuinely small enough that framework structure adds noise, not clarity What's next: NestJS v12 (planned Q3 2026) NestJS v12 is expected in Q3 2026 with three changes worth tracking: - Native ESM - CommonJS workarounds in monorepos become unnecessary - Vitest as the default test runner - replacing Jest in the scaffolded template - Standard Schema / Zod in decorators - class-validator may no longer be the only first-class DTO validation option. If this lands, the validation performance story changes: Zod's runtime cost is lower and its TypeScript inference is tighter If you are designing an application today that will be in production in 12 months, keep DTO validation encapsulated. The ValidationPipe pattern is stable; the underlying validator may swap. Agency take NestJS is our default for client backend work. Not because it is the most performant, but because it is the one that holds up when the project gets handed to a new developer six months later, when the team grows from two to five, when you need to write tests for a feature built by someone else. The structure NestJS enforces is the kind you would build yourself in Express eventually - it just starts from day one instead of after the first painful refactor. We reach for Fastify when a service has an explicit throughput requirement or when serverless cold start matters. We reach for Express when scope is tight and structure would be overhead rather than investment. The throughput gap in the synthetic benchmarks is real. Once you add a real database - even a fast one - all four frameworks in these benchmarks land within a few percent of each other. The bottleneck is I/O, not the framework. We measured it: at 10ms simulated query time, Fastify, Express, NestJS on Express, and NestJS on Fastify all hit ~8,700–8,900 req/s. The 2.5× synthetic gap becomes noise. Pick the framework your team can build on confidently. That is NestJS for most teams doing serious backend work in 2026. Need help with this in your product? Our senior team builds NestJS and Node.js back ends, and you can hire our Node.js developers to join your own team. See how we helped Speeki grow to 100+ enterprise clients, or book a free 15-minute call.

Latest stargazers

The last 10 distinct accounts we observed this year.

Most active participants

PR, issue, comment & review events in the past year.

Packages

Scan pending.

README badges

Embed a badge in your README or website.

Live mentions · last 30 days
mcollina/autocannon mentions in the last 30 days