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 # 84Weight 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
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.
| package | version | files | lines | what fastify delegates to it |
|---|---|---|---|---|
avvio | 9.3.0 | 12 | 803 | the boot graph: register, after, ready, close |
find-my-way | 9.9.0 | 11 | 2,072 | the router: a radix tree, constraints, params |
@fastify/ajv-compiler | 4.0.6 | 4 | 152 | validation: wraps ajv 8.20.0 with fastify's defaults |
fast-json-stringify | 7.0.1 | 7 | 2,743 | serialisation: a generated function per response schema |
pino | 10.3.1 | 15 | 2,196 | logging: request.log is a pino child logger |
light-my-request | 6.6.0 | 6 | 1,769 | inject(): a request without a socket |
@fastify/error | 4.2.0 | 1 | 100 | error 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.
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
- 69registerafterreadylisteninjectroutegetpostdecorateaddHookaddSchemasetErrorHandlerand 57 more
- symbol slots
- 32statekeepAliveConnectionsacceptedHTTPMethodsoptionschildrenserverBindingsand 26 more
- prototype chain
Object.prototype12 members
Object.create(parent)- own properties
- 4readygetSchemagetSchemasdb
- symbol slots
- 13childrenReplyRequestcontentTypeParserhooksroutePrefixand 7 more
- prototype chain
- the root instance69 own
Object.prototype12 members
Object.create(child)- own properties
- 3 — the same three that every scope re-creates
- prototype chain
- the child scope4 ownthe root instance69 own
Object.prototype12 members
- own properties
- 8idparamsrawquerylogbodytenantuser
- prototype chain
_Request1 own_Request1 own_Request1 ownRequest.prototype21 membersObject.prototype12 members
- own properties
- 3rawrequestlog
- 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.
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}
- L2
serveris the raw Node server afterlisten;prefixandpluginNameare per scope — a child sees its own values through its own properties, everything else through the chain. - L9
register,afterandreadyreturn the instance (this) and are thenable, which is what letsawait app.register(x)work. Everything is queued untilreadydrains it. - L16Every route method returns the instance. The shorthand also accepts
(url, handler)with no options object; both forms end inroute(opts). - L22Three decorate methods, one existence-check rule each;
hasDecoratorwalks the chain, so it answers for a parent's decorator too. - L28Schema methods write to this scope's
SchemaController; the compiler setters replace the compiler for this scope's routes only. - L34
setErrorHandlerandsetNotFoundHandlerinstall per scope.printRoutesrenders the radix tree — the same shape the tree figures draw.
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}
- L2The own properties: assigned in the constructor in this order for every request.
bodyisundefineduntil parsing runs;paramsandqueryexist from routing. - L10
validationErroris only set when the route hasattachValidation: true; otherwise a failed validation replies 400 before the handler. - L12Getters on
Request.prototype:urlandmethodread fromraw;hostfalls back to the HTTP/2:authoritypseudo-header;iphonourstrustProxy. - L24
routeOptionsis the matched route's frozen options:method,url,config,schema,constraints,handler. - L26The schema helpers reach the scope's compiled functions or compile ad hoc;
validateInputreturns a boolean and fillsvalidationError.
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}
- L2Three own properties (
raw,request,log) — measured — andstatusCode, whichcode()sets.sentflips when the write starts; hooks read it to stop early. - L11Status and header methods return the reply (
this), soreply.code(201).header(...).send(...)is one expression.type()disables the content-type guessing thatsenddoes. - L22
sendis the single exit for every payload kind;redirectsetslocationand sends an empty body;hijackhandsrawto you and skips the rest of the pipeline. - 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. - L35
thenmakesawait replyresolve when the response is sent — the same mechanism that lets a handlerreturn reply.
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
- L4The handler's
thisis the scope, which is why a plainfunctioncan readthis.db. Its return value is whatwrapThenablesends. - L6Five schema parts. The four incoming ones compile to validators,
responseto one serializer per status key (200,2xx,default). - L9
constraintsis a second key on the same method and path; the values are matched by the router's strategies. - L16Route-level hooks run after the scope's hooks of the same stage. Arrays are allowed;
onTimeoutandonRequestAbortare route options too. - L24A route-level
errorHandlersits before the scope's chain; the compiler and formatter options override the scope's for this route only. - L31A plugin is a function of
(instance, opts); the async form resolves when its body is done, the callback form callsdone. 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.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.
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 })
- L3
Fastify(options)runs the factory infastify.js: it validates the options once, builds the root instance, wires avvio to it and creates the router. Nothing listens yet. - L5
app.getregisters a route synchronously into the radix tree and creates its Context. Its hooks and schemas are resolved later, at preReady. - L7
listen()boots first — ready() drains every plugin — thenlib/server.jscreates the Node server and binds the port.
- L3
FastifyInstanceis the type of the root object and of every scope a plugin receives; the generic parameters carry the server, request and reply types. - L5
app.getregisters a route synchronously into the radix tree and creates its Context. Its hooks and schemas are resolved later, at preReady. - L7
listen()boots first — ready() drains every plugin — thenlib/server.jscreates 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.
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 })
- L5
http2: truemakeslib/server.jscallhttp2.createSecureServerwhenhttpsis present, orhttp2.createServerfor cleartext h2c. - L7
allowHTTP1keeps HTTP/1.1 clients working on the same port — the same handler serves both, because request and reply wrap either kind of stream. - L14
serverFactoryreceives fastify's request handler and must return something withlistenandclose; fastify never creates a server itself in that case.
- L5
http2: truemakeslib/server.jscallhttp2.createSecureServerwhenhttpsis present, orhttp2.createServerfor cleartext h2c. - L7
allowHTTP1keeps HTTP/1.1 clients working on the same port — the same handler serves both, because request and reply wrap either kind of stream. - L13
FastifyServerFactorytypes the(handler, opts)pair: the handler is fastify's request listener, and the return value must offerlistenandclose.
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/prefixand/prefix/; a leading slash is de-duplicated when the prefix ends with one. - HEAD: a
GETroute also registers aHEADroute unlessexposeHeadRoute: false; its onSend hook measures the payload forcontent-lengthand 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.
| hook | signature (done-style) | payload it receives | can end the request? |
|---|---|---|---|
| onRequest | (request, reply, done) | — | yes: reply.send() |
| preParsing | (request, reply, payload, done) | the raw body stream; done(null, newStream) replaces it | yes |
| preValidation | (request, reply, done) | request.body is parsed, not yet validated | yes |
| preHandler | (request, reply, done) | validated params, query, body | yes |
| preSerialization | (request, reply, payload, done) | the handler's return value (objects only); done(null, newPayload) replaces it | no |
| onSend | (request, reply, payload, done) | the serialised string or buffer; done(null, newPayload) replaces it | no |
| onResponse | (request, reply, done) | the response is already written | no |
| onError | (request, reply, error, done) | the error, before the error handler runs | no — sending here throws |
| onTimeout | (request, reply, done) | fires when connectionTimeout elapses | no |
| onRequestAbort | (request, done) | the client closed the socket early | no |
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.
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
- L2
R.propsis the list of value decorators declared withdecorateRequest. It is copied when the subclass is built, which is when the scope is created. - L3Every request object starts with the same seven assignments in the same order — one V8 hidden class for all of them.
- L11Value decorators become own properties here, per request. Functions and getters go on the prototype instead (see
lib/decorate.js). - 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.
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)
- L1The whole of encapsulation is this line: the child scope's prototype is the parent. Reads fall through; writes land on the child.
- L2The parent keeps a list of its children —
addHookandaddSchemawalk it to push additions down. - L6Each scope gets its own Reply and Request subclasses, so request decorators added here are invisible to sibling scopes.
- L10
buildHookscopies the parent's hook arrays withslice()— a snapshot, kept current afterwards by propagation. - 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.
function buildErrorHandler (parent = rootErrorHandler, func) { if (!func) { return parent } const errorHandler = Object.create(parent) errorHandler.func = func return errorHandler
- L1A scope's error handler is an object whose prototype is the parent scope's handler object — the same trick as the scopes themselves.
- L6When a custom handler throws,
handleErrormoves toObject.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.
| checked | count |
|---|---|
| scenarios | 21 |
| requests compared | 84 |
| compared per request | status, content type, content length, payload, and the order of hook and handler calls |
| fastify version | 5.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.
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()
- L1
registeronly queues. Nothing inside a plugin runs until the queue is drained, soapp.registercalls can be read as a list of what will happen, in order. - L3When
vouchersstarts,overridehas just created its scope. The plugin body runs, its own registrations queue on the child, and they drain beforeafteron line 7 runs — depth first. - L5
addHookfor a lifecycle hook is itself deferred throughafter(): it lands at the end of this plugin's load and is pushed intoclaimRoutes, which already exists by then. - L8
ready()drains the root queue, emits preReady — hooks resolved per route, schemas compiled — then runs onReady hooks. After this,route,registerandaddHookthrow.
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.
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}
- 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.
- L3Static children first. Labels are compressed runs of characters, so
/vouchers/and/versionshare a/vnode with two children. - 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.
- 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.
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()
- L3The chain state is one integer. Each hook that finishes calls
next(); the array index moves by one. - L5Any error — passed to
done(err), thrown, or rejected — leaves the chain immediately and reachescb(err), which becomesreply.send(err). - 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.
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.
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') })
- L1Returning a value from an async handler is
reply.send(value)—wrapThenablesends the resolved value. Objects go through serialisation; strings do not. - L2A sync handler that calls
reply.sendand returnsundefined: nothing more happens. Returning a value here as well would send twice. - L3
reply.sendinside an async handler must be paired withreturn reply— otherwise the promise resolves toundefinedand fastify tries to send again. - L4
nullsends an empty body; with204thecontent-lengthheader is omitted (measured). - L5A rejection becomes
reply.send(err): onError hooks, then the error handler chain, then the normal serialisation and onSend path.
| about the response | how it is decided | what that looks like |
|---|---|---|
| status code, normally | reply.code(n) before the payload is sent | the last call before sending wins |
| status code, on an error | read from err.statusCode when it is 400 or more, otherwise 500 |
|
| content type |
| guessed from the payload, unless reply.type() set one |
| serialisation | a compiled response schema for the status code if one exists, else JSON.stringify | schema drops undeclared properties and coerces '42' to 42 |
| hijack | reply.hijack() hands the raw response to you | fastify writes nothing after it |
| not found | reply.callNotFound() re-routes into the prefix's 404 context | the 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 returns | preSerialization | serialised | onSend receives | content-type |
|---|---|---|---|---|
| an object | yes | by the compiled serializer or JSON.stringify | the string | application/json; charset=utf-8 |
| a string | skipped | skipped | the same string | text/plain; charset=utf-8 |
| a Buffer | skipped | skipped | the Buffer itself | application/octet-stream |
| a stream | skipped | skipped | the stream object | whatever reply.type() set; no content-length |
null | yes | to the four characters null | that string | application/json; charset=utf-8 |
after reply.hijack() | skipped | skipped | never runs | whatever 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.
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:
| decorator | lives on | existence check | a child may shadow? | visible to |
|---|---|---|---|---|
| decorate(name, v) | the scope object (own property) | Object.hasOwn — own only | yes | this scope and its descendants, at request time and at load time once the plugin has run |
| decorateRequest(name, fn) | the scope's Request prototype | whole chain | no — FST_ERR_DEC_ALREADY_PRESENT | every request in this scope and its descendants |
| decorateRequest(name, value) | the scope's props list → own property of each request | whole chain | no | descendants created after the declaration only — the list is copied at override |
| decorateRequest(name, { getter }) | the prototype, as an accessor | whole chain | no | as 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.
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}))
- L2Read during the plugin body, before
dbPluginhas loaded: false. Order matters for anything a plugin reads while it loads. - 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.
- L5
fpisfastify-plugin: it marks the function sooverridereturns the parent, anddecoratelands on the root object. - L7A hook added to the root after
earlyloaded still reaches/x:_addHookpushes 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 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.
What registration writes
| you write | fastify registers | why |
|---|---|---|
scope.get('/') with prefix /api | GET /api and GET /api/ | prefixTrailingSlash defaults to 'both' |
scope.get('/items') with prefix /api | GET /api/items only | /api/items/ is a 404 unless ignoreTrailingSlash: true |
app.get('/x') | GET /x and HEAD /x | exposeHeadRoutes defaults to true; the HEAD route drops the body in onSend |
app.get('/x') twice | FST_ERR_DUPLICATED_ROUTE at registration | same method, path and constraints |
app.route({ method: ['GET', 'POST'] }) | one route per method, one context each | route() 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.
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 }))
- L1Constrained handlers are tried before the unconstrained one at the same node.
hostmatches theHostheader exactly or by regex. - L3With an unconstrained sibling, a request from any other host still resolves. Without one, a non-matching host is a 404.
- L4
versionreadsAccept-Version;1.xselects 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.
| call | answers | measured on a five-route app |
|---|---|---|
printRoutes() | the tree as a string | compressed node labels, the methods held at each node, and any constraint object |
printRoutes({ commonPrefix: false }) | the same routes as full paths | easier to scan, but no longer the shape the lookup walks |
printRoutes({ includeHooks: true }) | each route with its resolved hooks | where the HEAD twin's headRouteOnSendHandler() becomes visible |
hasRoute({ method, url, constraints }) | whether that route pattern exists | true 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 null | the registration-time view — parameter names, not values |
getDecorator(name) | the decorator value | throws 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.
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' }))
- L1
addSchemastores a shared schema on the scope's SchemaController and pushes it to existing children; a later$ref: "money#"resolves against it at compile time. - L11Each part under
schemacompiles to its own function at preReady:body,querystring,params,headers. - L19
additionalProperties: falseplus fastify'sremoveAdditionaldefault means extra keys are stripped, not rejected. - L21A
responseschema is keyed by status code. The compiled serializer only writes declared properties —leaknever leaves the process.
- L1
addSchemastores a shared schema on the scope's SchemaController and pushes it to existing children; a later$ref: "money#"resolves against it at compile time. - L11The generic on
posttypesreq.body; with a type provider the type is derived from the schema instead of declared twice. - L19
additionalProperties: falseplus fastify'sremoveAdditionaldefault means extra keys are stripped, not rejected. - L21A
responseschema is keyed by status code. The compiled serializer only writes declared properties —leaknever leaves the process.
Validation: ajv with four defaults
| ajv option | fastify default | effect you see |
|---|---|---|
coerceTypes | 'array' | ?dry=true becomes true, /n/7 becomes 7, and a single value can satisfy an array schema |
useDefaults | true | a missing property with default is filled in on the parsed object |
removeAdditional | true | with additionalProperties: false, unknown properties are deleted rather than rejected |
allErrors | false | validation 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 sent | message the client receives |
|---|---|
{ code: 12 } | body/code must be string |
{} | body must have required property 'code' |
{ code: 'AB' } with minLength 4 | body/code must NOT have fewer than 4 characters |
{ money: { amount: 0 } } via $ref | body/money/amount must be >= 1 |
/n/seven with integer param | params/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:
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 object | ns per call | reading |
|---|---|---|
JSON.stringify(obj) | 391.8 | the baseline: reflection over keys at call time |
stringify(obj) from fast-json-stringify | 340.9 | 13% less for this six-property object; the gap grows with nesting and shrinks with tiny payloads |
| validating one body | ns per call | reading |
|---|---|---|
validate(body) compiled once at boot | 62.3 | what fastify does per request |
| hand-written checks | 4.5 | the floor: a few comparisons |
new Ajv().compile(schema)(body) per request | 8,100,425 | 130,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 registered | find-my-way lookup | linear regex scan, worst case |
|---|---|---|
| 10 | 228.4 ns | 292 ns |
| 100 | 295.3 ns | 2,460 ns |
| 1000 | 295.6 ns | 88,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() |
|---|---|
| 0 | 40.7 |
| 2 | 38.3 |
| 5 | 40.5 |
| 10 | 38.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
| server | req/s | avg latency | p99 |
|---|---|---|---|
| raw node http | 28,493 | 1.22 ms | 3 ms |
| fastify, no schema | 26,115 | 1.28 ms | 4 ms |
| fastify + response schema | 28,440 | 1.15 ms | 3 ms |
| fastify + 5 async onRequest hooks | 29,693 | 1.12 ms | 2 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 each | ready() time |
|---|---|
| 10 | 15.8 ms |
| 100 | 125.9 ms |
| 500 | 494.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.
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}
- L9
build()returns an un-listened instance so tests caninjectinto the same object the server would run.server.jscallsbuild().listen(). - L11The first four are
fastify-pluginplugins: they decorate the root and load in this order, soauthcan readconfigwhile it loads. - L15
vouchersis encapsulated with a prefix: its hooks, error handler and 404 stay inside/vouchers. - 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.
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'] })
- L4
fpmakes this plugin write to the root scope.dependenciesis checked at load: ifconfighas not registered, fastify throws with the plugin names. - L5
app.configexists becauseconfigloaded first — a load-time read, so order matters here. - L7
decoratepublishes the pool. Every handler in every scope readsthis.dborreq.server.dbthrough the prototype chain. - L8onClose hooks run in reverse registration order at
close(): the pool ends after the plugins registered later have closed.
- L4Declaration merging tells TypeScript about the decorator; without it
app.dbis a type error even though it exists at runtime. - L6
fpmakes this plugin write to the root scope.dependenciesis checked at load: ifconfighas not registered, fastify throws with the plugin names. - L9
decoratepublishes the pool. Every handler in every scope readsthis.dborreq.server.dbthrough the prototype chain. - L10onClose hooks run in reverse registration order at
close(): the pool ends after the plugins registered later have closed.
The claim route
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
- L4A scope-level preHandler: every route in this plugin — and in any plugin registered beneath it — is rate limited. Routes outside
/vouchersare not. - L6Both schemas are shared (
addSchemainconfig), so the same shape validates here and serialises in the admin plugin. - L11A plain
functionsothisis the scope:this.dbandthis.whatsappresolve through the prototype chain to the root decorators. - L12
claimVoucheris a plain module that takes its dependencies as arguments — the service layer never imports fastify. - L18The scope's error handler answers domain errors; rethrowing hands anything else to the parent handler, which is the default one here.
- L4
FastifyPluginAsynctypes the(app, opts)signature; an options interface can be passed as its generic. - L5A scope-level preHandler: every route in this plugin — and in any plugin registered beneath it — is rate limited. Routes outside
/vouchersare not. - L7The route generic names the body and reply types; a type provider derives them from the schema instead (see below).
- L12A plain
functionsothisis the scope:this.dbandthis.whatsappresolve through the prototype chain to the root decorators. - 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
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()})
- L7Decorating before
ready()wins the name: the realwhatsappplugin would throw FST_ERR_DEC_ALREADY_PRESENT, so the test build registers it conditionally or the plugin checkshasDecoratorfirst. - L9
injectbuilds a request with light-my-request and runs the full pipeline — routing, hooks, validation, serialisation — without a socket. - L15
res.json()parses the serialised payload;res.headersandres.statusCodeare 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.
| what | where it happens | shape |
|---|---|---|
| request id | genReqId at request creation, default an incrementing counter per process | req.id, returned as request-id header only if you set it |
| request logger | logger.child({ reqId }) built by childLoggerFactory | req.log and reply.log are the same child |
| incoming line | logged on request creation by log-controller.js | { reqId, req: { method, url, host, remoteAddress } } |
| completed line | onResponse time, with reply.elapsedTime | { reqId, res: { statusCode }, responseTime } |
| level per scope | logLevel as a register option or route option | a scope can log at debug while the app logs at info |
Types from the schema
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})
- L3
Type.Objectreturns a plain JSON schema object; fastify compiles it exactly as a hand-written one.
- L8
Staticderives the TypeScript type from the same object, so the runtime check and the compile-time type cannot drift apart. - L10
withTypeProvidertells the route generics to readBody,Querystring,ParamsandReplytypes off the schema. - L12No
as, no manual generic: the handler'sreq.bodyis typed from the validated shape.
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)})
- L1preClose runs before the server stops accepting; keep-alive connections are closed by fastify between
preCloseandonClose. - L2onClose hooks run last-registered first, so a plugin that depends on
dbcloses beforedbdoes. - L5
close()resolves when everyonClosehas finished; the process exits only then.
The final model
- 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