What NestJS, Express and Fastify are, and which layer each occupies

Express is a minimal routing and middleware layer over Node's HTTP server. A request passes through a chain of functions, each taking req, res and next. Express adds nothing beyond that chain: it doesn't decide where your business logic lives, how you validate input or how modules depend on each other. Each team decides those things for itself.

Fastify is at the same level. It is also an HTTP framework that sits directly on Node, but it is organized differently. You extend it with encapsulated plugins and attach behavior to defined lifecycle hooks. It uses JSON Schema to validate requests and serialize responses. Because of that, some of the structure that Express leaves to you is built into Fastify. Fastify still doesn't tell you how to arrange your application code.

NestJS is one level higher. It is an application framework that provides modules, controllers, providers and a dependency injection container. It doesn't handle HTTP itself. It runs on a platform adapter, which is Express by default and Fastify if you choose it. A NestJS service is therefore usually also an Express service, with an architecture added on top.

That is why "NestJS vs Express" is partly a mismatched comparison: it sets an application framework against the HTTP layer it often runs on. The useful question isn't which one handles more requests per second. It is who will maintain the code, how many people will change it, and whether they would rather get structure from the framework or agree on it among themselves.

+-----------------------------------------------------------------+
|  NestJS: application framework                                  |
|  modules, controllers, providers, DI container                  |
+-----------------------------------------------------------------+
                    |  runs on an HTTP platform adapter
          +---------+----------------------+
          | default                        | optional
          v                                v
+---------------------------+  +---------------------------+
|  Express                  |  |  Fastify                  |
|  HTTP framework           |  |  HTTP framework           |
|  req -> mw -> mw -> route |  |  plugins + lifecycle hooks|
|  each mw gets req,res,next|  |  JSON Schema validation + |
|  validation: your choice  |  |  response serialization   |
|  app layout: left to team |  |  app layout: left to team |
+---------------------------+  +---------------------------+
          |                                |
          +---------------+----------------+
                          v
+-----------------------------------------------------------------+
|  Node.js HTTP server (node:http)                                |
+-----------------------------------------------------------------+

Express and Fastify are HTTP frameworks that run directly on Node's HTTP server, and NestJS is an application framework (modules, controllers, providers, DI) that runs on one of them through a platform adapter: Express by default, Fastify if you choose it.

How each framework works under the hood

Express

Express runs a linear chain. Every app.use(fn) and route handler receives (req, res, next) and either responds or calls next(). Order of registration is the only structure. An error passed to next(err) skips ahead to middleware with the four-argument signature (err, req, res, next). Express 5 also forwards rejected promises from async handlers to that error middleware. Express 4 does not, so a thrown await leaves the request hanging unless each handler is wrapped. Check this before upgrading older codebases or copying patterns between them.

Fastify

Fastify organizes code as plugins registered with fastify.register(). Each plugin gets its own encapsulated scope. A decorate() call or an addHook('onRequest', …) inside one plugin is visible to that plugin and its children, not to siblings. One feature's auth hook therefore cannot silently apply to another feature's routes. Requests pass through a defined lifecycle (onRequest, preValidation, preHandler, onSend and others), so the position of a concern is explicit rather than implied by file order. Routes declare JSON Schemas. Fastify compiles them to validate input and to serialize responses, which also strips fields you didn't declare. A structured logger is attached to every request as request.log.

NestJS

Nest adds a dependency-injection container on top of an HTTP adapter, which is Express by default with a Fastify adapter available. Modules group providers (injectable classes) and controllers (decorated route handlers). Constructor parameters are resolved by type. The request-path pieces correspond to familiar middleware roles:

  • Guards are allow/deny middleware with access to route metadata.
  • Pipes validate and transform handler arguments.
  • Interceptors wrap the handler to run logic before and after it.
  • Exception filters are error middleware.

Nest's main addition is that each of these is attached declaratively per route, controller or app.

import Fastify from 'fastify';

const app = Fastify({ logger: true }); // structured logger, exposed per request as request.log

// Admin feature: its decorator and hook live only in this plugin's scope
app.register(async (admin) => {
  admin.decorate('auditLabel', 'admin');
  admin.addHook('onRequest', async (request, reply) => {
    if (request.headers['x-admin-token'] !== 'example-token') {
      reply.code(401).send({ error: 'unauthorized' });
      return reply;
    }
  });
  admin.get('/admin/stats', async () => ({ ok: true }));
});

// Public feature: a sibling plugin, so the admin hook never runs here
app.register(async (pub) => {
  pub.get('/users/:id', {
    schema: {
      params: { type: 'object', properties: { id: { type: 'integer' } }, required: ['id'] },
      response: {
        200: { type: 'object', properties: { id: { type: 'integer' }, name: { type: 'string' } } },
      },
    },
  }, async (request) => {
    request.log.info({ id: request.params.id }, 'fetching user');
    // passwordHash is not in the response schema, so the serializer drops it
    return { id: request.params.id, name: 'Ada', passwordHash: 'not-sent' };
  });
});

await app.listen({ port: 3000 });

Two sibling Fastify plugins: the admin plugin's hook and decorator stay inside its own scope, and the public route's JSON Schema validates params, removes undeclared response fields and logs through request.log.

Structure vs flexibility: implementing the same cross-cutting concerns three ways

That declarative attachment answers a question every team has to settle: who enforces the pattern? In Nest, the framework does. In Fastify, the plugin tree does. In Express, only code review and team convention do.

Express validates nothing by default. Request validation, schema documentation and error formats are all opt-in. Each one depends on a library choice and on someone remembering to mount the middleware on every router. That's why one service can end up with three error shapes. Fastify sits between the two. A route's schema option is checked by Ajv before the handler runs, and the same schema drives response serialization. Validation and output shape therefore come from a single declaration. Nest goes furthest. It binds ValidationPipe to class-validator DTOs, and with a global pipe a new endpoint is validated unless someone explicitly opts it out.

Concern NestJS Fastify Express
Validation DTO + ValidationPipe Route schema (JSON Schema, Ajv) Library of your choice, per route
Auth Guard (CanActivate) onRequest/preHandler hook in an encapsulated plugin Middleware placed before routes
Error shaping Exception filter setErrorHandler, scoped per plugin Four-argument error middleware
Logging Logger, interceptors Built-in Pino via request.log Separate middleware
Config ConfigModule injection Plugin decorating the instance process.env reads wherever needed
Enforced by DI container and decorators Encapsulation and hook order Convention
New hire learns first Module graph, request lifecycle Plugin scoping, hook phases Your team's conventions

The last row is where cost appears. In Nest and Fastify, a new hire learns concepts that carry over from one project to the next. In Express, they have to learn your codebase's own conventions, and those are rarely written down.

// --- Express: nothing validates req.body unless you add it to every route ---
import express from 'express';
const ex = express();
ex.use(express.json()); // parses JSON only; performs no validation
ex.post('/users', (req, res) => {
  // Hand-rolled check: the error shape is whatever this author chose
  if (typeof req.body?.email !== 'string') {
    return res.status(400).json({ msg: 'email required' });
  }
  res.status(201).json({ email: req.body.email });
});

// --- Fastify: Ajv checks the declared schema before the handler runs ---
import Fastify from 'fastify';
const fa = Fastify({ logger: true });
fa.post('/users', {
  schema: {
    body: {
      type: 'object',
      required: ['email'],
      properties: { email: { type: 'string', minLength: 3 } },
    },
    // The same declaration also drives response serialization
    response: { 201: { type: 'object', properties: { email: { type: 'string' } } } },
  },
}, async (req, reply) => reply.code(201).send(req.body));

// --- NestJS: a DTO plus one global pipe validates every endpoint ---
import { IsEmail } from 'class-validator';
export class CreateUserDto {
  @IsEmail() email!: string;
}
// In main.ts, once per app (assumes `app` from NestFactory.create):
//   app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
// Any controller method typed `@Body() dto: CreateUserDto` is now checked.

The same POST /users validation in Express, Fastify and NestJS: Express requires a hand-written check on each route, Fastify enforces a declared JSON Schema before the handler, and Nest applies DTO validation to every endpoint through one global pipe.

Testing and dependency boundaries

Testing is where those conventions either pay off or cost you.

Nest: overrides through the container

Every Nest provider is resolved by the DI container, so swapping a dependency in a test does not require changing production code. Test.createTestingModule() builds a module graph, and overrideProvider(PaymentsClient).useValue(fake) replaces one node in it before compilation. The rest of the graph wires itself. This is routine because the seams already exist. Every class that declares a constructor dependency is a place where a test can substitute one. The cost is boot time and indirection. A test that compiles a large module pulls in everything that module imports, so teams usually create narrow test modules rather than importing AppModule.

Express: seams you build yourself

Express has no container. If a route handler calls require('./db') directly, the only ways to isolate it are module-level mocking or restructuring. The usual fix is a factory, such as createApp({ db, mailer }), that returns a configured app and receives its dependencies as arguments. This works well, but each team has to invent the factory, and someone has to enforce it. Handlers written outside the pattern become hard to test in isolation.

Fastify: inject plus encapsulation

Fastify's app.inject() sends a simulated request through the full routing, hook and serialization pipeline without opening a socket, so HTTP-level tests stay fast. Plugins are encapsulated, which means a test can register a single plugin with a fake decorator for its dependency. You get Nest-like isolation at the plugin boundary without adopting a container. Changes stay local because a plugin can see only what it registers or what its parent registers.

// routes/charges.js: the plugin depends on app.payments, not on a concrete client
async function chargesRoutes(app) {
  app.post('/charges', async (request, reply) => {
    const result = await app.payments.charge(request.body.amount);
    reply.code(201);
    return { id: result.id };
  });
}
module.exports = chargesRoutes;

// test/charges.test.js
const { test } = require('node:test');
const assert = require('node:assert');
const Fastify = require('fastify');
const chargesRoutes = require('../routes/charges');

test('POST /charges calls the payments dependency', async (t) => {
  const app = Fastify();

  // Put a fake in the parent scope. The encapsulated plugin can see it, and no other code is involved.
  app.decorate('payments', {
    charge: async (amount) => ({ id: 'ch_test_123', amount }),
  });
  app.register(chargesRoutes);
  t.after(() => app.close());

  // inject() runs the request through routing, hooks and serialization without opening a socket
  const res = await app.inject({
    method: 'POST',
    url: '/charges',
    payload: { amount: 500 },
  });

  assert.strictEqual(res.statusCode, 201);
  assert.deepStrictEqual(res.json(), { id: 'ch_test_123' });
});

This Fastify test registers one plugin with a fake payments decorator and calls the plugin's route through app.inject(), so you can test it without the rest of the app or a network socket.

Impact on performance, development cost and engineering time

Framework choice shows up in latency mostly on high-throughput, low-latency endpoints: cache lookups, auth checks, proxies. Most services wait on databases and downstream calls, so per-request framework overhead is a small share of total time.

Illustrative estimate (assumed numbers, not a benchmark). Assume framework overhead of 0.1 ms, a database call of 20 ms and 4.9 ms of application logic.

overhead share = framework_ms ÷ (framework_ms + io_ms + app_ms) = 0.1 ÷ (0.1 + 20 + 4.9) = 0.4%

Use the same overhead on a 1 ms cache-hit endpoint and the share rises to 10%. That's why you should profile your own slowest and busiest routes before treating raw request throughput as a deciding factor.

Engineering cost matters more for most teams, and each framework charges it at a different time:

  • NestJS costs the most at the start. Modules, providers, decorators and DI scoping take time to learn, and even small features need some boilerplate. On large teams that cost pays back: new engineers can find things because every service has the same layout, and reviewers check against known conventions instead of arguing about structure.
  • Express is cheap to start, and the cost grows over time. Someone has to write and enforce the conventions for error handling, validation and module layout. If no one does, each service drifts in its own direction, and later changes slow down.
  • Fastify asks for schema work upfront: request and response schemas for each route. Validation and serialization come from those same schemas.

Picking for benchmark numbers alone can leave a team with an unstructured codebase. Picking for structure alone can saddle a three-person team with ceremony it never needed.

Which team should use which framework, and when to avoid each

Team-fit matrix

Team profile Best fit Why
1–4 engineers, prototype or short-lived service Express Small API surface and no imposed layout, so code goes straight to business logic
Team with strong conventions, throughput-sensitive APIs Fastify Schema-driven validation and serialization reward discipline that already exists
Large or rotating team, many services NestJS Modules, providers and DI make every repo look the same to the next person
Developers from Angular, Spring or .NET NestJS Decorators and constructor injection match habits they already have
Need enforced structure and Fastify's request pipeline NestJS on the Fastify adapter Nest's architecture sits on top, and Fastify handles HTTP underneath

When to avoid each

NestJS: Skip it for tiny services where the module, controller and provider files outnumber the actual logic. Decorator-driven indirection also makes stack traces harder to read. When something fails inside a guard or interceptor, you have to know the framework's lifecycle to find the cause.

Express: Avoid it if the team has no written conventions and no review discipline. Express doesn't stop anyone from putting validation in route handlers, database calls in middleware or error handling in five different styles. Without agreed rules, every service drifts in its own direction.

Fastify: Avoid it if the team won't learn plugin encapsulation and registration order. Decorators and hooks that work in one scope and silently don't exist in another lead to confusing bugs.

One framework or several

Standardizing on one framework makes onboarding, shared libraries, observability wiring and moving engineers between services cheaper. The cost is fit: a one-endpoint webhook receiver carries the same weight as a large domain service. Allowing several frameworks lets each service fit its job, but every extra framework doubles the work of maintaining shared middleware, security patches and internal tooling. A workable compromise is one default framework plus a documented, approved exception for cases where the default clearly doesn't fit.

Migration, coexistence and maintenance hygiene

The safest migration path is incremental. A Nest application runs on Express by default, so existing Express middleware can usually be registered inside Nest without being rewritten. That lets a team move route handlers into controllers and providers one module at a time, rather than doing a big-bang rewrite.

Moving to Fastify is harder. Fastify's compatibility plugins, @fastify/express and @fastify/middie, let Express-style (req, res, next) middleware run. That middleware sits outside Fastify's own hook lifecycle and encapsulation model, though, and it may receive raw Node request and response objects instead of Express's extended ones. Treat these plugins as a bridge for the migration period. Port anything on a hot path to native hooks or plugins.

Nest's adapter switch (from platform-express to platform-fastify) is only clean if application code never touches the underlying framework. Code that injects @Req() or @Res() and calls res.json(), reads req.session, or relies on Express-only middleware is tied to one adapter. Injecting @Res() also hands response handling back to you, so interceptors and serialization may no longer apply the way you expect. Each piece of code like this weakens the claim that the adapter can be swapped. Keep it in a few clearly named places, and run the test suite against both adapters if a switch is realistic.

For version hygiene, keep in mind that Express and Fastify both have 5.x major lines. Fastify publishes a Long Term Support policy, so plan upgrades around its support windows rather than waiting until a version reaches end of life. Finally, subscribe to security releases for the whole dependency tree, not only the framework core. Express's own security advisories have covered middleware such as multer, morgan and compression. In a typical service, middleware and plugins make up much more of the attack surface than the framework itself.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to nestjs vs express, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.