Node.js Framework

Fastify

Fastify is a fast, schema-first Node.js web framework. JSON Schema compiles your validation and serialization, and encapsulated plugins keep large services honest.

intermediate15 min readUpdated Sep 16, 2026
server.ts
ts
// server.ts
import Fastify from "fastify";
import { Type } from "@sinclair/typebox";

const app = Fastify({ logger: true });

const User = Type.Object({
  id: Type.String({ format: "uuid" }),
  name: Type.String({ minLength: 1 }),
  email: Type.String({ format: "email" }),
});

app.get("/users/:id", {
  schema: {
    params: Type.Object({ id: Type.String({ format: "uuid" }) }),
    response: { 200: User },
  },
}, async (request, reply) => {
  const user = await db.users.findById(request.params.id);
  if (!user) return reply.callNotFound();
  return user;
});

await app.listen({ port: 3000, host: "0.0.0.0" });
Released
2016
Runs on
Node.js
Style
Schema-first, plugin-based
Core idea
JSON Schema
Language
JavaScript / TypeScript
Version
5.x

Why it matters

Why Fastify wins on throughput

Throughput by design

A radix-tree router and compiled schemas let Fastify serve far more requests per second than Express while keeping the API familiar.

Validation that compiles

A JSON Schema is not just documentation. Fastify compiles it into a validator and a serializer, so bad input is rejected before your handler runs.

Encapsulated plugins

Every plugin gets its own scope. Decorators and hooks stay local unless you explicitly share them, which keeps large services from leaking state.

The big picture

The three ideas behind Fastify

Declare the shape of your data, wrap features in encapsulated plugins, and intercept the request at well-defined lifecycle points.

JSON Schema

Declare

Describe params, body, query and responses once, and Fastify enforces and serialises them for you.

Plugins

Encapsulate

A plugin is a function that can decorate the instance and register routes inside its own scope.

Hooks

Intercept

Lifecycle hooks such as onRequest, preHandler and onSend run at precise points in every request.

The complete guide

Fastify: Everything you need to know

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:

  1. onRequest — the earliest point, ideal for authentication and request IDs.
  2. preParsing — before the body is read, for compression or size checks.
  3. preValidation — after parsing, before schema validation.
  4. preHandler — after validation, right before the handler.
  5. preSerialization and onSend — shape the payload on the way out.
  6. onResponse and onError — 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-plugin and feature code without it, so scopes stay meaningful.
  • Use onRequest for authentication and preHandler for authorisation that needs the parsed body.
  • Centralise error formatting with setErrorHandler instead of branching in every handler.
  • Log structured fields, redact secrets, and never log whole request bodies.
  • Validate configuration with @fastify/env and fail fast on startup.
  • Call app.close() in tests and on SIGTERM so connections drain cleanly.

Common mistakes

  • Registering a plugin without fastify-plugin and wondering why decorators are undefined elsewhere.
  • Forgetting await app.ready() in tests, so schemas and plugins are not loaded yet.
  • Using JSON.stringify by 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 preHandler to 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.

In practice

Routes, plugins and hooks

The four pieces of a Fastify service: a typed route, an encapsulated dependency, a lifecycle hook and an inject test.

routes/users.ts
import type { FastifyPluginAsync } from "fastify";
import { Type, type Static } from "@sinclair/typebox";

const User = Type.Object({
  id: Type.String({ format: "uuid" }),
  name: Type.String({ minLength: 1 }),
  email: Type.String({ format: "email" }),
});

const Params = Type.Object({
  id: Type.String({ format: "uuid" }),
});

export const userRoutes: FastifyPluginAsync = async (app) => {
  app.get<{ Params: Static<typeof Params> }>(
    "/users/:id",
    {
      schema: { params: Params, response: { 200: User } },
    },
    async (request, reply) => {
      const user = await app.users.findById(request.params.id);
      if (!user) return reply.callNotFound();
      return user;
    },
  );
};

Declaring the contract

A schema validates and serialises in one declaration. Manual checks scatter the same rules across every handler and drift apart over time.

Prefer
app.post("/users", {
  schema: {
    body: Type.Object({
      name: Type.String({ minLength: 1 }),
      email: Type.String({ format: "email" }),
    }),
  },
}, createUser);
Avoid
app.post("/users", async (request, reply) => {
  const { name, email } = request.body as any;
  if (typeof name !== "string") {
    return reply.code(400).send({ error: "invalid" });
  }
  // validation grows with every field
});

Sharing a dependency

Decorate inside a plugin to keep the instance's surface explicit. Attaching properties to the instance by hand is invisible to Fastify's lifecycle.

Prefer
export default fp(async (app) => {
  app.decorate("users", createUserRepository());
});
Avoid
// Mutating the instance directly skips decorators,
// typing and the onClose lifecycle.
(app as any).users = createUserRepository();

Trade-offs

Is Fastify worth the schema ceremony?

Fastify trades a little extra declaration for speed, safety and structure. The cost only shows up on the smallest projects.

Strengths

  • Fast with a reason

    The router and compiled serializers are genuinely faster, so the same hardware serves more traffic without a rewrite.

  • Validation and docs from one source

    The schema you validate with can also generate an OpenAPI document and TypeScript types, so contracts stay in sync.

  • Structure that scales

    Plugin encapsulation gives every feature its own scope. Dependencies are registered once and reused deliberately.

Trade-offs

  • Schemas take time to learn

    JSON Schema is more verbose than a Zod object, and the error messages need customising before they are friendly to API consumers.

  • The ecosystem is smaller

    There is a plugin for almost everything, but far fewer Stack Overflow answers than Express, so you read the docs more often.

  • Strictness can surprise

    Response schemas strip unknown properties by default. That is a feature, but it looks like data loss until you understand serialization.

FAQ

Frequently asked questions

Keep learning

Related topics from the roadmap.

$ start learning

Ready to start learning Fastify?

Our interactive tutorial walks you through Fastify step by step — with quizzes and real code you can run in the browser.