mrkeyoor.com_
Sat 19 Sept 10:02 UTC
npmWeb Backendupdated 19 Sept 2026

fastify review

Fastify 5.12.1 is a Node.js HTTP framework whose routes can carry JSON Schema for request validation and response serialization. It compiles those schemas, exposes request-scoped Pino logging, and uses registered plugins to add routes, hooks, decorators, and services inside explicit encapsulation boundaries. This is server code: our browser build failed in esbuild. Version 5.12.1 is a security release that fixes root-primitive validation bypass and `X-Forwarded-*` spoofing when `trustProxy` uses a hop count. It also makes `preClose` run once per declaring instance and corrects canonical URLs for a prefixed slash route.

Verdict

Fastify 5.12.1 fits Node APIs that will use route schemas and plugin encapsulation rather than treating the framework as a faster Express clone. Install the security release, configure proxy trust precisely, and look elsewhere for edge portability or an opinionated full-stack application structure.

We installed it

Lab card: what happened when we installed fastifyScreenshot of fastify documentation
Install✓ · 4.9s49 packages on disk · 14 MB
ImportESM import works · require() works · CommonJS package
Browsern/acould not be bundled for the browser (Node-only code, most likely)
TypesTypeScript types bundled
Known vulns00 critical · 0 high · 0 moderate · 0 low (npm audit)

Answers from our run

Does fastify install cleanly?

Yes. In a fresh container with an empty cache, npm install fastify finished in 5 seconds, leaving 49 packages and 14 MB on disk. npm audit reported no known vulnerabilities.

Can fastify run in a browser?

Not directly: esbuild could not bundle it for the browser in our run, which normally means it depends on Node built-ins. Use it on the server, or find a browser-targeted alternative.

Does fastify work with both ESM and CommonJS?

Yes. Both import 'fastify' and require('fastify') worked in Node 22 in our run. The package is published as CommonJS.

Does fastify include TypeScript types?

Yes, type declarations ship inside the package, so no @types install is needed.

fastify or express: which should you use?

express: Use it when existing middleware and team familiarity outweigh schema compilation and scoped plugins. Fastify 5.12.1 fits Node APIs that will use route schemas and plugin encapsulation rather than treating the framework as a faster Express clone.

When should you not use fastify?

The service must run unchanged in browser or edge-worker runtimes. Our esbuild browser target failed, and Fastify's server, stream, socket, and lifecycle APIs are built around Node.

API stability4/5Fastify 5 keeps the established route declaration, hooks, decorators, plugins, schema compilation, reply methods, logging, and injection test API. Patch 5.12.1 fixes security and lifecycle behavior without changing ordinary handlers. Major upgrades still require planning: the README points production v5 users to a dedicated 5.x branch because main now tracks v6, and official ecosystem plugins publish compatibility ranges tied to Fastify majors.
Docs5/5The official site has separate references for the server, routes, requests, replies, hooks, lifecycle, decorators, plugins, validation and serialization, errors, content parsers, logging, HTTP/2, testing, TypeScript, serverless use, LTS policy, and benchmarking. The guides explain encapsulation and plugin authoring with code. The amount of material can slow a first read, but the hard production topics are documented rather than left to examples in issue threads.
Maintenance5/5GitHub reports 37,027 stars, 128 open issues and pull requests, an unarchived repository, and a push on August 23, 2026. Release 5.12.1 arrived on August 18 with two coordinated medium-severity security fixes plus route and shutdown corrections. The project publishes a security policy and an LTS matrix, keeps the 5.x release line separate while v6 develops on main, and names a multi-person core maintenance team.
Ecosystem4/5npm recorded 11,704,337 downloads in the latest completed week. Fastify's ecosystem guide separates team-maintained and community plugins, with packages for common concerns such as CORS, authentication, rate limits, documentation, cookies, and static files. Built-in TypeScript declarations, CommonJS loading, ESM interop, and `inject()` help common Node setups. Express still has more middleware and older examples, and compatibility must be checked for every plugin major.

Use it if

  • Your Node API can define JSON Schema beside each route and benefit from compiled input checks plus response serialization.
  • Plugins should own their routes, hooks, decorators, startup work, and shutdown cleanup inside a predictable scope.
  • Structured request logging is required from the first endpoint; Fastify wires Pino into the request lifecycle when logging is enabled.
  • Tests should exercise the full routing lifecycle in process through `app.inject()` without opening or reserving a TCP port.
Skip it if

Setup reality

We installed fastify 5.12.1 in a fresh Node 22 Bookworm container. npm took 4.9 seconds, put 49 packages on disk, and used 14 MB. Fastify declares 15 direct dependencies and no peers; the package is 3912 KB unpacked. npm audit found zero known vulnerabilities. It is CommonJS with no exports map, while both require() and ESM import worked. TypeScript declarations are included. Our esbuild browser target failed, which confirms this is Node-side code.

A useful Fastify application writes schemas for params, queries, bodies, headers, and replies. Request validation runs before the handler. Response schemas also filter serialized fields, so an omitted property disappears even if the handler returns it. Treat schemas as part of the API contract and test them. User-provided schemas are application code: the documentation warns against passing untrusted schema text into the validators or using database access in initial validation.

Registration is asynchronous and scoped. Await plugin registration when later setup depends on it, wrap shared plugins with fastify-plugin when their decorators must escape the child scope, and declare decorator dependencies close to the plugin. The listening default is local host. Containers normally need { host: '0.0.0.0' }, paired with deliberate network exposure. Set trustProxy only for proxies you control; 5.12.1 specifically repairs spoofing under hop-count trust.

Use one reply style per handler. An async handler can return a value, or it can send through reply; mixing the two makes lifecycle reasoning harder. Hooks have fixed phases, and premature work in the wrong hook can run before authentication or parsing. Close the instance during tests and shutdown so onClose handlers release pools. Fastify does not move CPU-heavy work off the event loop, and its speed claims do not erase database or upstream latency.

Patterns

Start a logged JSON server start-server

import Fastify from 'fastify'

const app = Fastify({ logger: true })

app.get('/', async () => ({ hello: 'world' }))

try {
  await app.listen({ port: 3000 })
} catch (error) {
  app.log.error(error)
  process.exitCode = 1
}

An async route may return an object and let Fastify serialize it. The default listener is local to the machine unless a host is supplied.

Reject an invalid JSON body validate-body

app.post('/users', {
  schema: {
    body: {
      type: 'object',
      additionalProperties: false,
      required: ['name', 'email'],
      properties: {
        name: { type: 'string', minLength: 1 },
        email: { type: 'string', format: 'email' },
      },
    },
  },
}, async (request) => ({ created: request.body.name }))

Body validation happens before the handler and normally returns 400 on failure. Register any extra AJV formats your schema uses.

Limit fields in a successful reply serialize-response

app.get('/users/:id', {
  schema: {
    response: {
      200: {
        type: 'object',
        required: ['id', 'name'],
        properties: {
          id: { type: 'integer' },
          name: { type: 'string' },
        },
      },
    },
  },
}, async () => ({ id: 1, name: 'Ada', passwordHash: 'secret' }))

The compiled response serializer emits only schema fields. That can protect a secret, but it also hides an accidentally undocumented field, so assert the public shape in tests.

Type params and query values type-route-input

type RequestShape = {
  Params: { id: string }
  Querystring: { verbose?: boolean }
}

app.get<RequestShape>('/items/:id', {
  schema: {
    params: { type: 'object', required: ['id'], properties: { id: { type: 'string' } } },
    querystring: { type: 'object', properties: { verbose: { type: 'boolean' } } },
  },
}, async (request) => ({ id: request.params.id, verbose: request.query.verbose ?? false }))

A TypeScript generic does not validate network input. Keep the runtime schema aligned, or use a supported type provider that derives types from the same schema.

Expose a database decorator share-plugin-decorator

import fp from 'fastify-plugin'

async function databasePlugin (app, options) {
  const db = await connect(options.url)
  app.decorate('db', db)
  app.addHook('onClose', async () => db.close())
}

export default fp(databasePlugin, { name: 'database' })

await app.register(import('./database-plugin.js'), { url: process.env.DB_URL })

A normal registered plugin creates a child scope. The `fastify-plugin` wrapper makes this decorator available to the surrounding application and records plugin metadata.

Protect only one route group scope-auth-hook

await app.register(async function protectedRoutes (scope) {
  scope.addHook('onRequest', async (request, reply) => {
    if (!await verify(request.headers.authorization)) {
      return reply.code(401).send({ error: 'unauthorized' })
    }
  })

  scope.get('/admin', async () => ({ allowed: true }))
}, { prefix: '/api' })

The hook belongs to this plugin scope and its descendants. Routes registered outside the scope do not inherit it.

Map validation and server failures handle-errors

app.setErrorHandler((error, request, reply) => {
  request.log.error({ err: error })

  if (error.validation) {
    return reply.code(400).send({ error: 'invalid request' })
  }

  const status = error.statusCode && error.statusCode < 500 ? error.statusCode : 500
  return reply.code(status).send({ error: status === 500 ? 'internal error' : error.message })
})

Validation errors reach the custom handler too. Avoid returning internal exception text on 5xx responses even though the full error belongs in server logs.

Exercise a route without a socket test-with-inject

import test from 'node:test'
import assert from 'node:assert/strict'
import { build } from './app.js'

test('health route', async (t) => {
  const app = build()
  t.after(() => app.close())

  const response = await app.inject({ method: 'GET', url: '/health' })
  assert.equal(response.statusCode, 200)
  assert.deepEqual(response.json(), { ok: true })
})

Injection waits for application boot and runs the request lifecycle in process. Closing the instance also tests plugin shutdown paths.

Bind a container-visible address listen-in-container

await app.listen({
  port: Number(process.env.PORT ?? 3000),
  host: '0.0.0.0',
})

Binding all interfaces is normally needed inside a container. Restrict published ports and place authentication or a trusted proxy in front of any non-public service.

Allow one browser origin configure-cors

import cors from '@fastify/cors'

await app.register(cors, {
  origin: ['https://app.example.com'],
  methods: ['GET', 'POST'],
  credentials: true,
})

Use a `@fastify/cors` version compatible with Fastify 5. Avoid reflecting arbitrary origins when credentials are enabled.

Add a text content parser parse-custom-content

app.addContentTypeParser('text/plain', { parseAs: 'string', bodyLimit: 64 * 1024 },
  function parseText (request, body, done) {
    done(null, body)
  }
)

app.post('/events', async (request) => ({ length: request.body.length }))

Set a body limit that matches the endpoint. Content parsers belong to an encapsulation scope, so register the parser where the consuming routes can see it.

Run plugin cleanup on termination shut-down-cleanly

async function shutdown (signal) {
  app.log.info({ signal }, 'shutting down')
  try {
    await app.close()
  } catch (error) {
    app.log.error(error)
    process.exitCode = 1
  }
}

process.once('SIGTERM', () => shutdown('SIGTERM'))
process.once('SIGINT', () => shutdown('SIGINT'))

`close()` stops accepting work and runs registered shutdown hooks. The process can exit naturally after open pools and handles have been released.

Alternatives

PackageRegistryPick it when
expressnpmUse it when existing middleware and team familiarity outweigh schema compilation and scoped plugins.
koanpmUse it when a small async middleware core is enough and you want to choose routing, validation, and logging separately.
hononpmUse it when the same handler model must target Node, Bun, Deno, and worker-style edge platforms.

More web backend guides

urllib3 · requests · ws · anyio · undici · httpx · the whole shelf →

How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.