What is the real difference between Fastify and Express?

Express and Fastify both handle HTTP requests in Node.js, but they make different assumptions about how an application should be organized. For a new API, that structural difference matters more than raw throughput. It determines how code is split up, how contracts are enforced, and how safely a growing team can add features.

Express is a minimal middleware chain. Every app.use() call pushes a function onto one shared stack, and requests walk that stack in order. Middleware extends req and res by attaching properties wherever it needs to, so nothing records which part of the app added which property. Teams choose Express because almost everyone already knows it, nearly every Node.js library has an example for it, and it imposes no structure.

Fastify is built around encapsulated plugins. Each plugin gets its own scope for decorators, hooks and routes. It routes through a radix tree (find-my-way). It treats JSON Schema as the main way to validate input and to serialize output. Teams choose Fastify because they want each route to declare its contract and they want clear boundaries between features. The speed gains largely come from these same design choices.

Concern Express Fastify
Routing Ordered list of route layers checked in sequence Radix-tree router (find-my-way)
Serialization JSON.stringify Compiled serializers generated from response schemas
Validation Not built in; added through libraries JSON Schema per route, compiled at startup
Extension model One global middleware stack; req/res mutated freely Encapsulated plugins with scoped decorators and hooks
Async errors Depends on major version; older code often relies on wrappers Rejected promises are sent to the error handler
Logging None built in Pino logger built in, turned on through an option
Ecosystem Very large, mature, uneven quality Smaller, with an official core plugin set
Learning curve Shallow Steeper: encapsulation, hooks and schemas

How does each framework work under the hood?

Express. An Express app is itself a request handler function that you pass to Node's HTTP server. When a request arrives, Express wraps nothing: req and res are Node's own IncomingMessage and ServerResponse objects, with Express helpers such as res.json() and req.params added through extended prototypes. The router holds an ordered list of layers. Each layer is either middleware or a route with a path pattern. Express walks the list, tests each layer's pattern against the URL, and calls the matching function. That function either ends the response or calls next() to continue down the list. Calling next(err) skips ahead to the first error-handling middleware. Nothing is compiled ahead of time; matching, body parsing and serialization all happen per request, using whatever middleware you installed.

Fastify. Fastify does most of its work at startup. When you call listen() or ready(), it boots the plugin tree in registration order, creating a child context for each encapsulated plugin. As routes register, it inserts them into the find-my-way radix tree, compiles each route's input schemas into Ajv validation functions, and compiles each response schema into a dedicated serializer. At request time, Fastify:

  1. Looks up the route in the radix tree.
  2. Creates its own Request and Reply objects that wrap the raw Node objects (still reachable as request.raw and reply.raw).
  3. Runs the route's hook chain: onRequest, preParsing, the content-type parser, preValidation, the precompiled validator, and preHandler.
  4. Calls the handler. A returned value or resolved promise becomes the payload; a thrown error or rejected promise goes to the error handler for that context.
  5. Runs preSerialization, serializes the payload with the precompiled serializer (or a generic fallback if no response schema exists), runs onSend, writes the response, and then runs onResponse.

Because hooks and decorators are attached to contexts rather than a global stack, each route only runs the hooks registered in its own context and its ancestors.

Why is Fastify faster than Express, and when does that actually matter?

The gap comes from two places. First, serialization: when a route declares a response JSON Schema, Fastify compiles a dedicated serializer for that shape at startup. The serializer already knows the field names and types, so it does less work than the generic JSON.stringify that Express calls on every response. It also drops properties that are not in the schema. Second, routing: Fastify matches URLs against a radix tree, so lookup cost depends on the length of the path rather than the number of routes. Express walks its middleware and route stack in order and tests each layer until one matches, then serializes the response generically.

Illustrative estimate. These are assumed inputs, not measurements. Suppose a handler waits 20 ms on a database query. Assume Express adds 0.8 ms of framework overhead and Fastify adds 0.3 ms. Total latency is then 20.8 ms versus 20.3 ms, a difference of about 2.4%. The database accounts for almost all of the request time.

The overhead matters when the framework's own work makes up most of each request:

  • Thin proxies that do little besides forward traffic.
  • High-fanout gateways that make many internal calls per request.
  • CPU-bound JSON shaping on small responses served at high request rates.

Most of Fastify's advantage depends on declaring response schemas. Without them, it falls back to generic serialization and gives up much of the gain. In typical APIs, latency is usually dominated by database queries, N+1 call patterns, payload size and event-loop blocking. Profile your service before deciding that the framework is the bottleneck.

How should you benchmark Fastify vs Express for your own API?

Published numbers come from minimal "hello world" handlers, so they tell you little about your service. Build two small apps that mirror one real endpoint: the same handler logic, a realistic request and response body size, and the logger configured to the level you run in production. Logging alone can change results noticeably, because serialization and I/O compete for the same event loop.

Compare like with like

Express does no validation by default, while Fastify validates input when you attach a schema. To make the comparison fair, add an equivalent JSON Schema validator to the Express route. Then run Fastify twice, once with a response schema and once without. The response schema enables a faster serializer, so this pair shows how much of the gap comes from serialization and how much from routing.

Run it properly

  • Warm up first. Send traffic for a short period and discard those results, so V8 has optimized the hot paths before you measure.
  • Isolate the load generator. Run it on a separate machine, or pin it to different CPU cores. Otherwise the client and server compete for the same CPU.
  • Record p99 latency alongside requests per second. A setup that wins on throughput can still have worse tail latency.

A basic run looks like autocannon -c 100 -d 30 <target-url>. Here -c sets the number of concurrent connections and -d sets the duration in seconds. Repeat each run several times.

Naive benchmarks can mislead in either direction. Empty handlers exaggerate Fastify's lead. A stubbed database that returns instantly does the same. A slow real dependency can hide the difference entirely.

#!/usr/bin/env bash
set -euo pipefail

APP="$1"
URL="${2:?target URL required}"
BODY="$(cat ./fixtures/order-request.json)"

NODE_ENV=production taskset -c 0,1 node "$APP" &
SERVER_PID=$!
trap 'kill $SERVER_PID' EXIT
sleep 2

run() {
  taskset -c 2,3 npx autocannon -c 100 -d "$1" -m POST \
    -H 'content-type: application/json' -b "$BODY" "$URL"
}

run 15 > /dev/null 2>&1

mkdir -p results
for i in 1 2 3 4 5; do
  run 30 > "results/$(basename "$APP" .js)-run$i.txt" 2>&1
done

echo "Done. Compare p99 latency and Req/Sec across results/*.txt"

A repeatable benchmark script that pins server and autocannon to separate CPU cores, discards a warm-up run, and records several measured runs per app variant so throughput and p99 latency can be compared like for like.

How does the Fastify plugin model work compared to Express middleware?

Express has one application object and one middleware chain. Every app.use call appends to that chain, so anything a middleware attaches to req is effectively global. Fastify instead builds a tree of contexts. Each fastify.register call creates a child context that inherits from its parent. Decorators added with decorate, decorateRequest and decorateReply, along with hooks, stay inside that context and its descendants. Siblings and parents never see them.

root
 ├── db plugin (fastify-plugin) ──► fastify.db added to root
 ├── users routes   decorateRequest('tenant')   sees: db, tenant
 └── billing routes decorate('stripeClient')    sees: db, stripeClient

Wrapping a plugin with fastify-plugin deliberately skips the new context, so its decorators land in the parent. Use it for shared resources such as a database pool. Leave route plugins encapsulated.

Plugins load asynchronously but strictly in registration order. A plugin's dependencies are ready before the next sibling starts. after runs once the preceding registrations finish. ready resolves when the entire tree has booted. A plugin that never resolves or calls done fails startup once the configurable plugin timeout elapses, so a stuck connection surfaces at boot rather than on the first request.

There are two common failure modes. The first is a missing fastify-plugin wrapper. This shows up as fastify.db being undefined in a sibling plugin, and the fix is to wrap the provider rather than re-register it. The second is wrapping everything "to be safe," which turns hooks and decorators back into Express-style global state.

import Fastify from 'fastify'
import fp from 'fastify-plugin'

const app = Fastify({ pluginTimeout: 10000 }) // a plugin stuck at boot fails startup

// BROKEN: a plain plugin gets its own child context, so 'db' stays private to it
async function dbPluginBroken (fastify) {
  fastify.decorate('db', { query: async (sql) => [] })
}

// FIX: fp() skips the child context, so 'db' is added to the parent (root)
const dbPlugin = fp(async function dbPlugin (fastify) {
  fastify.decorate('db', { query: async (sql) => [{ id: 1, sql }] })
  fastify.addHook('onClose', async () => { /* close the pool here */ })
}, { name: 'db' })

app.register(dbPlugin) // swap in dbPluginBroken and both checks below print false

app.register(async function usersRoutes (fastify) {
  fastify.decorateRequest('tenant', null) // private to this context
  fastify.addHook('onRequest', async (req) => {
    req.tenant = req.headers['x-tenant-id'] ?? 'default'
  })
  fastify.get('/', async (req) => fastify.db.query(`SELECT * FROM users -- ${req.tenant}`))
}, { prefix: '/users' })

app.register(async function billingRoutes (fastify) {
  fastify.decorate('stripeClient', { charge: async () => 'ok' }) // private too
  fastify.log.info({
    seesDb: fastify.hasDecorator('db'),                      // true with fp
    seesTenant: fastify.hasRequestDecorator('tenant')        // false: sibling
  })
}, { prefix: '/billing' })

app.after(() => { /* runs once the registrations above have loaded */ })

await app.ready() // resolves once the whole plugin tree has booted
console.log('root sees db:', app.hasDecorator('db'))           // true with fp
console.log('root sees stripeClient:', app.hasDecorator('stripeClient')) // false
await app.close()

Wrapping the database plugin with fastify-plugin exposes db to the root and all sibling plugins, while the tenant request decorator and stripeClient stay scoped to the route plugins that declare them.

How do you implement schema-first routes, hooks and error handling in Fastify?

A route declares body, querystring, params, headers and response schemas keyed by status code. Fastify compiles the input schemas with Ajv, so malformed requests are rejected before your handler runs. It compiles the response schemas into serializers, which are faster than generic JSON.stringify. Those serializers emit only declared properties, so an internal field such as passwordHash cannot leak by accident. The same behaviour has a downside. If someone adds a column to the model but not to the response schema, the field silently disappears from responses, so add contract tests that assert on response shape. Ecosystem plugins can generate OpenAPI from these same schemas, and TypeScript type providers derive handler types from them, so one definition drives validation, docs and types.

The request lifecycle runs through these hooks in order: onRequest, preParsing, body parsing, preValidation, validation, preHandler, the handler, preSerialization, onSend and onResponse. onError fires when an error occurs.

Express middleware Fastify equivalent
Request ID built-in genReqId / onRequest
Auth onRequest or preHandler
Body parsing built-in content-type parsers
Validation route schema (Ajv)
Response shaping response schema, preSerialization
Logging built-in Pino, onResponse
Error middleware setErrorHandler (onError only observes)

In Fastify, async handlers can simply throw or return, and setErrorHandler maps the error to a response. Express traditionally relies on next(err) or promise wrappers, so check how your Express version handles rejected promises.

Pino logging is built in, and every log line carries the request ID. Platform teams get consistent JSON logs across services without having to agree on a shared logging middleware.

const Fastify = require('fastify');
const { randomUUID } = require('node:crypto');

const app = Fastify({
  logger: true, // Pino JSON logs; every request-scoped line carries reqId
  genReqId: (req) => req.headers['x-request-id'] ?? randomUUID(), // reuse upstream ID if present
});

// Echo the request ID so clients and proxies can correlate logs
app.addHook('onRequest', async (request, reply) => { reply.header('x-request-id', request.id); });

app.patch('/users/:id', {
  schema: {
    params: { type: 'object', required: ['id'], properties: { id: { type: 'string', minLength: 1 } } },
    querystring: { type: 'object', properties: { notify: { type: 'boolean' } } }, // Ajv coerces 'true'
    headers: { type: 'object', required: ['authorization'], properties: { authorization: { type: 'string' } } },
    body: { type: 'object', required: ['email'], additionalProperties: false,
      properties: { email: { type: 'string', maxLength: 254 } } },
    // Compiled serializer emits only these fields; anything undeclared is dropped
    response: { 200: { type: 'object', properties: { id: { type: 'string' }, email: { type: 'string' } } } },
  },
}, async (request) => {
  // Async handler: return a value or throw; no next(err) needed
  const user = { id: request.params.id, email: request.body.email, passwordHash: '$2b$10$placeholder' };
  return user; // passwordHash never reaches the client
});

// Central mapping from thrown errors to HTTP responses
app.setErrorHandler((error, request, reply) => {
  if (error.validation) {
    return reply.code(400).send({ error: 'Bad Request', message: error.message });
  }
  const status = error.statusCode ?? 500;
  if (status >= 500) request.log.error({ err: error }, 'unhandled error');
  reply.code(status).send({ error: status >= 500 ? 'Internal Server Error' : error.message });
});

app.listen({ port: 3000, host: '0.0.0.0' });

A Fastify route that validates params, querystring, headers and body with Ajv, strips the undeclared passwordHash field through its response schema, propagates request IDs into Pino logs, and maps thrown errors to responses in setErrorHandler.

What does each framework cost in development time and team effort?

Fastify asks for more upfront. Every route needs a schema, and someone has to keep it in step with the handler as fields change. A schema that drifts from the real response either drops data during serialization or fails validation in production. New team members also have to learn the request lifecycle, the register and ready sequence, encapsulation scopes, and type providers before they can work confidently. Debugging adds problems Express developers rarely see. A decorator may be missing because a plugin loaded in the wrong scope, or an onRequest hook may short-circuit a route it should never have touched.

That investment replaces work you would do anyway. One schema provides input validation, OpenAPI output and TypeScript types, so you no longer write manual if (!body.email) checks or maintain separate DTOs. Encapsulation makes it difficult for one module to reach into another's state, which slows the coupling that usually builds up over a few years. Built-in structured logging means the platform team doesn't have to standardize logging across services.

Express costs less to start. Most Node.js engineers already know it, hiring is easier, and almost any problem has an existing middleware package or a forum answer. A request runs through a linear middleware stack that is easy to follow in a debugger. The cost shows up later. Validation, error shape, logging format and module boundaries are conventions your team has to define, document and enforce in code review.

Both frameworks have shipped breaking major releases, and ecosystem packages lag behind them. Before adopting any plugin or middleware, check that it supports the major version you run.

Who should use Fastify, and who should use Express?

Fastify fits:

  • Teams building new JSON APIs that want every route to declare its inputs and outputs, and want validation and response filtering enforced by the framework rather than by review.
  • TypeScript teams that want handler types derived from the same schemas that validate requests, instead of maintaining types and validators separately.
  • Organizations with several teams in one codebase, where encapsulated plugins give each feature area its own decorators and hooks without leaking into others.
  • Platform teams that want structured logs with request IDs by default across many services.
  • Services where framework overhead is a meaningful share of request time, such as thin proxies, gateways and high-rate endpoints returning small JSON payloads.

Express fits:

  • Teams that already know Express well and have working conventions for validation, errors and logging.
  • Projects that depend on Express-specific middleware or on libraries that only document Express integration.
  • Prototypes, internal tools and small services where getting something running quickly matters more than enforced structure.
  • Server-rendered apps that use Express's built-in view rendering with a template engine. Fastify can render templates too, but needs a plugin for it.
  • Engineers who prefer minimal abstraction, where the whole request path is a list of functions they can read top to bottom.

When should you avoid Fastify or Express?

Avoid Fastify when:

  • The team has no time to learn its model. Encapsulation, hook ordering and the boot sequence produce confusing bugs, such as missing decorators, when misunderstood. A deadline-driven project staffed by people new to it may move slower than with Express.
  • A critical dependency only exists as Express middleware. The compatibility layer works, but it reintroduces per-request overhead and runs outside Fastify's hooks and encapsulation, so you lose much of the reason to switch.
  • Response shapes are highly dynamic or unknown ahead of time, such as a passthrough that forwards arbitrary upstream JSON. Response schemas would strip unknown fields, so you would skip them and lose the serialization benefit and the leak protection.
  • Nobody will maintain the schemas. Schemas that drift from reality either drop fields silently or reject valid requests, which is worse than having no schema.

Avoid Express when:

  • You need contracts enforced, not just documented. Without discipline and added libraries, validation and response filtering are easy to skip, and internal fields can leak into responses.
  • Many teams share one large codebase. A single global middleware stack and freely mutated req/res make it hard to see which module depends on which property, and coupling grows over time.
  • Framework overhead dominates request cost, as in thin proxies or high-rate gateways, and profiling confirms the framework is where the CPU time goes.
  • You would otherwise rebuild Fastify's defaults by hand. If you are about to assemble schema validation, structured logging, request IDs and async error handling from separate packages, a framework that ships them may cost less to maintain.

When should you choose Fastify, stick with Express, or migrate between them?

Pick Fastify when you want the framework to enforce structure. Encapsulated plugins keep modules from leaking decorators and hooks into each other. Route schemas give you validation, serialization and a contract you can generate documentation from. On high-rate JSON endpoints, lower per-request overhead leaves CPU headroom before you need to scale out.

Stay with Express when the team already knows it well, when you depend on a specific middleware with no Fastify equivalent, or when you want as little abstraction as possible. For small internal tools, framework overhead is noise next to database and network time, so a switch rarely pays off.

Scenario Recommendation
New public API, high request rate, JSON payloads Fastify
Large codebase split across several teams Fastify, for plugin encapsulation
Critical dependency on an Express-only middleware Express, or isolate that path
Low-traffic internal tool or admin service Whichever the team knows
Existing Express API with no performance or structure problems Keep Express
Existing Express API under load or growing hard to maintain Migrate route by route behind a proxy

A compatibility plugin can run Express middleware inside Fastify while you migrate. Treat it as temporary. It wraps each request in Express-style objects, which brings back the overhead you were trying to remove. It also blurs lifecycle semantics: that middleware runs outside Fastify's hook ordering and encapsulation, so error handling and reply state become harder to reason about.

The cleaner path is to put a reverse proxy in front of both apps. Move routes to the Fastify service one at a time, verify each against its schema, and retire the Express app once the last route has moved.

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 fastify 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 route's URL or method automatically notifies the services that consume it — no hunting through repos for callers.

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.
  • Consumers learn about a breaking change at review time rather than from production errors.
  • 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.