FASTIFY / v5 architecture
code
Source-level breakdown, fastify@5.12.5

Fastify, compiled before the first request.

The package is the wiring. The engines — avvio, find-my-way, ajv, fast-json-stringify — are imports. Every plugin scope is Object.create(parent), every schema becomes a function at boot, and a request is a fixed chain of ten hooks around one handler. This page walks the real files, runs a working model of the three engines in the page, and prints what it measured.

  • router
  • hooks and lifecycle
  • plugins and encapsulation
  • schema, compiled
  • errors and 404
  • request and reply
  • decorators, the DI layer
  • your code

What is actually in the package

One file makes the instance; a directory of 32 files gives it behaviour; the engines live in 15 dependencies. Read the package as wiring: fastify.js assembles objects that other packages know how to run.

fastify/                      # the published package
├── fastify.js                # 1,026 lines — the factory and the instance
└── lib/
    ├── config-validator.js           # 1,266
    ├── content-type-parser.js        # 425
    ├── content-type.js               # 214
    ├── context.js                    # 95
    ├── decorate.js                   # 160
    ├── error-handler.js              # 155
    ├── error-serializer.js           # 134
    ├── error-status.js               # 14
    ├── errors.js                     # 548
    ├── four-oh-four.js               # 181
    ├── handle-request.js             # 230
    ├── head-route.js                 # 45
    ├── hooks.js                      # 433
    ├── initial-config-validation.js  # 37
    ├── log-controller.js             # 169
    ├── logger-factory.js             # 157
    ├── logger-pino.js                # 68
    ├── noop-set.js                   # 10
    ├── plugin-override.js            # 91
    ├── plugin-utils.js               # 169
    ├── promise.js                    # 23
    ├── reply.js                      # 1,093
    ├── req-id-gen-factory.js         # 55
    ├── request.js                    # 398
    ├── route.js                      # 696
    ├── schema-controller.js          # 164
    ├── schemas.js                    # 217
    ├── server.js                     # 441
    ├── symbols.js                    # 73
    ├── validation.js                 # 461
    ├── warnings.js                   # 93
    └── wrap-thenable.js              # 84

Weight sits where the work is: lib/reply.js (1,093 lines) owns sending, lib/route.js (696) owns registration, and lib/config-validator.js (1,266) is generated code that checks the options object once at startup.

The engines are imports

fastifyfastify.js + lib/, 9,425 linesavvioboot graph, 803 linesfind-my-wayradix router, 2,072 lines@fastify/ajv-compilervalidation, ajv 8.20.0fast-json-stringifyserialisation, 2,743 linespinologging, 2,196 lineslight-my-requestinject(), 1,769 linesarrows point the way require() does: the package pulls its engines in

Each dependency owns one mechanism and exposes it through a small interface. avvio runs the plugin graph, find-my-way answers lookups, the two compilers turn schemas into functions, and pino and light-my-request sit at the edges.

packageversionfileslineswhat fastify delegates to it
avvio9.3.012803the boot graph: register, after, ready, close
find-my-way9.9.0112,072the router: a radix tree, constraints, params
@fastify/ajv-compiler4.0.64152validation: wraps ajv 8.20.0 with fastify's defaults
fast-json-stringify7.0.172,743serialisation: a generated function per response schema
pino10.3.1152,196logging: request.log is a pino child logger
light-my-request6.6.061,769inject(): a request without a socket
@fastify/error4.2.01100error classes: the FST_ERR_* constructors

How the packages compose at runtime

fastify() builds one object and hands parts of it to the engines. avvio gets the object to boot; find-my-way gets every route; each scope gets its own Request and Reply subclasses; each route gets a Context that holds its resolved hooks and compiled schema functions.

fastify(options)fastify.js — the factoryroot instancea plain object, 69 own propsavvioregister, after, readyfind-my-wayrouter.on, router.findHooksten lifecycle arraysRequest / Reply classesone subclass per scopeSchemaControllervalidator + serializererror handler chainObject.create(parent)decoratorsown props on the scopeserver.jshttp, https, http2route contextsone Context per routeyour handlerroute functionreply.sendboot-time objects on the left and middle; request-time objects (Request, Reply, your handler) on the right

Read off the real objects with Object.getOwnPropertyNames and Object.getOwnPropertySymbols, not from the documentation. Three separate things are worth telling apart in each: what the object owns, what it hides behind symbols, and what it inherits.

fastify() return valuea plain object
own properties
69
registerafterreadylisteninjectroutegetpostdecorateaddHookaddSchemasetErrorHandlerand 57 more
symbol slots
32
statekeepAliveConnectionsacceptedHTTPMethodsoptionschildrenserverBindingsand 26 more
prototype chain
Object.prototype12 members
public methods and getterssymbol-keyed internals plugins must not touch
a child scopeObject.create(parent)
own properties
4
readygetSchemagetSchemasdb
symbol slots
13
childrenReplyRequestcontentTypeParserhooksroutePrefixand 7 more
prototype chain
the root instance69 ownObject.prototype12 members
re-created per scopewhat this plugin decoratedits private slots
a grandchild scopeObject.create(child)
own properties
3 — the same three that every scope re-creates
prototype chain
the child scope4 ownthe root instance69 ownObject.prototype12 members
request objectan instance of this scope's Request subclass
own properties
8
idparamsrawquerylogbodytenantuser
prototype chain
_Request1 own_Request1 own_Request1 ownRequest.prototype21 membersObject.prototype12 members
assigned by the constructor, in this order, for every requestone per value decorator on this scope
reply objectan instance of this scope's Reply subclass
own properties
3
rawrequestlog
prototype chain
Reply.prototype31 membersObject.prototype12 members

The shapes, in code

The three objects your code touches, declared the way the shipped types declare them (types/instance.d.ts, types/request.d.ts, types/reply.d.ts), with the generics folded away. Return types are the real ones: most reply methods return the reply so they chain; most instance registration methods return the instance for the same reason.

FastifyInstance, folded
interface FastifyInstance {  server: RawServer  log: FastifyBaseLogger  prefix: string  pluginName: string  version: string  initialConfig: Readonly<FastifyServerOptions>   register(plugin: FastifyPluginAsync | FastifyPluginCallback, opts?: object): this  after(listener?: (err: Error | null) => void): this & PromiseLike<void>  ready(listener?: (err: Error | null) => void): this & PromiseLike<void>  listen(opts?: FastifyListenOptions): Promise<string>  close(): Promise<void>  inject(opts: InjectOptions | string): Promise<LightMyRequestResponse>   route(opts: RouteOptions): this  get(url: string, opts: RouteShorthandOptions, handler: RouteHandler): this  post(url: string, opts: RouteShorthandOptions, handler: RouteHandler): this  hasRoute(opts: { method: HTTPMethods; url: string }): boolean  addHook(name: HookName, hook: HookHandler): this   decorate(name: string | symbol, value: unknown): this  decorateRequest(name: string | symbol, value: unknown): this  decorateReply(name: string | symbol, value: unknown): this  hasDecorator(name: string | symbol): boolean  hasPlugin(name: string): boolean   addSchema(schema: unknown): this  getSchema(id: string): unknown  getSchemas(): Record<string, unknown>  setValidatorCompiler(fn: FastifySchemaCompiler): this  setSerializerCompiler(fn: FastifySerializerCompiler): this  addContentTypeParser(type: string | RegExp, opts: ParserOptions, fn: Parser): void  setErrorHandler(handler: (error: FastifyError, request, reply) => unknown): this  setNotFoundHandler(handler: RouteHandler): this  addConstraintStrategy(strategy: ConstraintStrategy): void  withTypeProvider<Provider extends FastifyTypeProvider>(): FastifyInstance  printRoutes(opts?: PrintRoutesOptions): string}
  1. L2server is the raw Node server after listen; prefix and pluginName are per scope — a child sees its own values through its own properties, everything else through the chain.
  2. L9register, after and ready return the instance (this) and are thenable, which is what lets await app.register(x) work. Everything is queued until ready drains it.
  3. L16Every route method returns the instance. The shorthand also accepts (url, handler) with no options object; both forms end in route(opts).
  4. L22Three decorate methods, one existence-check rule each; hasDecorator walks the chain, so it answers for a parent's decorator too.
  5. L28Schema methods write to this scope's SchemaController; the compiler setters replace the compiler for this scope's routes only.
  6. L34setErrorHandler and setNotFoundHandler install per scope. printRoutes renders the radix tree — the same shape the tree figures draw.
FastifyRequest, folded
interface FastifyRequest {  id: string  params: Params  query: Querystring  body: Body  headers: IncomingHttpHeaders & Headers  raw: IncomingMessage | Http2ServerRequest  log: FastifyBaseLogger  server: FastifyInstance  validationError?: Error & { validation: unknown; validationContext: string }   readonly url: string  readonly originalUrl: string  readonly method: string  readonly host: string  readonly hostname: string  readonly port: number | null  readonly protocol: 'http' | 'https'  readonly ip: string  readonly ips?: string[]  readonly socket: Socket  readonly signal: AbortSignal  readonly mediaType: string | undefined  readonly routeOptions: Readonly<RequestRouteOptions>   getValidationFunction(httpPart: HTTPRequestPart): ValidationFunction | undefined  compileValidationSchema(schema: object, httpPart?: HTTPRequestPart):    ValidationFunction  validateInput(input: unknown, schema: object, httpPart?: HTTPRequestPart): boolean  getDecorator<T>(name: string | symbol): T  setDecorator<T>(name: string | symbol, value: T): void}
  1. L2The own properties: assigned in the constructor in this order for every request. body is undefined until parsing runs; params and query exist from routing.
  2. L10validationError is only set when the route has attachValidation: true; otherwise a failed validation replies 400 before the handler.
  3. L12Getters on Request.prototype: url and method read from raw; host falls back to the HTTP/2 :authority pseudo-header; ip honours trustProxy.
  4. L24routeOptions is the matched route's frozen options: method, url, config, schema, constraints, handler.
  5. L26The schema helpers reach the scope's compiled functions or compile ad hoc; validateInput returns a boolean and fills validationError.
FastifyReply, folded
interface FastifyReply {  raw: ServerResponse | Http2ServerResponse  request: FastifyRequest  log: FastifyBaseLogger  server: FastifyInstance  statusCode: number  readonly sent: boolean  readonly elapsedTime: number  readonly routeOptions: Readonly<RequestRouteOptions>   code(statusCode: number): this  status(statusCode: number): this  header(key: string, value: unknown): this  headers(values: Record<string, unknown>): this  getHeader(key: string): number | string | string[] | undefined  getHeaders(): Record<string, number | string | string[] | undefined>  hasHeader(key: string): boolean  removeHeader(key: string): this  type(contentType: string): this  trailer(key: string, fn: TrailerFunction): this   send(payload?: unknown): this  redirect(url: string, statusCode?: number): this  hijack(): this  callNotFound(): void  writeEarlyHints(hints: Record<string, string | string[]>): void   serialize(payload: unknown): string | ArrayBuffer | Buffer  serializer(fn: (payload: unknown) => string): this  getSerializationFunction(httpStatus: string, contentType?: string):    SerializationFunction  compileSerializationSchema(schema: object, httpStatus?: string):    SerializationFunction  serializeInput(input: object, httpStatus: string, contentType?: string): unknown  then(fulfilled: () => void, rejected: (err: Error) => void): void}
  1. L2Three own properties (raw, request, log) — measured — and statusCode, which code() sets. sent flips when the write starts; hooks read it to stop early.
  2. L11Status and header methods return the reply (this), so reply.code(201).header(...).send(...) is one expression. type() disables the content-type guessing that send does.
  3. L22send is the single exit for every payload kind; redirect sets location and sends an empty body; hijack hands raw to you and skips the rest of the pipeline.
  4. L28The serialisation helpers reach the compiled function for a status, or compile one on demand. serializer() sets a per-reply function that replaces the schema path.
  5. L35then makes await reply resolve when the response is sent — the same mechanism that lets a handler return reply.
RouteOptions and the plugin signatures, folded
interface RouteOptions {  method: HTTPMethods | HTTPMethods[]  url: string  handler: (this: FastifyInstance, request: FastifyRequest,            reply: FastifyReply) => unknown  schema?: { body?: object; querystring?: object; params?: object; headers?: object;             response?: Record<number | string, object> }  config?: object  constraints?: { host?: string | RegExp; version?: string;                  [custom: string]: unknown }  bodyLimit?: number  logLevel?: string  exposeHeadRoute?: boolean  prefixTrailingSlash?: 'both' | 'slash' | 'no-slash'  attachValidation?: boolean  onRequest?: HookHandler | HookHandler[]  preParsing?: HookHandler | HookHandler[]  preValidation?: HookHandler | HookHandler[]  preHandler?: HookHandler | HookHandler[]  preSerialization?: HookHandler | HookHandler[]  onSend?: HookHandler | HookHandler[]  onResponse?: HookHandler | HookHandler[]  onError?: HookHandler | HookHandler[]  errorHandler?: (error: FastifyError, request: FastifyRequest,                  reply: FastifyReply) => unknown  validatorCompiler?: FastifySchemaCompiler  serializerCompiler?: FastifySerializerCompiler  schemaErrorFormatter?: (errors: ValidationError[], dataVar: string) => Error} type FastifyPluginAsync<Opts = object> =  (instance: FastifyInstance, opts: Opts) => Promise<void>type FastifyPluginCallback<Opts = object> =  (instance: FastifyInstance, opts: Opts, done: (err?: Error) => void) => void
  1. L4The handler's this is the scope, which is why a plain function can read this.db. Its return value is what wrapThenable sends.
  2. L6Five schema parts. The four incoming ones compile to validators, response to one serializer per status key (200, 2xx, default).
  3. L9constraints is a second key on the same method and path; the values are matched by the router's strategies.
  4. L16Route-level hooks run after the scope's hooks of the same stage. Arrays are allowed; onTimeout and onRequestAbort are route options too.
  5. L24A route-level errorHandler sits before the scope's chain; the compiler and formatter options override the scope's for this route only.
  6. L31A plugin is a function of (instance, opts); the async form resolves when its body is done, the callback form calls done. Either way, its registrations drain before the next sibling.

The three _Request links are one subclass per scope, each owning only its constructor — that is what encapsulation costs per level. The magenta properties are the reason a value decorator added after a scope was created never reaches it: they are copied into the constructor when the subclass is built, not looked up on a prototype at request time.

Core modules

Every public method belongs to one file. Anything that builds or boots an instance lives in fastify.js and lib/plugin-override.js; anything about a route in lib/route.js; anything a request does in lib/request.js and lib/reply.js; anything about order in lib/hooks.js and lib/handle-request.js.

fastify.jsthe factory, 1,026 lineslib/route.jsroute(), prefix, HEAD, preReadylib/handle-request.jsparse → validate → handlerlib/request.jsRequest, subclassed per scopelib/plugin-override.jsObject.create(old)lib/context.jsContext, one per routelib/hooks.jsthe runnerslib/reply.jsReply class, send()lib/decorate.jsdecorate, decorateRequestlib/validation.jscompile + run validatorslib/schema-controller.jsshared schemas, compilerslib/error-handler.jshandler chain + fallbacklib/server.jslisten; http, https, http2lib/four-oh-four.jsper-prefix 404 contextslib/content-type-parser.jsbody parsers by media typelib/errors.jsFST_ERR_* codessolid arrows are the reading order: factory → route → request path → the objects; dashed ones only step to the next row

fastify.js, the factory

fastify(options) is the only export. It validates options against a generated schema (lib/config-validator.js), creates the root instance as a plain object with 69 own methods and properties, attaches avvio to it, builds the router, the root Request and Reply classes, the Hooks object and the SchemaController, and returns the object. Nothing listens, nothing is compiled yet.

app.js
import Fastify from 'fastify' const app = Fastify({ logger: true }) app.get('/health', async () => ({ ok: true })) await app.listen({ port: 3000 })
import Fastify, { type FastifyInstance } from 'fastify' const app: FastifyInstance = Fastify({ logger: true }) app.get('/health', async () => ({ ok: true })) await app.listen({ port: 3000 })
  1. L3Fastify(options) runs the factory in fastify.js: it validates the options once, builds the root instance, wires avvio to it and creates the router. Nothing listens yet.
  2. L5app.get registers a route synchronously into the radix tree and creates its Context. Its hooks and schemas are resolved later, at preReady.
  3. L7listen() boots first — ready() drains every plugin — then lib/server.js creates the Node server and binds the port.
  1. L3FastifyInstance is the type of the root object and of every scope a plugin receives; the generic parameters carry the server, request and reply types.
  2. L5app.get registers a route synchronously into the radix tree and creates its Context. Its hooks and schemas are resolved later, at preReady.
  3. L7listen() boots first — ready() drains every plugin — then lib/server.js creates the Node server and binds the port.

lib/server.js, the listener

listen() first awaits ready(), then chooses how to build the Node server: http.createServer by default, https.createServer when https options exist, http2.createServer or http2.createSecureServer when http2: true, or whatever a serverFactory returns. The request listener it registers is the same function in every case.

server options
import Fastify from 'fastify'import { readFileSync } from 'node:fs' const app = Fastify({  http2: true,  https: {    allowHTTP1: true,    key: readFileSync('./key.pem'),    cert: readFileSync('./cert.pem'),  },}) const factory = Fastify({  serverFactory(handler, opts) {    const server = createMyServer(opts)    server.on('request', handler)    return server  },})
import Fastify, { type FastifyServerFactory } from 'fastify'import { readFileSync } from 'node:fs' const app = Fastify({  http2: true,  https: {    allowHTTP1: true,    key: readFileSync('./key.pem'),    cert: readFileSync('./cert.pem'),  },}) const makeServer: FastifyServerFactory = (handler, opts) => {  const server = createMyServer(opts)  server.on('request', handler)  return server}const factory = Fastify({ serverFactory: makeServer })
  1. L5http2: true makes lib/server.js call http2.createSecureServer when https is present, or http2.createServer for cleartext h2c.
  2. L7allowHTTP1 keeps HTTP/1.1 clients working on the same port — the same handler serves both, because request and reply wrap either kind of stream.
  3. L14serverFactory receives fastify's request handler and must return something with listen and close; fastify never creates a server itself in that case.
  1. L5http2: true makes lib/server.js call http2.createSecureServer when https is present, or http2.createServer for cleartext h2c.
  2. L7allowHTTP1 keeps HTTP/1.1 clients working on the same port — the same handler serves both, because request and reply wrap either kind of stream.
  3. L13FastifyServerFactory types the (handler, opts) pair: the handler is fastify's request listener, and the return value must offer listen and close.

lib/route.js and lib/context.js

route(opts) normalises the URL against the scope's prefix, runs onRoute hooks, creates a Context and inserts the route into find-my-way — all synchronously. Then it registers an after() callback and an avvio preReady listener: only there are the route's hooks resolved from the scope's arrays, its schemas compiled and its 404 context chosen.

  • prefix rules: / inside a prefixed plugin registers both /prefix and /prefix/; a leading slash is de-duplicated when the prefix ends with one.
  • HEAD: a GET route also registers a HEAD route unless exposeHeadRoute: false; its onSend hook measures the payload for content-length and drops it.
  • duplicates: the same method and path with the same constraints throws FST_ERR_DUPLICATED_ROUTE at registration.
  • Context: one per route, holding handler, schema, config, the resolved hook arrays, the compiled validators and serializers, and the errorHandler link.

lib/handle-request.js and lib/hooks.js

lib/hooks.js holds the ten lifecycle arrays and the runners. One generator, hookRunnerGenerator, produces the runner used by most stages: call the next function, and if it returns a thenable wait for it, else expect done(). Three stages get their own runner because they pass a payload through: preParsing, preSerialization and onSend.

hooksignature (done-style)payload it receivescan end the request?
onRequest(request, reply, done)—yes: reply.send()
preParsing(request, reply, payload, done)the raw body stream; done(null, newStream) replaces ityes
preValidation(request, reply, done)request.body is parsed, not yet validatedyes
preHandler(request, reply, done)validated params, query, bodyyes
preSerialization(request, reply, payload, done)the handler's return value (objects only); done(null, newPayload) replaces itno
onSend(request, reply, payload, done)the serialised string or buffer; done(null, newPayload) replaces itno
onResponse(request, reply, done)the response is already writtenno
onError(request, reply, error, done)the error, before the error handler runsno — sending here throws
onTimeout(request, reply, done)fires when connectionTimeout elapsesno
onRequestAbort(request, done)the client closed the socket earlyno

lib/request.js and lib/reply.js

Request is a class with 21 prototype members; Reply has 31. Both are subclassed per scope so decorators stay inside the scope that declared them.

lib/request.js (excerpt)
function buildRegularRequest (R) {  const props = R.props.slice()  function _Request (id, params, req, query, log, context) {    this.id = id    this[kRouteContext] = context    this.params = params    this.raw = req    this.query = query    this.log = log    this.body = undefined     let prop    for (let i = 0; i < props.length; i++) {      prop = props[i]      this[prop.key] = prop.value    }  }  Object.setPrototypeOf(_Request.prototype, R.prototype)  Object.setPrototypeOf(_Request, R)  _Request.props = props  _Request.parent = R   return _Request
  1. L2R.props is the list of value decorators declared with decorateRequest. It is copied when the subclass is built, which is when the scope is created.
  2. L3Every request object starts with the same seven assignments in the same order — one V8 hidden class for all of them.
  3. L11Value decorators become own properties here, per request. Functions and getters go on the prototype instead (see lib/decorate.js).
  4. L16The subclass chains to the parent scope's class, so a method decorated in the parent is still found.

lib/plugin-override.js and lib/decorate.js

override(old, fn, opts) is what avvio calls before it runs a plugin. A plugin marked with Symbol.for('skip-override') — what fastify-plugin sets — gets the parent back; any other plugin gets a child built from it.

lib/plugin-override.js (excerpt)
  const instance = Object.create(old)  old[kChildren].push(instance)  instance.ready = old[kAvvioBoot].bind(instance)  instance[kChildren] = []   instance[kReply] = Reply.buildReply(instance[kReply])  instance[kRequest] = Request.buildRequest(instance[kRequest])   instance[kContentTypeParser] = ContentTypeParser.helpers.buildContentTypeParser(instance[kContentTypeParser])  instance[kHooks] = buildHooks(instance[kHooks])  instance[kRoutePrefix] = buildRoutePrefix(instance[kRoutePrefix], opts.prefix)
  1. L1The whole of encapsulation is this line: the child scope's prototype is the parent. Reads fall through; writes land on the child.
  2. L2The parent keeps a list of its children — addHook and addSchema walk it to push additions down.
  3. L6Each scope gets its own Reply and Request subclasses, so request decorators added here are invisible to sibling scopes.
  4. L10buildHooks copies the parent's hook arrays with slice() — a snapshot, kept current afterwards by propagation.
  5. L11The route prefix is joined once here; every route the plugin registers is stored under it.

lib/validation.js and lib/schema-controller.js

Schemas are plain objects until preReady. compileSchemasForValidation asks the scope's validatorCompiler (ajv by default) for one function per part — params, querystring, headers, body — and stores them on the Context. compileSchemasForSerialization does the same for each status code under response. The SchemaController holds the shared schemas added with addSchema and is copied per scope, so $refs resolve within the scope's view.

lib/four-oh-four.js, lib/error-handler.js, lib/errors.js

Not-found is a route: the 404 handler is stored as a wildcard context per prefix and runs through the scope's own hooks. Errors follow a chain built with Object.create, and every framework error is an instance created by @fastify/error with a stable code such as FST_ERR_VALIDATION.

lib/error-handler.js (excerpt)
function buildErrorHandler (parent = rootErrorHandler, func) {  if (!func) {    return parent  }   const errorHandler = Object.create(parent)  errorHandler.func = func  return errorHandler
  1. L1A scope's error handler is an object whose prototype is the parent scope's handler object — the same trick as the scopes themselves.
  2. L6When a custom handler throws, handleError moves to Object.getPrototypeOf(handler): the parent's handler, then the default at the top.

lib/content-type-parser.js

Body parsing is a table keyed by media type. The defaults are application/json and text/plain; addContentTypeParser adds to the scope's copy of the table. A body with no matching parser is answered 415 before preValidation runs; an empty body with a JSON content type is 400 FST_ERR_CTP_EMPTY_JSON_BODY.

The three engines

Three mechanisms do the work: a boot graph that runs plugins in order and creates scopes, a radix tree that turns a method and path into a route context, and a hook chain that runs the context's functions in a fixed order around the handler.

The model that runs in this page

Every figure that steps through a boot, a lookup or a request is running in your browser: a re-implementation of those three engines, small enough to read. It is checked against the installed package by building the same application twice and comparing the responses and the observed call order.

checkedcount
scenarios21
requests compared84
compared per requeststatus, content type, content length, payload, and the order of hook and handler calls
fastify version5.12.5

Where it stops — read this before taking a figure as the whole truth:

  • No sockets: inject() is the only transport, so keep-alive, timeouts, HTTP/2 and onTimeout/onRequestAbort never fire
  • Schema support covers type, required, properties, additionalProperties, enum, const, min/max, length, pattern, items, $ref and default — not the full JSON Schema draft
  • Version constraints accept exact and x-wildcard forms rather than semver ranges
  • No logging: request.log is a no-op; the pino child-logger lifecycle is modelled in the figures, not run
  • Route-level errorHandler, schemaErrorFormatter and validatorCompiler/serializerCompiler options are not supported
  • bodyLimit is recorded but not enforced, and there is no streaming body — the whole payload is a string
  • Trailing-slash handling implements ignoreTrailingSlash and prefixTrailingSlash "both"; ignoreDuplicateSlashes is not modelled
  • Content-type parsers receive the whole body as a string; parseAs "buffer" behaves as "string"

Anything the model cannot do is either shown from captured output of the real package instead (the generated serializer, the log lines, printRoutes), or measured and labelled as such. No figure invents behaviour.

avvio, the boot graph

avvio is a queue with one rule: a plugin's own registrations drain before the next sibling starts. register appends, after appends a callback that runs when everything queued before it has loaded, and ready drains the root. Every plugin start goes through override, which decides whether a new scope is created.

boot order
app.register(config)app.register(db)app.register(async function vouchers(scope, opts) {  scope.register(claimRoutes, { prefix: '/claim' })  scope.addHook('preHandler', rateLimit)})app.after(() => app.log.info('vouchers loaded'))await app.ready()
app.register(config)app.register(db)app.register(async function vouchers(scope: FastifyInstance, opts: Opts) {  scope.register(claimRoutes, { prefix: '/claim' })  scope.addHook('preHandler', rateLimit)})app.after(() => app.log.info('vouchers loaded'))await app.ready()
  1. L1register only queues. Nothing inside a plugin runs until the queue is drained, so app.register calls can be read as a list of what will happen, in order.
  2. L3When vouchers starts, override has just created its scope. The plugin body runs, its own registrations queue on the child, and they drain before after on line 7 runs — depth first.
  3. L5addHook for a lifecycle hook is itself deferred through after(): it lands at the end of this plugin's load and is pushed into claimRoutes, which already exists by then.
  4. L8ready() drains the root queue, emits preReady — hooks resolved per route, schemas compiled — then runs onReady hooks. After this, route, register and addHook throw.

find-my-way, the radix tree

Routes are inserted into a tree keyed by path characters, compressed so that a run of characters with no branch is one node. Lookup walks the tree with the request path and backtracks when a branch dead-ends. The page's model, FaLite, mirrors the priority and the backtracking; the real tree adds constraint stores, multi-parametric segments and a handler storage per method.

FaLite: the lookup walk
function walk(node, rest, params) {  if (rest.length === 0) return pick(node, method)  for (const child of node.statics) {    if (rest.startsWith(child.label)) {      const hit = walk(child, rest.slice(child.label.length), params)      if (hit) return hit    }  }  if (node.param) {    const seg = rest.slice(0, rest.indexOf('/') + 1 || rest.length)    const next = { ...params, [node.param.name]: seg }    const hit = walk(node.param, rest.slice(seg.length), next)    if (hit) return hit  }  if (node.wildcard) return pick(node.wildcard, method, { ...params, '*': rest })  return null}
  1. L2A node stores handlers per method. Reaching the end of the path with no handler for this method is a miss, not a 405 — fastify answers 404 either way.
  2. L3Static children first. Labels are compressed runs of characters, so /vouchers/ and /version share a /v node with two children.
  3. L9One parametric child per node, tried only after every static child failed — that is the whole priority rule, and it is structural rather than sorted.
  4. L14The wildcard consumes the rest of the path. The 404 contexts are stored on wildcard nodes, one per prefix.

the hook chain

A route's Context holds ten arrays, or null when a stage has no hooks. handle-request.js calls the runner for each populated stage and the handler in between. The runner is the same generated closure for every stage that does not carry a payload.

lib/hooks.js (excerpt)
function hookRunnerGenerator (iterator) {  return function hookRunner (functions, request, reply, cb) {    let i = 0     function next (err) {      if (err || i === functions.length) {        cb(err, request, reply)        return      }       let result      try {        result = iterator(functions[i++], request, reply, next)      } catch (error) {        cb(error, request, reply)        return      }      if (result && typeof result.then === 'function') {        result.then(handleResolve, handleReject)      }    }     function handleResolve () {      next()
  1. L3The chain state is one integer. Each hook that finishes calls next(); the array index moves by one.
  2. L5Any error — passed to done(err), thrown, or rejected — leaves the chain immediately and reaches cb(err), which becomes reply.send(err).
  3. L12A hook that returns a thenable is awaited; a hook that returns nothing must call done. Doing both is the classic double-continue bug, and fastify does not guard against it.

The request lifecycle

One request is a fixed sequence: lookup, then the ten hook stages around parsing, validation, the handler and serialisation. Every stage is skipped at zero cost when its array is null. The strip below steps through the stages a normal request visits; the two rose boxes are the exits.

stage 1 / 14
HTTP requestsocket bytesroutingfind-my-way lookuponRequesthookspreParsinghooksparsingcontent-type parserpreValidationhooksvalidationcompiled schema fnpreHandlerhookshandleryour route functionpreSerializationhooksserializationcompiled serializeronSendhooksresponseraw.end(payload)onResponsehooksan error at any stageonError hooks → error handler chain → onSend → onResponseno route matchedthe prefix's 404 context runs the same hooks with its handler

How a handler answers

There are exactly three ways for the handler to produce a response and one to opt out. Which one you use decides what wrapThenable does with the promise.

five ways to answer
app.get('/a', async () => ({ a: 1 }))app.get('/b', (req, reply) => { reply.code(201).send({ b: 2 }) })app.get('/c', async (req, reply) => { reply.send({ c: 3 }); return reply })app.get('/d', async (req, reply) => { reply.code(204); return null })app.get('/e', async () => { throw new Error('boom') })
app.get('/a', async (): Promise<{ a: number }> => ({ a: 1 }))app.get('/b', (req, reply) => { reply.code(201).send({ b: 2 }) })app.get('/c', async (req, reply) => { reply.send({ c: 3 }); return reply })app.get('/d', async (req, reply) => { reply.code(204); return null })app.get('/e', async (): Promise<never> => { throw new Error('boom') })
  1. L1Returning a value from an async handler is reply.send(value) — wrapThenable sends the resolved value. Objects go through serialisation; strings do not.
  2. L2A sync handler that calls reply.send and returns undefined: nothing more happens. Returning a value here as well would send twice.
  3. L3reply.send inside an async handler must be paired with return reply — otherwise the promise resolves to undefined and fastify tries to send again.
  4. L4null sends an empty body; with 204 the content-length header is omitted (measured).
  5. L5A rejection becomes reply.send(err): onError hooks, then the error handler chain, then the normal serialisation and onSend path.
about the responsehow it is decidedwhat that looks like
status code, normallyreply.code(n) before the payload is sentthe last call before sending wins
status code, on an errorread from err.statusCode when it is 400 or more, otherwise 500
  • a thrown Error500
  • statusCode: 418418
content type
  • an objectapplication/json; charset=utf-8
  • a stringtext/plain; charset=utf-8
  • a Bufferapplication/octet-stream
guessed from the payload, unless reply.type() set one
serialisationa compiled response schema for the status code if one exists, else JSON.stringifyschema drops undeclared properties and coerces '42' to 42
hijackreply.hijack() hands the raw response to youfastify writes nothing after it
not foundreply.callNotFound() re-routes into the prefix's 404 contextthe same stages run, with the not-found handler

What each payload kind skips

The type of the value decides how much of the send path runs. Only objects and null are serialised; strings, Buffers and streams go straight to onSend; hijack leaves the path entirely.

handler returnspreSerializationserialisedonSend receivescontent-type
an objectyesby the compiled serializer or JSON.stringifythe stringapplication/json; charset=utf-8
a stringskippedskippedthe same stringtext/plain; charset=utf-8
a Bufferskippedskippedthe Buffer itselfapplication/octet-stream
a streamskippedskippedthe stream objectwhatever reply.type() set; no content-length
nullyesto the four characters nullthat stringapplication/json; charset=utf-8
after reply.hijack()skippedskippednever runswhatever you write to reply.raw

null is a payload, not an absence: it serialises to the four characters null and both hook stages run. What empties a response is the status — a 204 drops the body and both content headers at write time. reply.send() with no argument is the actual absence: onSend receives undefined and the body is empty with content-length: 0 (all measured).

Leaving the chain early

A hook that calls reply.send() ends the request: the remaining hooks of that stage and every later stage up to onSend are skipped, and onResponse still runs. An error anywhere jumps to onError, then the error handler; whatever the handler sends goes through onSend and onResponse like any other reply.

Plugins and encapsulation

A plugin is a function of (scope, opts). Registering it queues it; running it creates a scope whose prototype is the parent's scope. Decorators, routes, hooks, schemas, parsers and the error handler added through that scope are private to it and to the scopes registered beneath it.

root instancedecorate(db), addHook(onRequest)plugin A (child)Object.create(root), prefix /vouchersplugin B (child)Object.create(root), decorate(cache)fastify-plugin pluginskip-override: same object as rootplugin A1 (grandchild)Object.create(A): sees db, not cacheregisterregisterregisterregistera decorator is looked up along the prototype chain: down is visible, sideways is not

Decorators, the DI layer

Decorators are how a plugin publishes something — a database pool, a config object, a per-request user. Three targets, three placement rules, and one asymmetry that matters:

decoratorlives onexistence checka child may shadow?visible to
decorate(name, v)the scope object (own property)Object.hasOwn — own onlyyesthis scope and its descendants, at request time and at load time once the plugin has run
decorateRequest(name, fn)the scope's Request prototypewhole chainno — FST_ERR_DEC_ALREADY_PRESENTevery request in this scope and its descendants
decorateRequest(name, value)the scope's props list → own property of each requestwhole chainnodescendants created after the declaration only — the list is copied at override
decorateRequest(name, { getter })the prototype, as an accessorwhole chainnoas a function
decorateReply(...)the same three rules on Reply———

Reference-type values ({}, []) are refused on request and reply with FST_ERR_DEC_REFERENCE_TYPE because they would be shared across requests; use null and assign in a hook, or a getter.

What order does and does not change

Two facts, both measured against the real package, decide most registration-order questions.

order: load time vs request time
app.register(async function early(scope) {  scope.hasDecorator('db')          // false — db has not loaded yet  scope.get('/x', async function () { return this.hasDecorator('db') })})app.register(fp(async function dbPlugin(scope) { scope.decorate('db', pool) }))app.register(fp(async function lateHook(scope) {  scope.addHook('onRequest', audit) // applies to /x as well}))
app.register(async function early(scope: FastifyInstance) {  scope.hasDecorator('db')          // false — db has not loaded yet  scope.get('/x', async function () { return this.hasDecorator('db') })})app.register(fp(async function dbPlugin(scope) { scope.decorate('db', pool) }))app.register(fp(async function lateHook(scope) {  scope.addHook('onRequest', audit) // applies to /x as well}))
  1. L2Read during the plugin body, before dbPlugin has loaded: false. Order matters for anything a plugin reads while it loads.
  2. L3Read during a request: true. The lookup walks the prototype chain at call time, and by then every plugin has loaded. Measured in the conformance run.
  3. L5fp is fastify-plugin: it marks the function so override returns the parent, and decorate lands on the root object.
  4. L7A hook added to the root after early loaded still reaches /x: _addHook pushes it into every existing child. Hooks are never order-sensitive; load-time reads are.

fastify-plugin

fastify-plugin sets Symbol.for('skip-override') on the function and optionally records a name and version. With the flag set, override returns the parent object, so everything the plugin adds — decorators, hooks, schemas — lands one level up. Use it for capabilities the whole application shares; leave it off for features that own routes.

Putting it together

From fastify() to a served request there are six moments, and only the last two repeat. The listing is the event trace the in-page engine records while booting the voucher app; the figures in the breakdown step through it.

register()queue pluginsready()drain the queue, depth-firstpreReadyresolve hooks, compile schemaslisten()server.js creates the serverrequestlookup → hooks → handlerclose()preClose, onClose in reverseeverything left of "request" runs once per process; everything from "request" runs once per request
what the boot actually does
register  config          (skip-override)register  db              (skip-override)register  vouchers        prefix /vouchersboot-startoverride  config          → same object as rootdecorate  instance        configoverride  db              → same object as rootdecorate  instance        dboverride  vouchers        → scope-1, prefix /vouchersroute     POST /vouchers/claimroute     GET  /vouchers/:codehook-added  preHandler    rateLimit → scope-1preReadyhooks-resolved  POST /vouchers/claim  onRequest 1, preHandler 2hooks-resolved  GET  /vouchers/:code  onRequest 1, preHandler 1onReadyready

The preReady moment is the boundary the whole design leans on: after it, every route knows its hooks and holds compiled functions, and route, register and addHook throw AVV_ERR_ROOT_PLG_BOOTED — avvio refuses a booted root (measured). decorate still works; after listen() the remaining registration methods throw FST_ERR_INSTANCE_ALREADY_LISTENING.

Routing internals

One tree holds every route of every scope; the scope's prefix is joined into the path at registration, so the tree never knows about scopes. The 404 is the exception — it is stored per prefix as a wildcard context.

/rootvouchers/statichealthstatic, GETadmin/staticclaimstatic, POST:codeparam, GET*wildcard, 404/statsstatic, GETat each node: static children first, then the one param child, then the wildcard — the first full match wins

What registration writes

you writefastify registerswhy
scope.get('/') with prefix /apiGET /api and GET /api/prefixTrailingSlash defaults to 'both'
scope.get('/items') with prefix /apiGET /api/items only/api/items/ is a 404 unless ignoreTrailingSlash: true
app.get('/x')GET /x and HEAD /xexposeHeadRoutes defaults to true; the HEAD route drops the body in onSend
app.get('/x') twiceFST_ERR_DUPLICATED_ROUTE at registrationsame method, path and constraints
app.route({ method: ['GET', 'POST'] })one route per method, one context eachroute() loops over the array

Lookup, priority and backtracking

At every node the order is fixed: static children, then the parametric child, then the wildcard. If a static branch matches the next characters but dead-ends further down, the walk returns and tries the parametric child from the same node — so /a/:x/c still answers /a/b/c when /a/b/d exists (measured). Route count does not change the walk: the number of nodes visited depends on the path, not on how many other routes exist.

Constraints

A constraint is a second key for the same method and path. find-my-way derives the value from the request with a strategy — host and version are built in, addConstraintStrategy adds your own — and keeps constrained handlers in a store beside the plain one.

constraints
app.get('/c', { constraints: { host: 'admin.example.com' } },  async () => ({ who: 'admin' }))app.get('/c', async () => ({ who: 'anyone' }))app.get('/v', { constraints: { version: '1.2.0' } }, async () => ({ v: 1 }))app.get('/v', { constraints: { version: '2.0.0' } }, async () => ({ v: 2 }))
app.get('/c', { constraints: { host: 'admin.example.com' } },  async (): Promise<Who> => ({ who: 'admin' }))app.get('/c', async (): Promise<Who> => ({ who: 'anyone' }))app.get('/v', { constraints: { version: '1.2.0' } }, async () => ({ v: 1 }))app.get('/v', { constraints: { version: '2.0.0' } }, async () => ({ v: 2 }))
  1. L1Constrained handlers are tried before the unconstrained one at the same node. host matches the Host header exactly or by regex.
  2. L3With an unconstrained sibling, a request from any other host still resolves. Without one, a non-matching host is a 404.
  3. L4version reads Accept-Version; 1.x selects 1.2.0 (measured). A request without the header hits neither version route → 404.

Asking the router what it knows

Four methods read the structures back out, all of them at registration or afterwards, none of them per request.

callanswersmeasured on a five-route app
printRoutes()the tree as a stringcompressed node labels, the methods held at each node, and any constraint object
printRoutes({ commonPrefix: false })the same routes as full pathseasier to scan, but no longer the shape the lookup walks
printRoutes({ includeHooks: true })each route with its resolved hookswhere the HEAD twin's headRouteOnSendHandler() becomes visible
hasRoute({ method, url, constraints })whether that route pattern existstrue for a registered path and for its HEAD twin; false for a constrained route queried without its constraints
findRoute({ method, url })the route's handler, params and searchParams, or nullthe registration-time view — parameter names, not values
getDecorator(name)the decorator valuethrows FST_ERR_DEC_UNDECLARED when the name was never declared, where property access would return undefined

request.getDecorator and request.setDecorator are the same idea on the request: they read and write a declared request decorator and throw the same error for an undeclared name (measured). hasPlugin(name) answers whether a named plugin registered on this scope's chain.

Not found, per prefix

setNotFoundHandler in a plugin with a prefix installs a 404 context for that prefix: a wildcard route whose hooks are the scope's hooks and whose handler is yours. A miss under /api runs the /api handler; a miss elsewhere runs the root one, whose default payload is { message, error, statusCode } — three keys, no code (measured).

Schema to function

A schema on a route is an instruction to build two kinds of function at boot: validators for the incoming parts, and a serializer per response status. At request time only the functions run; the schema object is never consulted again.

JSON schemaroute option, plain objectajv.compile()at preReady, oncefast-json-stringify()at preReady, oncevalidate(data)a generated functionserialize(obj)a generated functionrequestcallsthe schema object is never read at request time — only the functions built from it are called
schemas on a route
app.addSchema({  $id: 'money',  type: 'object',  required: ['amount', 'currency'],  properties: {    amount: { type: 'integer', minimum: 1 },    currency: { type: 'string', enum: ['ZAR', 'USD'] },  },}) app.post('/claim', {  schema: {    body: {      type: 'object',      required: ['code'],      properties: {        code: { type: 'string', minLength: 4 },        money: { $ref: 'money#' },      },      additionalProperties: false,    },    response: { 200: { type: 'object', properties: { code: { type: 'string' } } } },  },}, async (req) => ({ code: req.body.code, leak: 'never sent' }))
app.addSchema({  $id: 'money',  type: 'object',  required: ['amount', 'currency'],  properties: {    amount: { type: 'integer', minimum: 1 },    currency: { type: 'string', enum: ['ZAR', 'USD'] },  },} as const) app.post<{ Body: ClaimBody }>('/claim', {  schema: {    body: {      type: 'object',      required: ['code'],      properties: {        code: { type: 'string', minLength: 4 },        money: { $ref: 'money#' },      },      additionalProperties: false,    },    response: { 200: { type: 'object', properties: { code: { type: 'string' } } } },  },}, async (req) => ({ code: req.body.code, leak: 'never sent' }))
  1. L1addSchema stores a shared schema on the scope's SchemaController and pushes it to existing children; a later $ref: "money#" resolves against it at compile time.
  2. L11Each part under schema compiles to its own function at preReady: body, querystring, params, headers.
  3. L19additionalProperties: false plus fastify's removeAdditional default means extra keys are stripped, not rejected.
  4. L21A response schema is keyed by status code. The compiled serializer only writes declared properties — leak never leaves the process.
  1. L1addSchema stores a shared schema on the scope's SchemaController and pushes it to existing children; a later $ref: "money#" resolves against it at compile time.
  2. L11The generic on post types req.body; with a type provider the type is derived from the schema instead of declared twice.
  3. L19additionalProperties: false plus fastify's removeAdditional default means extra keys are stripped, not rejected.
  4. L21A response schema is keyed by status code. The compiled serializer only writes declared properties — leak never leaves the process.

Validation: ajv with four defaults

ajv optionfastify defaulteffect you see
coerceTypes'array'?dry=true becomes true, /n/7 becomes 7, and a single value can satisfy an array schema
useDefaultstruea missing property with default is filled in on the parsed object
removeAdditionaltruewith additionalProperties: false, unknown properties are deleted rather than rejected
allErrorsfalsevalidation stops at the first error — one message per failed request

The default error formatter joins the part name, the instance path and ajv's message:

body or params sentmessage the client receives
{ code: 12 }body/code must be string
{}body must have required property 'code'
{ code: 'AB' } with minLength 4body/code must NOT have fewer than 4 characters
{ money: { amount: 0 } } via $refbody/money/amount must be >= 1
/n/seven with integer paramparams/id must be integer

The response is 400 with { statusCode, code: "FST_ERR_VALIDATION", error: "Bad Request", message }; attachValidation: true on the route puts the error on request.validationError instead and lets the handler decide.

Serialisation: a generated function per status

fast-json-stringify emits JavaScript source from the schema and evaluates it once. The function writes property by property, coerces to the declared type, and skips anything the schema does not name. This is the captured output for the voucher payload used in the measurements:

fast-json-stringify output (captured, trimmed)
function anonymous0 (input) {        const obj = (input && typeof input.toJSON === 'function')    ? input.toJSON()    : input          if (obj === null) return JSON_STR_EMPTY_OBJECT        let json = ''         json += JSON_STR_BEGIN_OBJECTlet addComma_0 = false           const value_id_1 = obj["id"]          if (value_id_1 !== undefined) {            !addComma_0 && (addComma_0 = true) || (json += JSON_STR_COMMA)            json += "\"id\":"            json += asInteger(value_id_1)          }           const value_code_2 = obj["code"]          if (value_code_2 !== undefined) {            !addComma_0 && (addComma_0 = true) || (json += JSON_STR_COMMA)            json += "\"code\":"

Shared schemas and $ref

addSchema is scope-aware: the SchemaController is rebuilt per scope with the parent's store as its base, and addSchema on a parent walks kChildren so children already created still see it. getSchema(id) and getSchemas() read the scope's view. Compilers see the same store, which is how a $ref to a schema added in a parent resolves inside a plugin.

Why it is fast, measured

Everything below was measured on this container — 1 vCPU Intel(R) Xeon(R) Processor @ 2.10GHz, Node v22.22.2, fastify 5.12.5 — and the load generator shared that one vCPU with the server. In-process numbers are trustworthy to a few percent; the throughput rows are not a ranking.

Work moved to boot

The design decision is to spend time at preReady so that a request does not: hooks resolved to arrays, schemas compiled to functions, routes inserted into a tree, prefixes joined into paths, request classes fixed per scope.

serialising one voucher objectns per callreading
JSON.stringify(obj)391.8the baseline: reflection over keys at call time
stringify(obj) from fast-json-stringify340.913% less for this six-property object; the gap grows with nesting and shrinks with tiny payloads
validating one bodyns per callreading
validate(body) compiled once at boot62.3what fastify does per request
hand-written checks4.5the floor: a few comparisons
new Ajv().compile(schema)(body) per request8,100,425130,023× slower — compile is a code generator; doing it per request is the mistake compile-at-boot exists to prevent

The router does not care how many routes exist

routes registeredfind-my-way lookuplinear regex scan, worst case
10228.4 ns292 ns
100295.3 ns2,460 ns
1000295.6 ns88,188 ns

The tree walk is bounded by the path length, so lookup stays flat from 10 to 1,000 routes. The linear scan is the mechanism a route table implemented as an ordered list of patterns uses; its cost grows with the number of routes registered before the one that matches.

What hooks cost

async no-op onRequest hooksµs per inject()
040.7
238.3
540.5
1038.4

Ten async no-op hooks are indistinguishable from none at this resolution: each is one promise resolution on the microtask queue. What a hook does — a database call, a JSON parse — costs; the mechanism does not.

Throughput, with the caveat

serverreq/savg latencyp99
raw node http28,4931.22 ms3 ms
fastify, no schema26,1151.28 ms4 ms
fastify + response schema28,4401.15 ms3 ms
fastify + 5 async onRequest hooks29,6931.12 ms2 ms

Load generator and server share the same vcpu; treat differences under ~15% as noise. All four sit within that band: on one shared core the framework is not the bottleneck, which is itself the useful reading — the request path adds no measurable overhead over a hand-written http.createServer handler for this payload.

What boot costs

encapsulated plugins, one route eachready() time
1015.8 ms
100125.9 ms
500494.9 ms

Roughly 1.0 ms per encapsulated plugin at the high end: a scope, two subclasses, a hook snapshot, a route insert and a schema controller each. It is paid once.

Production architecture: the voucher system

The same voucher flow — claim, validate, rate limit, generate, persist, notify — arranged the way the framework wants it: shared capabilities as fastify-plugin plugins that decorate, features as encapsulated plugins with a prefix, schemas on every route, hooks at the scope that needs them, and an error handler per scope.

app.jsfastify() + registerconfig pluginfp, decorate(config)db pluginfp, decorate(db), onClosewhatsapp pluginfp, decorate(whatsapp)auth pluginfp, decorateRequest(user)vouchers pluginencapsulated, prefix /vouchersadmin pluginencapsulated, prefix /adminPOST /claimschema, preHandler, handlerserviceplain moduleerror handlerscope-level, domain errors404 handlerper prefixfastify-plugin for capabilities the whole app shares; encapsulated plugins for features that own routes
app.js
import Fastify from 'fastify'import config from './plugins/config.js'import db from './plugins/db.js'import whatsapp from './plugins/whatsapp.js'import auth from './plugins/auth.js'import vouchers from './routes/vouchers.js'import admin from './routes/admin.js' export function build(opts = {}) {  const app = Fastify({ logger: true, ...opts })  app.register(config)  app.register(db)  app.register(whatsapp)  app.register(auth)  app.register(vouchers, { prefix: '/vouchers' })  app.register(admin, { prefix: '/admin' })  app.setNotFoundHandler((req, reply) => {    reply.code(404).send({ error: 'not_found', path: req.url })  })  return app}
import Fastify, { type FastifyServerOptions } from 'fastify'import config from './plugins/config.js'import db from './plugins/db.js'import whatsapp from './plugins/whatsapp.js'import auth from './plugins/auth.js'import vouchers from './routes/vouchers.js'import admin from './routes/admin.js' export function build(opts: FastifyServerOptions = {}) {  const app = Fastify({ logger: true, ...opts })  app.register(config)  app.register(db)  app.register(whatsapp)  app.register(auth)  app.register(vouchers, { prefix: '/vouchers' })  app.register(admin, { prefix: '/admin' })  app.setNotFoundHandler((req, reply) => {    reply.code(404).send({ error: 'not_found', path: req.url })  })  return app}
  1. L9build() returns an un-listened instance so tests can inject into the same object the server would run. server.js calls build().listen().
  2. L11The first four are fastify-plugin plugins: they decorate the root and load in this order, so auth can read config while it loads.
  3. L15vouchers is encapsulated with a prefix: its hooks, error handler and 404 stay inside /vouchers.
  4. L17The root 404 handler covers everything the prefixed plugins do not claim.

Capabilities as decorators

Each shared capability is one file: it connects, decorates, and registers its own onClose. The dependency between them is declared, checked at load, and consumed through this.

plugins/db.js
import fp from 'fastify-plugin'import { Pool } from 'pg' export default fp(async function db(app, opts) {  const pool = new Pool({ connectionString: app.config.DATABASE_URL })  await pool.query('select 1')  app.decorate('db', pool)  app.addHook('onClose', async () => { await pool.end() })}, { name: 'db', dependencies: ['config'] })
import fp from 'fastify-plugin'import { Pool } from 'pg' declare module 'fastify' { interface FastifyInstance { db: Pool } } export default fp(async function db(app, opts) {  const pool = new Pool({ connectionString: app.config.DATABASE_URL })  await pool.query('select 1')  app.decorate('db', pool)  app.addHook('onClose', async () => { await pool.end() })}, { name: 'db', dependencies: ['config'] })
  1. L4fp makes this plugin write to the root scope. dependencies is checked at load: if config has not registered, fastify throws with the plugin names.
  2. L5app.config exists because config loaded first — a load-time read, so order matters here.
  3. L7decorate publishes the pool. Every handler in every scope reads this.db or req.server.db through the prototype chain.
  4. L8onClose hooks run in reverse registration order at close(): the pool ends after the plugins registered later have closed.
  1. L4Declaration merging tells TypeScript about the decorator; without it app.db is a type error even though it exists at runtime.
  2. L6fp makes this plugin write to the root scope. dependencies is checked at load: if config has not registered, fastify throws with the plugin names.
  3. L9decorate publishes the pool. Every handler in every scope reads this.db or req.server.db through the prototype chain.
  4. L10onClose hooks run in reverse registration order at close(): the pool ends after the plugins registered later have closed.

The claim route

routes/vouchers.js
import { claimVoucher, VoucherError } from '../services/vouchers.js' export default async function vouchers(app, opts) {  app.addHook('preHandler', app.rateLimit({ max: 5, window: '1 minute' }))   app.post('/claim', {    schema: {      body: { $ref: 'claimBody#' },      response: { 201: { $ref: 'voucher#' } },    },  }, async function (req, reply) {    const voucher = await claimVoucher(this.db, req.user, req.body)    await this.whatsapp.send(req.user.phone, voucher.code)    reply.code(201)    return voucher  })   app.setErrorHandler((err, req, reply) => {    if (err instanceof VoucherError) {      return reply.code(err.status).send({ error: err.kind })    }    throw err  })}
import { claimVoucher, VoucherError } from '../services/vouchers.js'import type { FastifyPluginAsync } from 'fastify' const vouchers: FastifyPluginAsync = async (app, opts) => {  app.addHook('preHandler', app.rateLimit({ max: 5, window: '1 minute' }))   app.post<{ Body: ClaimBody; Reply: Voucher }>('/claim', {    schema: {      body: { $ref: 'claimBody#' },      response: { 201: { $ref: 'voucher#' } },    },  }, async function (req, reply) {    const voucher = await claimVoucher(this.db, req.user, req.body)    await this.whatsapp.send(req.user.phone, voucher.code)    reply.code(201)    return voucher  })   app.setErrorHandler((err, req, reply) => {    if (err instanceof VoucherError) {      return reply.code(err.status).send({ error: err.kind })    }    throw err  })}export default vouchers
  1. L4A scope-level preHandler: every route in this plugin — and in any plugin registered beneath it — is rate limited. Routes outside /vouchers are not.
  2. L6Both schemas are shared (addSchema in config), so the same shape validates here and serialises in the admin plugin.
  3. L11A plain function so this is the scope: this.db and this.whatsapp resolve through the prototype chain to the root decorators.
  4. L12claimVoucher is a plain module that takes its dependencies as arguments — the service layer never imports fastify.
  5. L18The scope's error handler answers domain errors; rethrowing hands anything else to the parent handler, which is the default one here.
  1. L4FastifyPluginAsync types the (app, opts) signature; an options interface can be passed as its generic.
  2. L5A scope-level preHandler: every route in this plugin — and in any plugin registered beneath it — is rate limited. Routes outside /vouchers are not.
  3. L7The route generic names the body and reply types; a type provider derives them from the schema instead (see below).
  4. L12A plain function so this is the scope: this.db and this.whatsapp resolve through the prototype chain to the root decorators.
  5. L19The scope's error handler answers domain errors; rethrowing hands anything else to the parent handler, which is the default one here.

Errors and not-found, per scope

Domain errors are classes with a status and a kind; the scope's error handler turns them into responses and rethrows everything else. onError at the root logs every error once, before any handler runs. The 404 is answered by the closest prefix: /vouchers/nope by the vouchers scope if it set one, otherwise by the root handler in app.js.

Tests through inject

test/claim.test.js
import { test } from 'node:test'import assert from 'node:assert/strict'import { build } from '../app.js' test('claims a voucher', async () => {  const app = build({ logger: false })  app.decorate('whatsapp', { async send() {} })  await app.ready()  const res = await app.inject({    method: 'POST', url: '/vouchers/claim',    headers: { authorization: 'Bearer test' },    payload: { campaign: 'SPRING' },  })  assert.equal(res.statusCode, 201)  assert.match(res.json().code, /^[A-Z0-9]{8}$/)  await app.close()})
import { test } from 'node:test'import assert from 'node:assert/strict'import { build } from '../app.js' test('claims a voucher', async () => {  const app = build({ logger: false })  app.decorate('whatsapp', { async send(): Promise<void> {} })  await app.ready()  const res = await app.inject({    method: 'POST', url: '/vouchers/claim',    headers: { authorization: 'Bearer test' },    payload: { campaign: 'SPRING' },  })  assert.equal(res.statusCode, 201)  assert.match(res.json().code, /^[A-Z0-9]{8}$/)  await app.close()})
  1. L7Decorating before ready() wins the name: the real whatsapp plugin would throw FST_ERR_DEC_ALREADY_PRESENT, so the test build registers it conditionally or the plugin checks hasDecorator first.
  2. L9inject builds a request with light-my-request and runs the full pipeline — routing, hooks, validation, serialisation — without a socket.
  3. L15res.json() parses the serialised payload; res.headers and res.statusCode are exactly what a client would see.

Logging

pino is the only logger fastify knows. logger: true creates the root logger; every request gets a child with the request id bound, and the two automatic lines — incoming and completed — are written by lib/log-controller.js.

whatwhere it happensshape
request idgenReqId at request creation, default an incrementing counter per processreq.id, returned as request-id header only if you set it
request loggerlogger.child({ reqId }) built by childLoggerFactoryreq.log and reply.log are the same child
incoming linelogged on request creation by log-controller.js{ reqId, req: { method, url, host, remoteAddress } }
completed lineonResponse time, with reply.elapsedTime{ reqId, res: { statusCode }, responseTime }
level per scopelogLevel as a register option or route optiona scope can log at debug while the app logs at info

Types from the schema

types from schemas
import { Type } from '@sinclair/typebox' export const ClaimBody = Type.Object({  campaign: Type.String({ minLength: 2 }),  note: Type.Optional(Type.String()),})
import { Type, type Static } from '@sinclair/typebox'import type { TypeBoxTypeProvider } from '@fastify/type-provider-typebox' export const ClaimBody = Type.Object({  campaign: Type.String({ minLength: 2 }),  note: Type.Optional(Type.String()),})export type ClaimBody = Static<typeof ClaimBody> const app = Fastify().withTypeProvider<TypeBoxTypeProvider>()app.post('/claim', { schema: { body: ClaimBody } }, async (req) => {  req.body.campaign      // string — inferred from the schema})
  1. L3Type.Object returns a plain JSON schema object; fastify compiles it exactly as a hand-written one.
  1. L8Static derives the TypeScript type from the same object, so the runtime check and the compile-time type cannot drift apart.
  2. L10withTypeProvider tells the route generics to read Body, Querystring, Params and Reply types off the schema.
  3. L12No as, no manual generic: the handler's req.body is typed from the validated shape.

Shutdown

shutdown
app.addHook('preClose', async () => { app.log.info('draining') })app.addHook('onClose', async (instance) => { await instance.db.end() }) process.on('SIGTERM', async () => {  await app.close()  process.exit(0)})
app.addHook('preClose', async () => { app.log.info('draining') })app.addHook('onClose', async (inst: FastifyInstance) => { await inst.db.end() }) process.on('SIGTERM', async () => {  await app.close()  process.exit(0)})
  1. L1preClose runs before the server stops accepting; keep-alive connections are closed by fastify between preClose and onClose.
  2. L2onClose hooks run last-registered first, so a plugin that depends on db closes before db does.
  3. L5close() resolves when every onClose has finished; the process exits only then.

The final model

boot phaseplugins → scopes → routes → compiled fnsone radix treeevery route of every scoperoute contextshooks, validators, serializers, handlera requestlookup → context → hook chainyour handlerreturn a value or use replyresponseserialize → onSend → end
  • Boot is a queue of plugins drained depth-first; each plugin runs against a scope that is Object.create(parent).
  • Decorators publish through the prototype chain; a child sees the parent's, a sibling does not, and a load-time read sees only what has already loaded.
  • Hooks are arrays copied down at scope creation and pushed down afterwards; a route's arrays are fixed at preReady.
  • Schemas become functions at preReady: validators per part, a serializer per status.
  • Routing is one radix tree for every scope, prefixes joined at registration, 404 contexts per prefix.
  • A request is: lookup, ten stages around your handler, and one of three ways to answer.
  • Errors follow a second prototype chain of handlers, from the scope that failed up to the default.

Every breakdown

Back to JIT-Atlas