How Express, Fastify and Hono are built: req/res, plugins and Web Standards
Express wraps Node's http module directly. A request becomes Node's IncomingMessage and ServerResponse, extended with helpers, and passes through one ordered list of (req, res, next) functions. Routing is itself middleware, so every registered layer is checked in order until one responds. Express has no built-in validation, and res.json relies on JSON.stringify. That simplicity, plus a very large middleware ecosystem, is why teams still choose it.
Fastify also runs on Node's http, but it defines a fixed lifecycle. Routes are matched by a radix-tree router. Named hooks (onRequest, preValidation, preHandler, preSerialization, onSend) run at known points. JSON Schemas attached to a route are compiled when the server starts: request schemas become Ajv validators, and response schemas become specialized serializers. Plugins are encapsulated, so decorators and hooks registered inside one plugin stay in that subtree unless they are explicitly shared. The result is structure and high throughput.
Hono's core is a function that takes a Web Standard Request and returns a Response. On Node, an adapter converts between Node's objects and those standard types. The same app can also run on Bun, Deno and edge runtimes, which is the main reason to use it.
| Stage | Express | Fastify | Hono |
|---|---|---|---|
| Routing | Linear layer scan | Radix tree, pre-built | Pluggable routers |
| Validation | Third-party middleware | Compiled JSON Schema | Validator middleware |
| Handler | (req, res) |
(request, reply) |
(c) → Response |
| Serialization | JSON.stringify |
Schema-compiled serializer | c.json() builds Response |
EXPRESS FASTIFY HONO (Node adapter)
+--------------------+ +--------------------+ +--------------------+
INPUT | Node req/res | | Node req/res | | Node -> Request |
+--------------------+ +--------------------+ +--------------------+
v v v
+--------------------+ +--------------------+ +--------------------+
ROUTE | linear layer scan | | radix tree | | pluggable router |
+--------------------+ +--------------------+ +--------------------+
v v v
+--------------------+ +--------------------+ +--------------------+
VALIDATE | 3rd-party mw | | compiled Ajv | | validator mw |
+--------------------+ +--------------------+ +--------------------+
v v v
+--------------------+ +--------------------+ +--------------------+
HANDLER | (req, res) | | (request, reply) | | (c) => Response |
+--------------------+ +--------------------+ +--------------------+
v v v
+--------------------+ +--------------------+ +--------------------+
SERIALIZE | JSON.stringify | | schema serializer | | c.json() |
+--------------------+ +--------------------+ +--------------------+
v v v
+--------------------+ +--------------------+ +--------------------+
OUTPUT | ServerResponse | | onSend -> reply | | Response -> Node |
+--------------------+ +--------------------+ +--------------------+
Fastify hooks: onRequest, preValidation, preHandler, preSerialization, onSend
Request lifecycle side by side: Express scans one middleware chain over Node req/res, Fastify runs a fixed hook lifecycle with schemas compiled at startup, and Hono converts Node objects to a Web Standard Request and returns a Response through its adapter.
How it works: one request through each framework
Follow a single POST /users request with a JSON body through each framework to see where the work happens.
Express. Node's http server emits a request event with an IncomingMessage and a ServerResponse. Express adds its helpers (req.params, res.json, res.status) and starts at the top of the layer stack. express.json() reads the body stream and parses it into req.body. Express then tests each remaining layer's path and method until it reaches the POST /users handler. Any validation runs as ordinary middleware in that chain. The handler calls res.json(), which runs JSON.stringify and writes the result to the socket. If no layer responds, the request falls through to the default 404 handler.
Fastify. At startup, Fastify inserts every route into its radix tree and compiles each route's schemas. When the request arrives, the router finds the route, then the lifecycle runs in a fixed order: onRequest hooks, body parsing, preValidation hooks, the compiled Ajv validator for the body, preHandler hooks, and then your handler. The value your handler returns goes through preSerialization, the compiled response serializer for that status code, and onSend before the reply is written. If an error is thrown at any stage, it goes to the route's error handler.
Hono. On Node, the adapter receives the Node request and builds a Web Standard Request from it. Hono's router matches the method and path and returns the middleware and handler chain for that route. Each middleware receives a context object c and an async next(), so code before await next() runs on the way in and code after it runs on the way out. A validator middleware parses and checks the body, then stores the result for c.req.valid(). The handler returns a Response, for example from c.json(), and the adapter writes that response's status, headers and body back through Node's ServerResponse. On Bun, Deno or an edge runtime the adapter step is skipped because the runtime already provides Request and Response.
The practical difference: Express decides everything at request time by walking a list, Fastify moves routing and schema work to startup, and Hono keeps a runtime-neutral core and pays a conversion step only on Node.
Routing: how each framework matches requests and when it matters
Take a small route set: GET /users, GET /users/:id, POST /users, and GET /health. All three frameworks express it almost identically, but they resolve a request against it very differently.
Express keeps an ordered stack of layers. Each app.use() and app.get() call appends a layer with a compiled path regex. On every request, Express walks that stack from the top and tests each layer until one matches and handles the request. Because of this, registration order is part of your routing logic. Matching cost also grows with the number of layers that come before the matched route, and that count includes middleware.
Fastify builds a radix tree from route paths at startup. A lookup walks the tree one path segment at a time, so its cost depends on the length of the URL rather than on how many routes are registered. Because routes live in a tree instead of a sequence, two conflicting route definitions fail at registration instead of being silently shadowed.
Hono ships several interchangeable routers. One compiles every route into a single regular expression. Another uses a trie. A "smart" default picks between them, and lighter options exist for environments where startup time matters more than lookup speed, such as short-lived serverless invocations.
Router cost matters when you have hundreds of routes, deep global middleware stacks in Express, or very cheap handlers on hot paths. Otherwise it is noise. A route lookup measured in microseconds disappears next to a database round trip, a call to another service, or serializing a large JSON payload. Swapping routers rarely changes p99 latency for an I/O-bound API.
import express from 'express';
import Fastify from 'fastify';
import { Hono } from 'hono';
import { RegExpRouter } from 'hono/router/reg-exp-router';
// Express: an ordered stack. Every request is tested against each layer
// in registration order, so this middleware is checked before any route.
const ex = express();
ex.use(express.json());
ex.get('/health', (req, res) => res.send('ok'));
ex.get('/users', (req, res) => res.json([]));
ex.get('/users/:id', (req, res) => res.json({ id: req.params.id }));
ex.post('/users', (req, res) => res.status(201).json(req.body));
// Fastify: routes are inserted into a radix tree at startup.
// Registering GET /users/:id a second time throws instead of shadowing.
const fy = Fastify();
fy.get('/health', async () => 'ok');
fy.get('/users', async () => []);
fy.get<{ Params: { id: string } }>('/users/:id', async (req) => ({ id: req.params.id }));
fy.post('/users', async (req, reply) => reply.code(201).send(req.body));
// Hono: same definitions, router chosen at construction.
// Omit the option to get the default SmartRouter.
const hn = new Hono({ router: new RegExpRouter() });
hn.get('/health', (c) => c.text('ok'));
hn.get('/users', (c) => c.json([]));
hn.get('/users/:id', (c) => c.json({ id: c.req.param('id') }));
hn.post('/users', async (c) => c.json(await c.req.json(), 201));
export { ex, fy, hn };
The same four-route set defined in Express, Fastify and Hono, with comments noting where each framework's matching strategy (ordered stack, radix tree, selectable router) shows up in the code.
Validation and response serialization: one endpoint, three implementations
Where the framework does change the cost of a request is validation and serialization. Take a POST /users endpoint that accepts a name and email and returns the created record.
In Fastify, the body schema is plain JSON Schema attached to the route definition. Fastify compiles it with Ajv when the server starts, so each request runs a pre-built validator, and malformed input gets a 400 before your handler runs.
In Hono, you add a validator middleware such as zValidator('json', schema) with a Zod schema. Inside the handler, c.req.valid('json') returns data whose type is inferred from that schema, so you never write the TypeScript shape separately.
In Express, nothing is built in. You mount express.json(), then add a third-party validator or a small middleware that calls schema.parse(req.body) and forwards errors to next. req.body stays loosely typed unless you cast it yourself, and keeping those casts accurate is your responsibility.
Fastify's response schemas are the less obvious advantage. When you declare a schema per status code, Fastify compiles it into a dedicated serializer that is usually faster than generic JSON.stringify. That serializer also drops any field the schema does not declare. This stops a stray passwordHash from leaking into a response. It also explains the classic bug where a newly added column never reaches the client: the code was updated but the schema was not, and no error is raised.
JSON Schema is also verbose, and on its own it duplicates your TypeScript types. A type provider, such as one based on TypeBox, lets you define the shape once and use it for both validation and types.
import Fastify from 'fastify';
const app = Fastify({ logger: true });
const userBody = {
type: 'object',
required: ['name', 'email'],
properties: {
name: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email' }
},
additionalProperties: false
};
// Response schema: compiled into a dedicated serializer at startup.
// Only declared fields are written, so passwordHash never leaves the server.
// Note: createdAt is NOT declared, so it is silently dropped too (schema drift).
const userResponse = {
type: 'object',
properties: {
id: { type: 'integer' },
name: { type: 'string' },
email: { type: 'string' }
}
};
app.post('/users', {
schema: { body: userBody, response: { 201: userResponse } }
}, async (request, reply) => {
// Body was already validated by the compiled Ajv validator; bad input got a 400.
const { name, email } = request.body;
const row = { id: 42, name, email, passwordHash: '$2b$10$placeholderhash', createdAt: new Date().toISOString() };
reply.code(201);
return row; // client receives only { id, name, email }
});
await app.listen({ port: 3000 });
A Fastify POST /users route whose JSON Schema body validator rejects bad input with a 400 and whose 201 response schema strips the undeclared passwordHash and createdAt fields, illustrating both the leak protection and the silent missing-field bug.
Type safety and developer experience in TypeScript
A schema can do two jobs: validation at runtime and type inference at compile time. That inference is where the three frameworks differ most for a TypeScript team.
With Fastify, calling withTypeProvider() on the instance, for example with the TypeBox provider, makes request.body, request.params and request.query take their types from the route's schema. If you add a required email field to the schema, the handler sees it immediately. Removing a field turns stale property access into a compile error.
Hono goes further. A validator middleware, such as the Zod validator, types c.req.valid('json') inside the handler. Hono also records each route's input and output types on the app itself. If you export typeof app as a type, a client built with hc<AppType>() gets autocompleted paths, typed request bodies and typed responses. You don't need to generate an OpenAPI file or run a codegen step. This works only when the client is TypeScript and can import that type, which usually means a monorepo.
Express has no inference from validation. You pass generics to Request<Params, ResBody, ReqBody> by hand, and nothing checks that they match what your validator actually enforces.
| Framework | Handler types from schema | Typed client | Source of truth |
|---|---|---|---|
| Express | No, manual generics | No | Hand-written interfaces |
| Fastify | Yes, via type provider | No built-in client | JSON Schema / TypeBox |
| Hono | Yes, via validator middleware | Yes, hc |
Route definitions |
The payoff is fewer client–server contract mismatches and less duplicated type code. The cost is extra dependencies, compiler slowdowns on very large route trees, and type errors that can be hard to read when inference fails.
// server.ts: the route definitions are the only source of truth
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const createUser = z.object({
email: z.string().email(),
name: z.string().min(1),
})
// Chain the routes so their input and output types accumulate on `app`
const app = new Hono()
.post('/users', zValidator('json', createUser), (c) => {
// Inferred as { email: string; name: string }, with no hand-written interface
const body = c.req.valid('json')
return c.json({ id: 'usr_123', email: body.email }, 201)
})
.get('/users/:id', (c) => c.json({ id: c.req.param('id') }))
// Export only the type, so no server code ships to the client
export type AppType = typeof app
export default app
// client.ts: same monorepo, imports the type, not the implementation
import { hc } from 'hono/client'
import type { AppType } from './server'
const client = hc<AppType>('')
// Paths, body shape and params are autocompleted and checked.
// Omitting `name` here is a compile error, not a runtime 400.
const res = await client.users.$post({
json: { email: 'dev@example.com', name: 'Dev' },
})
if (res.ok) {
const user = await res.json() // { id: string; email: string }
console.log(user.id)
}
const one = await client.users[':id'].$get({ param: { id: 'usr_123' } })
A Zod validator types c.req.valid('json') inside a Hono handler, and the exported typeof app lets hc give the client typed paths, request bodies and responses with no codegen step.
How to benchmark Node.js frameworks honestly
Run every candidate against the endpoint you actually ship, not a route that returns a constant string. Port one representative handler to each framework, such as the POST /users endpoint from the validation section. Keep everything that does work identical: the same database client and query, the same validation schema, the same response serialization, and the same logging level writing to the same destination. Pino-style logging in one app and no logging in another is enough to change the ranking.
Start the server with NODE_ENV=production. Then run a throwaway warm-up pass so the JIT has optimized hot paths and connection pools are full. After that, run a measured pass with a load generator, for example npx autocannon -c 100 -d 30 pointed at the endpoint under test. Generate load from a different machine, or at least different cores, so the load generator doesn't compete with the server for CPU. Repeat each run several times and record p50, p97.5 and p99 latency next to requests per second. Two frameworks can show similar throughput while one has a much worse tail.
Hello-world tests exaggerate differences because framework overhead is the only work being measured. Once a handler waits on I/O, that overhead becomes a small fraction of each request.
Illustrative estimate. Assume 2,000 req/s, 20 ms of database time per request, a 0.05 ms CPU-per-request advantage for the faster framework, and $30 per vCPU-month. The savings are 2,000 × 0.00005 s = 0.1 vCPU, or about $3 per month. Against those assumptions, a migration that takes two engineer-weeks would not pay back for decades.
Ecosystem, operations and migration paths
Day-to-day operations differ more than raw throughput does.
| Concern | Express | Fastify | Hono on Node |
|---|---|---|---|
| Middleware/plugins | Largest pool | Official plugin set | Smaller, growing |
| Structured logging | Bring your own | pino built in | Bring your own |
| Async errors | Depends on major version | Caught, sent to error handler | Caught by onError |
| APM auto-instrumentation | Widely supported | Widely supported | Often HTTP-level only |
Fastify's built-in pino logger attaches a request ID to every log line without extra setup. With the other two frameworks, you add a logger and propagate request context yourself. Check how your Express major version handles rejected promises from async handlers. Older releases drop them unless you wrap each handler.
Hono runs on Node through an adapter that converts Node requests into Web Request objects. Node Readable streams must become Web streams. Raw socket access and upgrade handling go through adapter-specific escape hatches. Agents that name spans by hooking into a framework's router may record only generic HTTP spans.
Migration. Fastify's Express compatibility plugin mounts existing middleware and routers. You can then move routes one at a time and drop the plugin when none remain. Moving to Hono means rewriting anything that touches req/res directly, including middleware, streaming responses and error handlers.
Fastify's encapsulation takes time to learn. Decorators and hooks registered inside a plugin are scoped to that plugin unless you wrap it with fastify-plugin. The common bug is an auth hook that silently doesn't run on sibling routes.
A smaller ecosystem shifts work onto your team: you write and maintain the integrations a larger community would otherwise provide.
How each framework can save engineering time
Request throughput rarely decides a team's velocity. Most of the time goes to writing glue code, keeping types and validation in sync, debugging contract mismatches and wiring up operations. Each framework cuts a different part of that work.
One schema instead of three definitions. Without schema-driven tooling, a field usually lives in a validator, a TypeScript interface and an API document, and all three must be updated together. Fastify with a type provider, or Hono with a validator middleware, derives handler types from the validation schema. Adding or renaming a field becomes one edit, and the compiler points to every handler that needs to change.
No client codegen step in TypeScript monorepos. Hono's hc client reads types straight from the exported AppType. A frontend or another service in the same repository gets typed paths and bodies without generating and committing a client, and a breaking server change shows up as a compile error in CI instead of a runtime failure in staging.
Safer responses by default. Fastify's response schemas strip undeclared fields, which removes a category of data-leak review from every endpoint. The trade-off, covered above, is that you must update the schema when you add a field, so add a test that checks the response shape.
Less operational setup. Fastify's built-in logger with request IDs and its centralized error handling replace middleware that Express teams otherwise write and maintain themselves. In Hono, onError gives one place to handle thrown errors across all routes.
No rewrite when one isn't needed. Express saves time when the codebase already depends heavily on its middleware: the team keeps working with tools it knows, and nothing has to be re-verified. When a move is justified, Fastify's Express compatibility plugin lets you migrate route by route instead of in one large change.
Illustrative estimate (assumed numbers). Assume a service with 40 endpoints, and assume each request or response shape changes about once a quarter. Assume keeping a separate validator, interface and client type in sync costs 30 extra minutes per change, compared with editing a single schema. That is 40 × 4 × 0.5 hours = 80 hours per year on synchronization alone. Replace the assumed inputs with your own change rate and per-change overhead before using this in a decision.
Alternatives to Express, Fastify and Hono
These three are not the only options, and sometimes none of them is the right layer to choose.
- Node's built-in
httpmodule. For a service with a handful of routes, such as a health endpoint plus a webhook receiver, a plainhttp.createServerhandler avoids a framework dependency entirely. You give up routing, body parsing and validation, so this stops being practical once the route count grows. - Koa. A minimal framework built around async middleware and a context object, with a smaller core than Express. It suits teams that like the Express model but want
async/await-first middleware. It still runs on Node'sreq/res, so it shares Express's portability limits. - Opinionated frameworks layered on top. Some application frameworks provide dependency injection, modules and conventions, and use Express or Fastify underneath as the HTTP layer. They make sense for large teams that want enforced structure, at the cost of more abstraction between your code and the request.
- Swapping among the three. Fastify is the usual alternative to Express when you want schemas and structure on Node. Hono is the alternative when the code must also run on Bun, Deno or edge runtimes. Express remains the alternative to both when ecosystem coverage and team familiarity outweigh everything else.
Which framework to choose, when to avoid each, and the cost impact
Choose based on the service you actually run. Throughput becomes the deciding factor only after the constraints below are settled.
| Service type | Pick | Avoid it when | Alternatives |
|---|---|---|---|
| Legacy-heavy monolith | Stay on Express; move route groups to Fastify gradually | You need schema-driven serialization on hot paths | Koa |
| High-throughput internal JSON API | Fastify | You must deploy to edge or non-Node runtimes | Express with strict schemas |
| Multi-runtime or edge service | Hono | You depend on Express-style middleware that expects Node req/res |
Fastify on Node only |
Express is the right choice when rewrite risk is the main cost. Every middleware you replace is behaviour you must re-verify, and new hires already know Express. Fastify pays off when CPU time per request drives your bill, but its encapsulation model adds a learning curve that you should budget into onboarding. Hono makes sense when one codebase must run on several runtimes. Its smaller Node ecosystem means your team will write more integrations.
Illustrative estimate (assumed numbers, not a benchmark). Use this formula to estimate instance count:
instances = ceil(peak RPS ÷ sustainable RPS per instance)
Assume a peak of 6,000 RPS. Assume Express sustains 1,500 RPS per instance and Fastify sustains 2,000.
- Express: ceil(6,000 ÷ 1,500) = 4 instances
- Fastify: ceil(6,000 ÷ 2,000) = 3 instances
Fastify saves one instance. Compare that monthly saving against the engineering weeks a migration would take. If the saving does not pay back those weeks within your planning horizon, stay on Express.
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 hono vs 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.