What is Fastify?
Fastify is a web framework for Node.js built around one bet: if you describe your data, the framework can do more of the work. It pairs a very fast router with a schema system, a scoped plugin model and a lifecycle of hooks. The result is a framework that is both quick and opinionated about safety, without forcing a folder structure on you.
It appeared in 2016 and reached a stable 1.0 in 2018. Today it powers production APIs at companies that need Express-like ergonomics with noticeably better performance and a real validation story. If you know Express, most of Fastify will feel familiar within an afternoon.
Schema-first: validation that compiles
The defining feature is that schemas are executable. When you attach a JSON Schema to a route, Fastify compiles it once at startup and uses the compiled validator for every request. No interpretation happens per call, which is why validation is nearly free.
import { Type, type Static } from "@sinclair/typebox";
const CreateUser = Type.Object({
name: Type.String({ minLength: 1, maxLength: 80 }),
email: Type.String({ format: "email" }),
});
type CreateUser = Static<typeof CreateUser>;
app.post("/users", {
schema: { body: CreateUser },
}, async (request) => {
// request.body is validated and typed as CreateUser
return createUser(request.body);
});
There are two payoffs. First, invalid requests are rejected with a 400 before your handler is called, so business logic only ever sees clean data. Second, the same schema drives serialization: Fastify compiles the response schema into a fast-json-stringify function, which is dramatically faster than JSON.stringify and guarantees you never leak fields you did not declare.
Routes and route options
A route is a method, a path and a handler, but the interesting part is the options object in between. It is where the schema, the handler and per-route metadata live.
app.route({
method: "GET",
url: "/health",
config: { public: true },
schema: {
response: {
200: Type.Object({ status: Type.String() }),
},
},
handler: async () => ({ status: "ok" }),
});
The shorthand methods (app.get, app.post and so on) accept the same options as their second argument. Route parameters arrive on request.params, the query string on request.query, and the parsed body on request.body. Because the shapes come from the schema, TypeScript knows all three.
reply is the other half of the handler. reply.code(404), reply.header(...) and reply.send(...) mirror Express, and returning a value from an async handler is shorthand for reply.send. Fastify also ships helpers such as reply.callNotFound() so error paths stay consistent.
Plugins and encapsulation
Fastify has no middleware chain in the Express sense. Instead, everything is a plugin, and every plugin gets its own scope. That single rule is what keeps large applications predictable.
import fp from "fastify-plugin";
const dbPlugin = fp(async (app) => {
app.decorate("users", createUserRepository(app.log));
}, { name: "db" });
await app.register(dbPlugin);
Without fastify-plugin, decorators and hooks added inside the plugin would be visible only to routes registered inside that same plugin. That is encapsulation: a feature can own its dependencies and configuration without polluting the rest of the app. Wrapping with fp deliberately removes the boundary when you are building shared infrastructure like a database or a logger.
Decorators are the idiomatic way to attach functionality: app.decorate("users", repo) for the instance, app.decorateRequest("user", null) for the request, and app.decorateReply for the reply. Because they are typed through module augmentation, you get autocomplete instead of any.
The lifecycle: hooks in order
Hooks let you run code at defined points without wrapping handlers. They execute in a fixed order:
onRequest— the earliest point, ideal for authentication and request IDs.preParsing— before the body is read, for compression or size checks.preValidation— after parsing, before schema validation.preHandler— after validation, right before the handler.preSerializationandonSend— shape the payload on the way out.onResponseandonError— observe the finished request.
app.addHook("onRequest", async (request) => {
request.start = process.hrtime.bigint();
});
app.addHook("onResponse", async (request, reply) => {
const ms = Number(process.hrtime.bigint() - request.start) / 1e6;
request.log.info({ ms }, "request completed");
});
Hooks are scoped like plugins, so a hook added inside a plugin only runs for routes in that plugin. onClose is the counterpart for shutdown, and it is where you release database pools and timers. Getting the order right is the main skill; the docs list it precisely and it rarely changes.
TypeScript without the ceremony
Fastify is written in TypeScript and its types are first-class. You type a route by passing generic parameters, and validation errors surface at compile time rather than in production.
app.get<{
Params: { id: string };
Querystring: { fields?: string };
}>("/users/:id", async (request) => {
const { id } = request.params;
const { fields } = request.query;
return findUser(id, fields);
});
The most valuable pattern is type providers. With @fastify/type-provider-typebox, the schema itself becomes the type, so you never write the interface twice and cannot let the two drift.
import { TypeBoxTypeProvider } from "@fastify/type-provider-typebox";
const app = Fastify().withTypeProvider<TypeBoxTypeProvider>();
app.post("/users", { schema: { body: CreateUser } }, async (request) => {
return createUser(request.body); // typed as CreateUser
});
Module augmentation is how decorators get their types: declare the property on FastifyInstance or FastifyRequest, and it is available everywhere. When something is unknown, that is usually a sign a schema or a declaration is missing.
Logging with pino, built in
Fastify ships with pino as its logger. Enable it with one option and every request gets a structured JSON log line with an id, timing and your own fields.
const app = Fastify({
logger: {
level: "info",
redact: ["req.headers.authorization"],
},
});
app.get("/orders", async (request) => {
request.log.info({ userId: request.user?.id }, "listing orders");
return listOrders(request.user?.id);
});
Because it is pino, the output is newline-delimited JSON that ships cleanly to any log aggregator, and request.log automatically includes the request context. In development, pipe through pino-pretty for readable output; in production, keep the JSON. Redaction rules are the safest way to keep tokens out of logs.
Testing with app.inject()
You do not need to open a port to test a Fastify app. app.inject() runs a request through the full stack, including routing, validation and hooks, and returns a response object you can assert against.
const app = buildApp();
await app.ready();
const res = await app.inject({
method: "POST",
url: "/users",
payload: { name: "Ada", email: "[email protected]" },
});
assert.equal(res.statusCode, 201);
assert.equal(res.json().name, "Ada");
await app.close();
Two habits make this pleasant. First, export a buildApp() factory from app.ts and call listen() only in server.ts, so tests never bind a port. Second, call await app.ready() before injecting, which forces plugins and schemas to finish loading. Tests run fast because there is no socket, and they are isolated because each test builds its own instance.
Best practices
- Define request and response schemas for every route, then derive types with a type provider.
- Keep the app factory and the listener in separate files so tests stay in-process.
- Register infrastructure with
fastify-pluginand feature code without it, so scopes stay meaningful. - Use
onRequestfor authentication andpreHandlerfor authorisation that needs the parsed body. - Centralise error formatting with
setErrorHandlerinstead of branching in every handler. - Log structured fields, redact secrets, and never log whole request bodies.
- Validate configuration with
@fastify/envand fail fast on startup. - Call
app.close()in tests and onSIGTERMso connections drain cleanly.
Common mistakes
- Registering a plugin without
fastify-pluginand wondering why decorators are undefined elsewhere. - Forgetting
await app.ready()in tests, so schemas and plugins are not loaded yet. - Using
JSON.stringifyby hand when a response schema would serialise faster and safer. - Leaving response schemas off, which means any property can leak to clients.
- Adding global hooks when a scoped plugin hook would avoid surprising unrelated routes.
- Treating hooks as middleware and expecting
preHandlerto run before body parsing. - Ignoring the default error handler’s shape and breaking API clients with inconsistent responses.
Where to go next
Fastify is the natural step up from Express when throughput and validation start to matter. If your team wants an opinionated architecture with dependency injection on top of Fastify, read the NestJS guide. For the same web-standards style on edge runtimes, see Hono. And if the plugin lifecycle still feels abstract, revisit the Node.js basics underneath it.