cheat sheet

fastify

Package-level reference for the Fastify framework on npm — install, plugin model, Node support, and validation/encapsulation gotchas.

fastify

What it is

fastify is a Node.js web framework focused on raw throughput, schema-driven validation, and an encapsulation-aware plugin system. Routes declare their request/response shapes via JSON Schema, and Fastify uses that schema to validate inputs and serialize responses with a pre-compiled fast-json-stringify function — the source of most of its performance edge over Express.

Reach for fastify when you need a typed, validated, observable Node HTTP server with a sane plugin model. Reach for hono if you target edge runtimes (Cloudflare Workers, Deno Deploy, Bun), express for the simplest possible middleware stack, or koa / h3 for thinner cores.

Install

Fastify is a library, used inside your own application bin or via a process manager.

bash
npm install fastify

Output: added fastify to dependencies

bash
pnpm add fastify

Output: added 1 package, linked from store

bash
yarn add fastify

Output: added fastify

bash
bun add fastify

Output: installed fastify

bash
deno add npm:fastify

Output: added npm:fastify to import map (Node-compat required)

Versioning & Node support

Current line is fastify@5.x (released late 2024).

  • fastify@5 — Node 20+ LTS only. Dual ESM/CJS.
  • fastify@4 — Node 14.21+ / 16.x / 18.x. Still receives security patches.
  • Strict semver — major bumps deprecate APIs at the previous major and remove them at the next. The 4 → 5 migration removed several reply.send() edge cases and tightened types.
  • TypeScript types ship in-tree and are first-class — Fastify's TS contributor team is unusually responsive.

Package metadata

  • Maintainer: Matteo Collina + Fastify contributors (fastify org on GitHub)
  • Project home: github.com/fastify/fastify
  • Docs: fastify.dev
  • npm: npmjs.com/package/fastify
  • License: MIT
  • First released: 2017
  • Downloads: ~3 million+ weekly downloads — the most popular high-performance Node framework after Express.

Peer dependencies & extras

Fastify core has zero peer-deps but expects you to install plugins from the @fastify/* org as needed. There is no extras flag — each plugin is its own npm package.

Commonly-paired plugins:

  • @fastify/cors — CORS handling
  • @fastify/helmet — secure headers
  • @fastify/jwt — JWT auth + verification
  • @fastify/cookie, @fastify/session — cookie / session middleware
  • @fastify/rate-limit — request throttling
  • @fastify/static — serve static files
  • @fastify/multipart — multipart/form-data
  • @fastify/websocket — WebSocket via ws
  • @fastify/swagger + @fastify/swagger-ui — OpenAPI generation from route schemas
  • @fastify/under-pressure — load shedding under memory pressure
  • @fastify/oauth2, @fastify/passport — auth integrations

Companion ecosystem:

  • pino — Fastify's default logger. Already pulled in transitively; configure via app.log.
  • ajv — JSON Schema validator. Also transitive; customise via ajvOptions.
  • fast-json-stringify — response serializer. Transitive.
  • @platformatic/db — higher-level platform built on Fastify

Alternatives

FrameworkTrade-off
expressOldest and most-known. Slower, no built-in validation, weaker types. Massive middleware ecosystem.
honoEdge-first; runs on Workers / Deno / Bun / Node. Smaller core, similar speed. Best when targeting non-Node runtimes.
koaExpress creators' modern rewrite. Smaller core, async/await native. Less ecosystem momentum.
h3Used by Nuxt / Nitro. Edge-friendly. Pairs with Nitro for full deploy adapter coverage.
nestjsClass+decorator framework that can run on top of Fastify. Heavier, opinionated DI.
restifyOlder REST-focused framework. Largely superseded by Fastify.

Common gotchas

  1. Schema-first validation requires real JSON Schema. Passing a TypeScript interface or Zod schema directly does NOT work. Use @sinclair/typebox (recommended), zod-to-json-schema, or write JSON Schema manually. Without a response schema the serializer falls back to slow JSON.stringify.
  2. Plugin encapsulation creates request-decorator scope issues. Decorators registered inside an app.register(plugin) are visible only within that plugin's encapsulated scope. To share a decorator app-wide, wrap with fastify-plugin (the helper) to break encapsulation deliberately.
  3. Logging defaults to Pino in production mode. Without pino-pretty configured, logs are NDJSON — useful in production, painful locally. Configure: logger: { transport: { target: "pino-pretty" } } in dev only.
  4. reply.send() is mostly fire-and-forget. Calling await reply.send(data) is rarely what you want — Fastify already returns the value if you return data from the handler. Mixing reply.send + return causes double-send errors.
  5. Hooks vs middleware vs plugins. Three overlapping concepts: onRequest/preHandler/onSend hooks (lifecycle), app.addHook vs app.register(plugin). Middleware (app.use) is Express-compat and discouraged for new code.
  6. Validation errors return 400 by default. Customise via setErrorHandler to return your preferred error envelope. Without customisation the response shape may not match your API contract.
  7. reply.raw bypasses Fastify entirely. Writing to reply.raw (the underlying Node socket) skips serialization, lifecycle hooks, and content-type negotiation. Use only for streaming SSE or websocket upgrades.
  8. TypeScript route generics are verbose. The full RouteGenericInterface (Body, Querystring, Params, Headers, Reply) is loud. Use TypeBoxTypeProvider or the JsonSchemaToTsProvider plugins to infer types from your schemas instead of duplicating.
  9. Production listener: host: '0.0.0.0', not localhost. The default bind is 127.0.0.1, which won't accept external connections inside Docker / Kubernetes. Always set host: '0.0.0.0' for containerised deploys.

Real-world recipes

Schema-validated route with TypeBox

TypeBox produces JSON Schema at module load time and feeds it directly to Fastify's validator + serializer. The result: full typed inference end-to-end without code generation.

typescript
import Fastify from "fastify";
import { Type } from "@sinclair/typebox";
import { TypeBoxTypeProvider } from "@fastify/type-provider-typebox";

const app = Fastify({ logger: true }).withTypeProvider<TypeBoxTypeProvider>();

const CreateUser = Type.Object({
  email: Type.String({ format: "email" }),
  age: Type.Optional(Type.Integer({ minimum: 0 })),
});
const UserResp = Type.Object({ id: Type.String(), email: Type.String() });

app.post("/users", {
  schema: { body: CreateUser, response: { 201: UserResp } },
  handler: async (req, reply) => {
    // req.body is fully typed: { email: string; age?: number }
    const id = crypto.randomUUID();
    reply.code(201);
    return { id, email: req.body.email };
  },
});

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

Output: valid request returns 201 + JSON; missing email returns 400 with structured error.

JWT auth via @fastify/jwt

Most APIs need an auth layer. @fastify/jwt handles signing, verification, and request-decoration.

typescript
import Fastify from "fastify";
import jwt from "@fastify/jwt";

const app = Fastify();
await app.register(jwt, { secret: process.env.JWT_SECRET! });

app.decorate("authenticate", async (req, reply) => {
  try {
    await req.jwtVerify();
  } catch {
    reply.code(401).send({ error: "Unauthorized" });
  }
});

app.post("/login", async (req) => {
  // ...validate credentials...
  return { token: app.jwt.sign({ sub: "user-id" }) };
});

app.get("/me", { preHandler: app.authenticate }, async (req) => {
  return { user: req.user };
});

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

Output: /login returns a JWT; /me returns 401 without it, decoded user with it.

WebSocket route

@fastify/websocket adds the wsHandler option to routes.

typescript
import Fastify from "fastify";
import websocket from "@fastify/websocket";

const app = Fastify();
await app.register(websocket);

app.get("/chat", { websocket: true }, (socket, req) => {
  socket.on("message", (msg) => {
    socket.send(`echo: ${msg.toString()}`);
  });
});

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

Output: client connecting to ws://localhost:3000/chat receives echo: <message> for each message.

File upload with @fastify/multipart

typescript
import Fastify from "fastify";
import multipart from "@fastify/multipart";
import { pipeline } from "node:stream/promises";
import { createWriteStream } from "node:fs";

const app = Fastify();
await app.register(multipart, { limits: { fileSize: 10 * 1024 * 1024 } });

app.post("/upload", async (req, reply) => {
  const data = await req.file();
  if (!data) return reply.code(400).send({ error: "no file" });
  await pipeline(data.file, createWriteStream(`/tmp/${data.filename}`));
  return { saved: data.filename, size: data.file.bytesRead };
});

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

Output: uploaded file streamed to disk; multipart parsing happens on demand, so a malicious client can't consume memory by uploading 10 GB.

Graceful shutdown

Production servers need to drain in-flight requests before exiting. Fastify has close(); pair with signal handlers.

typescript
import Fastify from "fastify";

const app = Fastify();
app.get("/", async () => ({ ok: true }));

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

for (const sig of ["SIGTERM", "SIGINT"] as const) {
  process.on(sig, async () => {
    app.log.info(`received ${sig}, draining…`);
    await app.close();
    process.exit(0);
  });
}

Output: existing connections complete; new ones get connection-refused; clean exit.

Production deployment

Fastify is a long-lived Node process. The deployment concerns are process management, clustering, observability, and graceful restarts.

Process manager: PM2

bash
npm install -g pm2
pm2 start dist/server.js -i max --name api
pm2 startup        # generate the systemd unit
pm2 save

Output: runs N workers (one per CPU core) under PM2, restarts on crash, auto-starts on boot.

-i max enables Node cluster mode — multiple workers share the same port via the OS load balancer. For CPU-bound work this scales linearly with cores.

Clustering with node:cluster

For full control without PM2:

typescript
import cluster from "node:cluster";
import os from "node:os";

if (cluster.isPrimary) {
  for (let i = 0; i < os.availableParallelism(); i++) cluster.fork();
  cluster.on("exit", (worker) => {
    console.log(`worker ${worker.process.pid} exited; restarting`);
    cluster.fork();
  });
} else {
  await import("./server.js");
}

Output: primary forks one worker per core; each worker runs Fastify.

For most workloads PM2 (or a similar tool like forever) is simpler than rolling your own cluster.

Behind a reverse proxy

Run Fastify behind Nginx, Caddy, HAProxy, or a cloud load balancer. The proxy handles TLS termination, HTTP/2, and static assets.

nginx
# /etc/nginx/sites-available/api
server {
  listen 443 ssl http2;
  server_name api.example.com;
  ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;

  location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
  }
}

Enable trustProxy: true in Fastify so it honours X-Forwarded-* headers:

typescript
const app = Fastify({ trustProxy: true });

Output: req.ip resolves the real client IP, not the proxy's loopback.

Containerised deploy

dockerfile
FROM node:20-slim AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev

FROM node:20-slim
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY dist ./dist
COPY package.json .
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/server.js"]

Bind to 0.0.0.0 inside containers — the default 127.0.0.1 is unreachable from outside.

Health checks

Most platforms expect a /health or /healthz endpoint:

typescript
app.get("/healthz", { logLevel: "warn" }, async () => ({ ok: true }));

Use logLevel: "warn" so health-check polls don't flood the log.

Performance tuning

Fastify's main performance edge over Express is schema-driven serialization. Use it.

  • Always declare response schemas. Without a response schema, Fastify calls plain JSON.stringify. With one, it uses fast-json-stringify — 2-5× faster for typical payloads.
  • Tune the logger. Pino is fast but logging still has cost. Use level: "info" in production, level: "warn" for static health checks. Avoid pino-pretty outside dev.
  • Reuse schema objects. Inline schemas force AJV to re-compile on every route registration. Hoist to constants or use addSchema to share by $id.
  • Avoid JSON.parse(body) in handlers. Fastify already parses application/json; double-parsing wastes CPU.
  • Stream large responses. For >1 MB JSON, stream from a generator or use reply.raw directly. Pre-buffered responses dominate memory under load.
  • Plugin encapsulation has a cost. Each register boundary adds setup overhead. For very hot paths, register the plugin once at root rather than per-context.
  • HTTP/2 vs HTTP/1. Fastify supports HTTP/2 via the http2: true option. For most APIs the reverse proxy speaks HTTP/2 externally and HTTP/1 internally — keep Fastify on HTTP/1.
  • Benchmark with autocannon. It's the tool Fastify's maintainers use. npx autocannon -c 100 -d 10 http://localhost:3000/.

A realistic baseline: a simple /users/:id route with TypeBox validation + schema serialization handles ~30,000 req/s per core on a modern x86 chip (Node 22). Plain Express hits ~10,000.

Version migration guide

FromToNotable changes
fastify@3fastify@4Major TypeScript types overhaul. reply.send return type changed. Several deprecated APIs removed.
fastify@4fastify@5Node 20+ floor. Querystring parser switched to node:url. Several reply edge cases tightened (no more silent double-send). Plugins must declare correct fastify peer-dep range.

5.x migration checklist:

  1. Bump Node engine to 20+.
  2. Re-check every app.register — plugin signatures changed slightly to accept typed FastifyPluginCallback / FastifyPluginAsync.
  3. Audit reply.send(...) calls — mixing with return data now errors instead of silently overwriting.
  4. Update @fastify/* plugins; they pin a major peer-dep range.
  5. Run tsc --noEmit — most breaking changes are caught at compile.
  6. Verify schemas still validate after the AJV bump (AJV 8.x is stricter on draft-07 vs 2019-09 mixing).

For long-term stability, pin minor in package.json ("fastify": "5.x") and run npm outdated fastify monthly.

ESM/CJS interop & bundling

Fastify dual-publishes ESM and CJS. The interop notes are about how plugins and TypeScript projects consume it.

SetupPattern
ESM Nodeimport Fastify from "fastify";
CJS Nodeconst Fastify = require("fastify");
TypeScript ESMSame as ESM. Set "module": "Node16" (or NodeNext).
TypeScript CJSSet "module": "commonjs". Some @fastify/* plugins are ESM-only — pin minor versions if you must stay on CJS.
Bundlingesbuild --bundle --platform=node --target=node20 works. Fastify's many plugins use dynamic require — pass --external for things that bundle poorly.
BunMostly works. Some plugins use node:cluster or low-level streams that Bun handles differently — test before relying.
Denoimport Fastify from "npm:fastify". Node-compat is required.
Edge runtimesFastify is NOT suitable for Cloudflare Workers / Vercel Edge / Deno Deploy — it expects a long-lived Node process with the http module. Use hono or h3 instead.

The @fastify/* plugins are ESM-first in their 4.x+ lines. CJS projects need to either preload via await import(...) or pin older plugin majors.

Plugin & ecosystem coverage

The @fastify/* org is the canonical plugin namespace. Third-party plugins use fastify-* (older) or any name with the right peer-dep.

PluginPurpose
@fastify/corsCORS handling with preflight cache.
@fastify/helmetSecure HTTP headers.
@fastify/jwtJWT signing, verification, and request decoration.
@fastify/cookieCookie parsing and signing.
@fastify/sessionCookie-based sessions.
@fastify/rate-limitToken-bucket rate limiting with Redis backend support.
@fastify/staticServe static files (preferably let the reverse proxy do this).
@fastify/multipartmultipart/form-data parsing.
@fastify/websocketWebSocket via ws.
@fastify/swagger + @fastify/swagger-uiOpenAPI generation from route schemas.
@fastify/sensibleConvenience methods (reply.notFound(), reply.badRequest()).
@fastify/under-pressureLoad shedding when event-loop lag or RSS exceed thresholds.
@fastify/redisRedis client wired into the app instance.
@fastify/postgresPostgres pool.
@fastify/mysqlMySQL pool.
@fastify/mongodbMongoDB client.
@fastify/etagAuto-generates ETag headers.
@fastify/compressgzip / brotli response compression.
@fastify/circuit-breakerCircuit-breaker pattern for upstream calls.
@fastify/type-provider-typeboxType-provider wiring TypeBox schemas to route handlers.
@fastify/type-provider-json-schema-to-tsAlternative type provider for plain JSON Schema.
fastify-pluginHelper to declare a plugin that breaks encapsulation.
mercuriusGraphQL adapter for Fastify.
pino, pino-pretty, pino-rollLogger ecosystem.

Testing & CI integration

Fastify has first-class support for in-process testing via app.inject() — no port binding, no HTTP, full lifecycle.

inject() for unit tests

typescript
import { test, expect } from "vitest";
import Fastify from "fastify";

test("GET / returns ok", async () => {
  const app = Fastify();
  app.get("/", async () => ({ ok: true }));

  const res = await app.inject({ method: "GET", url: "/" });
  expect(res.statusCode).toBe(200);
  expect(res.json()).toEqual({ ok: true });
  await app.close();
});

Output: 1 passed. No real TCP socket; the test runs in microseconds.

Suite-level helpers

Hoist app construction so each test starts clean:

typescript
import { beforeEach, afterEach } from "vitest";
import Fastify, { FastifyInstance } from "fastify";

let app: FastifyInstance;

beforeEach(async () => {
  app = Fastify();
  // ...register routes / plugins
  await app.ready();
});

afterEach(async () => {
  await app.close();
});

CI pipeline

yaml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22 }
      - run: npm ci
      - run: npm test
      - run: npx autocannon -c 100 -d 5 http://localhost:3000/  # smoke perf check
        if: github.event_name == 'pull_request'

Snapshot OpenAPI

When using @fastify/swagger, snapshot the generated OpenAPI to catch unintended API contract changes.

typescript
test("OpenAPI is stable", async () => {
  const app = await buildApp();
  await app.ready();
  expect(app.swagger()).toMatchSnapshot();
  await app.close();
});

Security considerations

  • Always validate inputs via schema. A route without a body / querystring / params schema accepts anything. Schema validation IS your input layer.
  • Use @fastify/helmet. Sets sane secure headers (CSP, HSTS, X-Content-Type-Options, etc.) with one line: app.register(helmet).
  • Rate-limit auth endpoints. @fastify/rate-limit with stricter limits on /login, /signup, /reset-password prevents brute force.
  • Don't trust req.headers["x-forwarded-*"] without trustProxy: true. Fastify rejects spoofed headers unless explicitly trusted. Configure with the number of proxies in front of you, not just true, to harden further.
  • @fastify/jwt defaults to HS256. For multi-service architectures, switch to RS256 or ES256 with public-key verification.
  • Don't log full request bodies in production. PII / passwords / tokens land in your log store. Configure Pino's redact to mask sensitive paths.
  • @fastify/multipart file-size limits. Default is unlimited — set limits: { fileSize: ... } or denial-of-service via large uploads is trivial.
  • Disable app.use for new code. Express-style middleware bypasses Fastify's lifecycle and validation. Use hooks instead.
  • Audit transitive deps. Fastify itself is well-audited; third-party @fastify/* plugins have varying maintenance. Run npm audit and pin minor versions of every plugin.
  • reply.raw bypasses everything. Use only when you understand the consequences (SSE, custom binary protocols).

Troubleshooting common errors

FST_ERR_VALIDATION (400) — request failed schema validation. The response body details which field. Adjust the schema or fix the client payload.

FST_ERR_INSTANCE_ALREADY_LISTENING — calling app.listen() twice. Common in tests where the test suite shares an app. Use app.inject for tests instead.

Cannot set headers after they are sent — mixed reply.send(data) with return data. Pick one. Most idiomatic: return data from the handler.

FastifyError: Failed building the validation schema — schema isn't valid JSON Schema. Common cause: passing a Zod schema directly. Convert via zod-to-json-schema or use TypeBox.

Plugin's decorator not visible in route handler — encapsulation. The decorator was registered inside a sub-plugin's scope. Wrap the plugin with fastify-plugin to expose globally, or move the decorator registration up.

Slow JSON serialization — no response schema. Add one matching your payload shape; serialization speed jumps.

request.body is undefined — Content-Type doesn't match a parser (most often missing application/json). Either set the header client-side or call app.addContentTypeParser for your custom type.

EADDRINUSE — port already taken. Find with lsof -i :3000 and kill, or pass a different port.

Cannot find module 'pino-pretty' — pretty transport configured but not installed as a dependency. npm install -D pino-pretty and gate it behind NODE_ENV !== "production".

Connection: close on every response — keep-alive disabled or the client isn't sending one. Verify with curl -v — Fastify supports keep-alive by default.

When NOT to use this

Skip Fastify when:

  • You target edge runtimes. Fastify needs Node's http module. For Workers / Vercel Edge / Deno Deploy, use hono or h3. They share the JSON Schema validation idiom but run on Web standards.
  • The API is trivially small. A single GET /health endpoint is fine with http.createServer. Fastify's plugin model becomes overhead.
  • You want classical decorators / DI. nestjs (which can run on Fastify under the hood) offers that model with more structure. Pure Fastify is functional, not OOP.
  • You're rewriting an Express app and don't have time to convert middleware. Express's middleware ecosystem has 15+ years of inertia. Fastify supports Express middleware via @fastify/express but it's a compatibility shim, not a goal.
  • The runtime is Bun-only. Bun ships its own fast HTTP server (Bun.serve). Fastify works on Bun but you forfeit Bun-native speed.
  • You write a pure GraphQL backend. mercurius is Fastify-native but Apollo Server has more docs. The Fastify edge isn't dramatic for a single GraphQL entry point.

See also