Skip to content

Top 33 Fastify Interview Questions and Answers [2026]

November 15, 2026 27 min read
Fastify interview guide by Second Talent
TL;DR: Fastify interviews in 2026 test whether a candidate understands encapsulation, the hook lifecycle and schema-driven validation well enough to predict what a short snippet does. The current stable release is Fastify 5.12.8 (November 2, 2026), which needs Node.js 20 or later. Candidates should also know the v5 breaking changes, the newer handlerTimeout and logController options, and what the 6.0 alphas are removing.

Fastify 5.12.8 shipped on November 2, 2026 as a security release: with a nonzero handlerTimeout, a streaming response that stayed open past the timeout could crash the whole Node.js process with an uncaught ERR_HTTP_HEADERS_SENT (GHSA-3qgw-9275-xf8w, affecting 5.8.0 up to 5.12.7).

The 5.12.8 release notes also backport router compile mode and disposable onClose hooks from the 6.0 alpha line.

The questions below are for senior Node.js and TypeScript backend interviews where Fastify is the main framework, with several find-the-bug and what-does-this-return rounds.

Key takeaways
  1. 1Fastify 5 runs on Node.js 20 and later, and the LTS table lists Node.js 24 and 26 for the coming 6.0 release.
  2. 2The default Ajv setup uses coerceTypes: 'array', useDefaults: true and removeAdditional: true, so extra body fields are silently dropped, not rejected.
  3. 3handlerTimeout (added in 5.8.0) defaults to 0; when set, a slow route gets a 503 and request.signal aborts, but the handler keeps running unless it checks the signal.
  4. 4Fastify 4 reached end of LTS on June 30, 2025, so any v4 service in production is running without upstream security fixes.

Plugins, Encapsulation and Decorators

1. What makes Fastify faster than Express, and where does the speed come from?

Most of the gain comes from work Fastify does once at startup instead of on each request. Routes go into find-my-way, a radix-tree router.

Each route's JSON schemas are compiled into validation functions by Ajv and into serializer functions by fast-json-stringify before the server listens.

Hooks are stored per route, so a request walks a precomputed list instead of a global middleware chain.

A strong candidate is specific about where the savings show up:

  • Response serialization. A route with a response schema uses a generated function instead of JSON.stringify, which is the largest single win on JSON-heavy APIs.
  • Routing. Lookup cost depends on path length, not route count. Since 5.12.8, routerOptions.compile: true turns each method's tree into generated JavaScript for faster lookups.
  • Logging. Pino writes JSON with low overhead, and Fastify creates a child logger per request instead of formatting strings.

A weak answer says "Fastify is faster" and stops.

A senior candidate adds that the database and outbound calls dominate most real latency, so the framework matters most for high request rates, many small JSON responses or tight CPU budgets per instance.

2. What is encapsulation in Fastify?

Each call to register creates a child context that inherits everything from its parent, while nothing it adds leaks upward or to siblings.

Decorators, hooks, schemas, content type parsers and error handlers registered inside a plugin only apply to routes in that plugin and its descendants.

The Encapsulation reference describes the result as a tree of contexts, with the root instance at the top.

The practical use is isolation: an authenticated subtree can add an auth hook without touching public routes, and two plugins can each register a decorator named db pointing at different databases.

The onClose hook is the one exception the docs list, because it is not encapsulated.

3. What does this return for GET /root?

import Fastify from 'fastify'

const app = Fastify()

app.register(async function child (instance) {
  instance.decorate('foo', 'bar')
  instance.get('/child', async function () {
    return { foo: this.foo }
  })
})

app.get('/root', async function () {
  return { hasFoo: this.hasDecorator('foo'), foo: this.foo ?? null }
})

await app.ready()

GET /root returns {"hasFoo":false,"foo":null}, and GET /child returns {"foo":"bar"}. The decorator lives in the child context, so the root cannot see it.

Inside a handler declared with function, this is the Fastify instance of the context where the route was registered.

Two follow-ups separate levels. If the handler were an arrow function, this would not be the Fastify instance at all. And to expose foo at the root, the child function would need to be wrapped with fastify-plugin, which question 4 covers.

4. When do you wrap a plugin with fastify-plugin?

Wrap it when the plugin's job is to add something the parent must see, such as a database client decorator, a shared auth helper or a global hook. fastify-plugin sets the hidden Symbol.for('skip-override') property, so register runs the plugin in the parent's context instead of creating a new one.

The Plugins reference recommends the module over setting the symbol by hand, because it also checks the Fastify version range and lets you declare a plugin name and dependencies.

import fp from 'fastify-plugin'
import pg from 'pg'

export default fp(async function dbPlugin (app, opts) {
  const pool = new pg.Pool({ connectionString: opts.url })
  app.decorate('db', pool)
  app.addHook('onClose', async () => {
    await pool.end()
  })
}, { name: 'db', fastify: '5.x' })

Do not wrap feature plugins that hold routes and route-specific hooks. A common mistake in code review is wrapping everything with fp "to be safe", which flattens the tree and makes every hook global.

5. Find the bug: why do the admin routes skip authentication?

// auth.js
export default async function auth (app) {
  app.decorateRequest('user', null)
  app.addHook('onRequest', async (request) => {
    request.user = await verifyToken(request.headers.authorization)
  })
}

// app.js
app.register(auth)
app.register(adminRoutes, { prefix: '/admin' })

auth is registered as a normal plugin, so its decorator and hook live in their own child context. adminRoutes is a sibling, so its routes run without the hook and request.user is not even declared there.

Nothing throws at startup, which is why this bug reaches production.

Two correct fixes exist, and a strong candidate explains when to use each. Wrap auth with fastify-plugin if every route in the app needs it.

Or register adminRoutes inside a context that also registers auth, which keeps public routes outside the hook:

app.register(async function protectedScope (scope) {
  await scope.register(authPlugin)        // fp-wrapped, so it applies to this scope
  await scope.register(adminRoutes, { prefix: '/admin' })
})

The hook also throws away failures: if verifyToken returns null for a bad token, the request still continues. Authentication hooks should reply with 401 or throw an error that carries statusCode: 401.

6. How does plugin loading order work, and what do after, ready and await register do?

Fastify loads plugins with avvio, which queues them and runs them in registration order, depth first, when the instance boots.

A plugin's children finish loading before the next sibling starts. await app.register(plugin) (or app.after()) waits for that plugin to load, so code after it can use the decorators it added. await app.ready() loads the whole tree and runs onReady hooks without listening, which is what tests use.

Two errors candidates should recognize: FST_ERR_DEC_AFTER_START when code decorates after the instance has started, and a plugin timeout when a callback-style plugin forgets to call done.

The pluginTimeout default is 10,000 ms. Since v5, a plugin can no longer mix the two styles: an async plugin that also takes done fails with FST_ERR_PLUGIN_INVALID_ASYNC_HANDLER.

7. Why does decorateRequest reject an object value?

A decorator value goes onto the Request prototype, so an object or array would be one shared reference across all concurrent requests.

One request mutating it would change what the next request sees, which the Decorators reference calls a security and memory leak risk. Since v5, Fastify throws FST_ERR_DEC_REFERENCE_TYPE for decorateRequest('user', {}).

The supported pattern is to declare the property with a primitive or null so the object shape stays stable, then assign a fresh value per request in a hook:

app.decorateRequest('user', null)

app.addHook('preHandler', async (request) => {
  request.setDecorator('user', await loadUser(request))
})

getDecorator and setDecorator throw FST_ERR_DEC_UNDECLARED for an unknown name, so a typo fails loudly.

A strong candidate also mentions why Fastify wants the property declared up front at all: V8 keeps one hidden class for every request object when properties are added in the same order.

Hooks and the Request Lifecycle

8. In what order do the request hooks run?

The Lifecycle reference gives the order as routing, then onRequest, preParsing, body parsing, preValidation, schema validation, preHandler, the handler, preSerialization, onSend, the response and onResponse. onError, onTimeout and onRequestAbort fire only when those events happen.

  • onRequest: no body yet. Good for cheap checks on headers such as tokens, rate limits and request IDs.
  • preParsing: can replace the raw payload stream, for example to decompress it.
  • preValidation: body parsed but not validated. Useful when auth needs a value from the body.
  • preHandler: input is validated and coerced, so this is the place for permission checks that read typed params.
  • preSerialization: can change the object before serialization; it does not run for strings, buffers or streams.
  • onSend: sees the serialized payload and can replace it with a string, Buffer, stream, ReadableStream, Response or null.
  • onResponse: after the response is sent; use it for metrics, not for changing the response.

9. Where should an authentication check run, onRequest or preHandler?

Run it in onRequest when it only needs headers or cookies, because rejecting there skips body parsing and validation for unauthenticated traffic.

That matters for upload routes, where parsing a large body before rejecting the caller wastes memory and bandwidth.

Move it to preValidation or preHandler when the decision depends on the body or on validated params, for example "can this user edit project :id".

A good answer splits the work: authentication (who are you) in onRequest, authorization (may you do this) in preHandler where params are typed. Weak answers put everything in the handler, which duplicates the check across routes.

10. Find the bug in this hook.

app.addHook('preHandler', async (request, reply, done) => {
  if (!request.user) {
    reply.code(401).send({ error: 'unauthorized' })
  }
  done()
})

Fastify refuses this at registration: an async hook that declares a done parameter throws FST_ERR_HOOK_INVALID_ASYNC_HANDLER, because mixing the two styles can run the handler twice.

The Hooks reference says the done callback is not available for async hooks.

Even in callback form the logic is wrong, because the hook sends a 401 and then lets the request continue to the handler, which then fails with "Reply was already sent". The fixed async version stops the chain by returning the reply:

app.addHook('preHandler', async (request, reply) => {
  if (!request.user) {
    return reply.code(401).send({ error: 'unauthorized' })
  }
})

Throwing an error with statusCode = 401 is the other clean option, since it routes the failure through the error handler and keeps the error format in one place.

11. What is the difference between the onError hook and setErrorHandler?

setErrorHandler decides the response for an error, while onError only observes it. Per the Hooks reference, onError runs before the custom error handler, is meant for logging or adding headers, and calling reply.send inside it throws.

Both are encapsulated, so a plugin can format its own errors without affecting the rest of the app.

app.setErrorHandler(function (error, request, reply) {
  if (error.validation) {
    return reply.code(400).send({ error: 'invalid_input', details: error.validation })
  }
  request.log.error({ err: error }, 'unhandled error')
  return reply.code(error.statusCode ?? 500).send({ error: 'internal' })
})

Two details strong candidates know. The allowErrorHandlerOverride option defaults to true in v5, and the docs warn it will default to false in the next major, so calling setErrorHandler twice in one scope will become an error.

And 5.12.8 now clamps an error's statusCode to the 400 to 599 range with a 500 fallback, so an error object carrying statusCode: 200 no longer produces a "successful" error response.

12. How do you shut a Fastify service down gracefully?

Call app.close() on SIGTERM and release resources in onClose hooks. By the time onClose runs, the server has stopped listening and in-flight requests have finished, so closing a pool there is safe.

Child-plugin onClose hooks run before parent ones, so the database plugin closes before root-level cleanup.

  • return503OnClosing defaults to true, so new requests during shutdown get a 503.
  • forceCloseConnections defaults to "idle" where the server supports it, closing idle keep-alive sockets so shutdown does not hang.
  • preClose runs before the server stops listening and is the place to end WebSocket or Server-Sent Events connections that would otherwise block server.close().

Since 5.12.8, an onClose hook can also be an object with Symbol.asyncDispose or Symbol.dispose.

A senior answer ties the shutdown timeout to the orchestrator: Kubernetes sends SIGKILL after terminationGracePeriodSeconds, so the app must finish draining before that.

Validation and Serialization

13. What do Fastify's default Ajv options do to incoming data?

The Validation and Serialization reference lists the baseline: coerceTypes: 'array', useDefaults: true, removeAdditional: true and allErrors: false. Validation therefore changes the request in place:

  • Coercion: ?limit=5 against { type: 'integer' } becomes the number 5, and a single value becomes an array when the schema says array.
  • Defaults: missing properties get the schema's default.
  • Removal: with additionalProperties: false, unknown properties are deleted instead of causing a 400.
  • allErrors false: validation stops at the first error. The docs warn that turning it on opens a denial-of-service risk.

The trap is the third point. Teams add additionalProperties: false expecting a client sending isAdmin: true to get a 400, and instead the field disappears silently. That is safe for mass assignment but confusing for API consumers.

If the API should reject unknown fields, pass custom ajv.customOptions with removeAdditional: false.

14. What do these requests return?

app.get('/items', {
  schema: {
    querystring: {
      type: 'object',
      properties: {
        limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
        tags: { type: 'array', items: { type: 'string' } }
      }
    }
  }
}, async (request) => request.query)

// A: GET /items
// B: GET /items?limit=5&tags=a
// C: GET /items?limit=abc

A returns {"limit":20} because useDefaults fills the default. B returns {"limit":5,"tags":["a"]}, with the number coerced from the string and the single tag coerced into an array by coerceTypes: 'array'.

C returns a 400 with a message that querystring/limit must be an integer.

The question tests whether the candidate knows the handler sees coerced data, not the raw query string.

A good follow-up: the same coercion applies to body, params and headers, so a JSON body sending "5" for an integer field also passes as 5.

15. What changed for schemas in Fastify 5?

Fastify 5 requires a full JSON Schema for querystring, params, body and response schemas, including type, and removed the jsonShortHand option.

A v4 route with querystring: { name: { type: 'string' } } must become an object schema with properties. The V5 Migration Guide lists the other schema changes:

  • Type Providers now separate validator and serializer schema types.
  • The date-time and time formats enforce a timezone after the ajv-formats update; iso-date-time and iso-time keep it optional.
  • The params object no longer has a prototype, so request.params.hasOwnProperty fails; use Object.hasOwn.

Candidates who did a v4 to v5 migration usually name the timezone change first, because it breaks clients that send 2026-11-09T10:00:00 without an offset.

16. How does a response schema prevent data leaks?

With a response schema, Fastify serializes through fast-json-stringify, which writes only the properties the schema declares. A handler that returns a full user row, password hash included, sends only what the schema lists.

const userOut = {
  type: 'object',
  properties: {
    id: { type: 'string' },
    email: { type: 'string' }
  },
  required: ['id', 'email']
}

app.get('/users/:id', {
  schema: { response: { 200: userOut } }
}, async (request) => {
  return db.users.findById(request.params.id) // row also has passwordHash
})
// Response: {"id":"u_1","email":"a@example.com"}

Response schemas are keyed by status code, with '2xx' and default as catch-alls, and can vary by content type.

A strong candidate mentions the limits: an error status without a matching schema falls back to JSON.stringify, and a type mismatch (a string where the schema says integer) is coerced or throws at serialization, so response schemas need tests too.

17. How do you share schemas across routes?

Add them with app.addSchema() using an $id, then reference them with $ref. Shared schemas follow encapsulation like everything else, so a schema added inside a plugin is only visible to that plugin and its children.

app.addSchema({
  $id: 'Money',
  type: 'object',
  properties: {
    amount: { type: 'integer' },
    currency: { type: 'string', minLength: 3, maxLength: 3 }
  },
  required: ['amount', 'currency']
})

app.post('/invoices', {
  schema: {
    body: {
      type: 'object',
      properties: { total: { $ref: 'Money#' } },
      required: ['total']
    }
  }
}, createInvoice)

The docs warn that addSchema stops being useful once you replace the validator and serializer compilers, because the custom compilers do not see Fastify's schema store. That is the trade-off behind Zod-based setups (question 26).

18. How do you customize validation errors?

There are three levels. attachValidation: true on a route puts the error on request.validationError and lets the handler decide. schemaErrorFormatter, set on the instance or with setSchemaErrorFormatter, builds the error message from the Ajv errors.

And setErrorHandler can check error.validation and error.validationContext (body, params, querystring or headers) to shape the final response.

Most teams want one consistent error body across the API, so the error handler is the usual answer. A candidate who reaches for attachValidation on every route has not used the error handler enough.

In the 6.0 alphas, default validation errors are created without a stack trace for performance, which matters if your logs or error tracker expect one.

Routes, Replies and Production Behavior

19. Find the bug in this async handler.

app.get('/report', async (request, reply) => {
  generateReport(request.query, (err, csv) => {
    if (err) return reply.code(500).send({ error: 'failed' })
    reply.type('text/csv').send(csv)
  })
})

The async handler resolves with undefined before the callback fires, and the Routes reference says undefined cannot be returned from an async handler.

Fastify treats the resolved promise as the end of the handler, logs an error and sends a response before the CSV is ready, then the later reply.send fails because the reply was already sent.

The fix is to return reply (or await reply) so Fastify waits for the callback, or better, to promisify generateReport and return the value:

app.get('/report', async (request, reply) => {
  const csv = await generateReportAsync(request.query)
  return reply.type('text/csv').send(csv)
})

The docs' rule is short: in an async handler either return the value, or call reply.send and return the reply. If a handler does both, the returned value wins and Fastify logs a warning.

20. How does handlerTimeout work, and what is the catch?

handlerTimeout, added in 5.8.0, sets a maximum time in milliseconds for the whole route lifecycle, from routing through serialization. On expiry, Fastify sends a 503 with FST_ERR_HANDLER_TIMEOUT and aborts request.signal.

It can be set on the server and overridden per route, and unlike connectionTimeout and requestTimeout it works at the application level, so keep-alive connections do not affect it.

const app = Fastify({ handlerTimeout: 10_000 })

app.get('/export', { handlerTimeout: 120_000 }, async (request) => {
  const rows = await db.query(exportSql, { signal: request.signal })
  return rows
})

The catch is that the timeout is cooperative: the handler keeps running after the 503 unless its I/O listens to request.signal.

A candidate who sets a 5 second timeout without passing the signal has only hidden the slow query from the client while it still holds a connection.

Strong candidates also know the 5.12.8 security fix: on 5.8.0 through 5.12.7, a timeout on a streaming route that had already sent headers could crash the process.

The timeout does not cover 404 handlers or a custom setNotFoundHandler, because they bypass the route lifecycle.

21. What does trustProxy change, and why is it a security setting?

With trustProxy enabled, Fastify reads X-Forwarded-For, X-Forwarded-Host and X-Forwarded-Proto to fill request.ip, request.ips, request.host and request.protocol. It defaults to false, because any client can send those headers.

The value can be true, an IP or CIDR string, an array, or a function. The Server reference for 5.12.8 says a plain hop count is no longer honored, because it cannot validate the immediate peer.

The answer to look for: set it to the load balancer's address range, never true when the app port is reachable directly, and do not use request.ip for authorization without that.

Rate limiters keyed on IP are the usual victim, since a spoofed header gives each request a fresh identity. Since v5, request.hostname excludes the port, and request.host and request.port hold the rest.

22. How do you stream a large response, and when would you use reply.hijack?

Return or send a Node.js Readable or a web ReadableStream, and Fastify pipes it, handles backpressure and destroys the stream if the client disconnects. Set the content type yourself, because a stream has no serializer.

import { createReadStream } from 'node:fs'

app.get('/exports/:id', async (request, reply) => {
  const file = await exportPath(request.params.id)
  return reply.type('text/csv').send(createReadStream(file))
})

reply.hijack() tells Fastify to stop managing the response, skipping later hooks and the automatic send, and clears any handlerTimeout timer. It suits protocol upgrades or libraries that write to reply.raw themselves.

The cost is that you lose serialization, onSend hooks and error handling for that response, so a senior candidate treats it as a last resort.

23. What is the right way to run Fastify in a container?

Listen on 0.0.0.0, since the default host is localhost and a container would not accept traffic from outside. In v5, listen only accepts an options object; the old positional signature is gone.

const app = await buildApp()

try {
  await app.listen({ port: Number(process.env.PORT ?? 3000), host: '0.0.0.0' })
} catch (err) {
  app.log.error(err)
  process.exit(1)
}

for (const signal of ['SIGINT', 'SIGTERM']) {
  process.once(signal, async () => {
    await app.close()
    process.exit(0)
  })
}

Strong candidates then talk about one process per container with the orchestrator handling replicas, instead of the cluster module, plus health endpoints excluded from request logs, a bodyLimit set deliberately (the default is 1 MiB) and keepAliveTimeout above the load balancer's idle timeout.

Fastify's default keepAliveTimeout is 72 seconds, longer than the 60 second default idle timeout on an AWS Application Load Balancer.

Logging, TypeScript and Testing

24. How do you configure Pino logging in Fastify 5?

Pass Pino options to logger, or pass an existing logger to loggerInstance. Fastify 5 split these: logger no longer accepts a logger instance, which was a common source of confusion in v4. Fastify 5.12.8 accepts Pino 9.14 or 10.x.

const app = Fastify({
  logger: {
    level: process.env.LOG_LEVEL ?? 'info',
    redact: ['req.headers.authorization', 'req.headers.cookie']
  },
  requestIdHeader: 'x-request-id',
  genReqId: () => crypto.randomUUID()
})

app.get('/orders/:id', async (request) => {
  request.log.info({ orderId: request.params.id }, 'loading order')
  return loadOrder(request.params.id)
})

Use request.log inside handlers, since it is a child logger that carries the request ID. requestIdHeader defaults to false, and the Logging reference warns that enabling it lets callers set an arbitrary, unvalidated ID.

A careful candidate validates or ignores that header from the public internet and keeps pino-pretty to development, since it adds formatting cost in production.

25. How do you silence health-check logs in current Fastify 5?

Since 5.10.0, use the logController option with a LogController instance. Its disableRequestLogging option accepts a boolean or a function of the request.

The top-level disableRequestLogging option, which accepts a function since 5.7.0, is now marked deprecated and the docs say it will be removed in Fastify 6.

import Fastify, { LogController } from 'fastify'

const app = Fastify({
  logger: true,
  logController: new LogController({
    disableRequestLogging: (request) => request.url === '/healthz'
  })
})

A subclass of LogController can override methods such as incomingRequest and requestCompleted to change levels or fields.

The docs note a trap: an overridden method does not apply the disable check automatically, so it must call this.isLogDisabled(request) itself.

26. What are type providers, and how do you choose one?

A type provider lets TypeScript infer request.body, request.query, request.params and reply types from the route schema, so the schema is the single source of truth. You opt in with withTypeProvider<T>().

The Type Providers reference lists official packages for TypeBox, json-schema-to-ts and Zod.

import Fastify from 'fastify'
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'
import { Type } from 'typebox'

const app = Fastify().withTypeProvider<TypeBoxTypeProvider>()

app.post('/users', {
  schema: {
    body: Type.Object({ email: Type.String(), age: Type.Integer({ minimum: 18 }) }),
    response: { 201: Type.Object({ id: Type.String() }) }
  }
}, async (request, reply) => {
  const { email, age } = request.body   // { email: string; age: number }
  const id = await createUser(email, age)
  return reply.code(201).send({ id })
})
  • TypeBox produces JSON Schema, so Ajv, fast-json-stringify, addSchema and OpenAPI generation keep working.
  • json-schema-to-ts keeps plain schemas, but schemas moved into variables need as const or inference widens to string.
  • Zod needs its own validator and serializer compilers, so you trade Fastify's compiled serialization for Zod's API.

A key detail: withTypeProvider types the instance it returns, and plugins receive a plain instance, so each plugin must call it again or use the provider's plugin type.

27. How do you type a decorator in TypeScript?

Use declaration merging on the fastify module, next to the plugin that adds the decorator:

import fp from 'fastify-plugin'
import type { Pool } from 'pg'

declare module 'fastify' {
  interface FastifyInstance {
    db: Pool
  }
  interface FastifyRequest {
    user: { id: string; role: 'admin' | 'member' } | null
  }
}

export default fp(async (app) => {
  app.decorate('db', createPool())
  app.decorateRequest('user', null)
}, { name: 'db' })

The weakness a strong candidate points out is that the augmentation is global: TypeScript now claims app.db exists in every context, including ones where the plugin was never registered. getDecorator<T>('db') called at registration time turns that gap into a boot-time error instead of a runtime undefined.

28. How do you test a Fastify app without opening a port?

Build the app in a factory function and call app.inject(), which uses light-my-request to send a fake HTTP request through the full lifecycle, hooks, validation and serialization included.

No socket is opened, so tests run in parallel without port conflicts.

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

test('rejects a body without email', async (t) => {
  const app = await buildApp({ logger: false })
  t.after(() => app.close())

  const res = await app.inject({
    method: 'POST',
    url: '/users',
    payload: { age: 30 }
  })

  assert.equal(res.statusCode, 400)
  assert.match(res.json().message, /email/)
})

Good answers keep listen out of the factory, close the app after each test so onClose hooks release pools, and test plugins in isolation by registering them on a bare instance.

The v5 migration guide names better node:test support as one reason for the Node.js 20 minimum.

29. Design question: how would you structure a 60-route Fastify service?

A strong design uses the encapsulation tree as the architecture. Infrastructure plugins (config, database, cache, auth helpers) are wrapped with fastify-plugin and registered at the root.

Feature modules register as normal plugins with a prefix, each holding its routes, schemas and feature-specific hooks. @fastify/autoload can load both folders by convention.

  • Shared schemas go in with addSchema at the level that needs them, not all at the root.
  • Auth scopes are separate contexts: public, authenticated and admin.
  • Plugins declare dependencies in fastify-plugin metadata so a missing database plugin fails at boot.
  • One error handler at the root defines the error format; feature plugins add their own only when they need a different one.

Weak answers copy an Express layout with a global middleware stack and controllers that import a singleton database client. That works, but it loses per-scope hooks, makes tests depend on module-level state and leaves encapsulation unused.

30. When would you not choose Fastify?

Fastify is a Node.js framework built around http.IncomingMessage. For code that must run on edge runtimes or Bun and Deno without Node.js APIs, a Web Standards framework fits better.

For a large team that wants strong conventions, dependency injection and modules out of the box, NestJS offers that, and it can run on a Fastify adapter underneath.

A balanced candidate also names Fastify's own costs: encapsulation surprises developers coming from Express, async and callback styles mixed in one codebase cause bugs like questions 10 and 19, and the plugin catalog is smaller than Express's middleware pool, though @fastify/express or @fastify/middie can mount Express middleware during a migration.

What Changed Recently

Jun 30, 2025
Fastify 4 reaches end of LTS
Aug 11, 2025
Fastify 5.5.0 moves router settings under routerOptions and types reply status codes with type providers
Jan 15, 2026
Fastify 5.7.0 adds conditional request logging and native WebStream handling
Mar 5, 2026
Fastify 5.8.0 adds handlerTimeout at server and route level
Jul 5, 2026
Fastify 5.10.0 introduces the LogController layer
Jul 30, 2026
Fastify 5.11.0 supports the HTTP QUERY method
Aug 11, 2026
Fastify 6.0.0-alpha.0 removes v5 deprecations and raises the minimum Node.js version
Nov 2, 2026
Fastify 5.12.8 fixes a handlerTimeout crash and backports router compile mode

31. Which Fastify 5 breaking changes catch teams during a v4 upgrade?

Fastify 5.0.0 came out on September 17, 2024, and the migration guide says to clear all v4 deprecation warnings first, because every v4 deprecation was removed. The changes that break code most often:

  • Node.js 20 minimum.
  • Full JSON Schema required for querystring, params, body and response (question 15).
  • loggerInstance for a custom logger; logger takes options only.
  • reply.redirect(url, code) instead of reply.redirect(code, url).
  • listen() accepts only an options object.
  • useSemicolonDelimiter defaults to false, so ?a=1;b=2 is no longer split.
  • request.hostname no longer includes the port.
  • Mixed async and callback plugins are rejected.
  • reply.getResponseTime() is gone; use reply.elapsedTime.

Fastify 4 left LTS on June 30, 2025, per the LTS policy, so this is no longer optional for services that still run it.

32. What has Fastify 5 added during 2026?

Six minor releases since January added features a candidate working on a current codebase may have used:

  • 5.7.0 (January 15): disableRequestLogging accepts a function of the request; streams use the native WebStream API.
  • 5.8.0 (March 5): handlerTimeout with request.signal cancellation.
  • 5.9.0 (June 28): request.mediaType, an onMaxParamLength router option, and a fix so forwarded host and proto headers are not trusted when the socket is missing.
  • 5.10.0 (July 5): LogController and lower per-request overhead in the lifecycle.
  • 5.11.0 (July 30): the QUERY method with app.query(), which requires a Content-Type header.
  • 5.12.0 (August 13): reply.mediaType; 5.12.8 later adds routerOptions.compile and disposable onClose hooks.

Security releases matter as much as features here. 5.8.5 (April 14) and 5.12.8 (November 2) both shipped advisories, and a candidate who pins exact versions without a patch process should explain how they would pick those up.

33. What is coming in Fastify 6?

Fastify 6 is in alpha, with 6.0.0-alpha.6 published on November 2, 2026 under the npm next tag, and the LTS table lists no release date. The alpha release notes show the direction:

  • Node.js 24 and 26 as the supported runtimes in the LTS table.
  • Error handler overrides disabled by default, matching the v5 allowErrorHandlerOverride warning.
  • Deprecated types removed: FastifyPlugin, FastifyLoggerInstance (use FastifyBaseLogger), FastifyRequestContext and others.
  • Validation errors without stack traces by default.
  • Fail closed when a response's content type is not declared in a route's response content map.

The practical advice for a v5 codebase is the same as before v5: fix deprecation warnings now. A candidate who knows their service already runs on Node.js 24 has one less blocker.

Signs of a Strong Answer

  • They draw the encapsulation tree before saying whether a hook or decorator applies to a route.
  • They pick a hook by what data exists at that point: headers in onRequest, validated params in preHandler.
  • They know the default Ajv options change the request and can say what happens to an unknown property.
  • They add response schemas for both speed and leak prevention, and test them.
  • They pass request.signal into I/O whenever they mention timeouts.
  • They name the Fastify and Node.js versions in production and the v5 change that bit them during the upgrade.

Hiring Fastify Developers

Engineers who have structured a Fastify service around plugins, owned its schemas and upgraded it from v4 to v5 are hard to spot from a resume, since most list "Node.js" and stop.

For adjacent screens, see our Node.js, Express.js, NestJS and TypeScript interview guides.

Second Talent helps US startups hire vetted AI-Native Engineers from Asia, at $23 to $39 USD an hour billed monthly, with matches in 24 hours and a 90-day one-time replacement guarantee.

See Node.js developers for hire and TypeScript developers, compare rates on the cost to hire a Node.js developer page, or tell us what you need.

Get developers who ship with AI agents.

Starts at $23/Hour.

Start Hiring

$0 cost until you hire