Fastify Interview Questions and Answers

Last updated:

Check out 30 of the most common Fastify interview questions, then take an AI-powered practice interview

Node.jsJavaScriptTypeScriptPerformancePlugins
30+
Questions
12
Basic
13
Intermediate
5
Advanced
Q1

What is Fastify and why was it created when Express already existed?

BasicFundamentals

Answer

Fastify is a Node.js web framework created in 2016 by Matteo Collina (Node.js TSC member) and Tomas Della Vedova, designed around a single principle: minimum overhead per request. Express was created in 2010 and its routing, body parsing, and middleware pipeline carry historical baggage that cannot be removed without breaking the ecosystem. Fastify solves three specific problems Express handles poorly.

First, JSON serialization is the slowest part of most APIs, so Fastify uses fast-json-stringify with a precompiled schema instead of the generic JSON.stringify. Second, Express middleware runs sequentially with chained callbacks and closures, while Fastify compiles all hooks for a route into a single function chain at registration time. Third, Express has no built-in validation, so Fastify uses Ajv with JIT-compiled JSON Schema validators for both incoming requests and outgoing responses.

The cumulative result is roughly 2x more requests per second on identical hardware in real-world benchmarks like the techempower JSON test. Fastify is now used in production at Microsoft (parts of Azure), Walmart (checkout APIs), American Express (member services), and several Indian companies including Razorpay (payment APIs), Postman (mock servers), and a growing number of fintech startups. The framework has been actively maintained for nine years with strict semver, a published LTS policy, and a release cadence aligned with Node.js LTS lines.

Key Points

  • Created by Matteo Collina, Node.js TSC member
  • Schema-based serialization via fast-json-stringify
  • Ajv JIT compilation for validation
  • Roughly 2x faster than Express in benchmarks
  • Plugin system with encapsulation
Q2

How do you create a basic Fastify server and define a route?

BasicRouting

Answer

Import the factory function, call it to create an instance, register routes, and call listen. Unlike Express where app.listen takes a callback, Fastify's listen returns a Promise that resolves when the server is bound to the port and ready to accept connections. You can either await it or pass a callback for legacy code, but async/await is the idiomatic style in 2026.

The route handler receives request and reply objects with richer APIs than Express's req and res. Return values from async handlers are automatically sent as the response body, so you rarely call reply.send() explicitly unless you need to set a status code, set headers, or send before doing additional async work. The factory function accepts options including logger configuration, body size limits, trust proxy settings (essential for behind-load-balancer deployments to get correct client IPs), and HTTP/2 enablement. Always bind to host 0.0.0.0 inside Docker containers, the Fastify default is 127.0.0.1 which is loopback-only and will leave you with a server that works on the host but is unreachable from outside the container, a very common rookie production bug.

import Fastify from 'fastify';

const fastify = Fastify({ logger: true });

fastify.get('/hello/:name', async (request, reply) => {
  const { name } = request.params;
  return { greeting: `Hello, ${name}` };
});

try {
  await fastify.listen({ port: 3000, host: '0.0.0.0' });
  console.log('Server listening on 3000');
} catch (err) {
  fastify.log.error(err);
  process.exit(1);
}

Key Points

  • host: '0.0.0.0' required inside Docker containers
  • Return value auto-sent as response
  • listen() returns a Promise
Q3

What is JSON Schema validation in Fastify and how do you attach a schema to a route?

BasicValidation

Answer

Fastify uses JSON Schema (draft 7 by default, draft 2020-12 available via configuration) for both request validation and response serialization. You attach a schema object to the route definition under the schema property, with sub-keys for body, querystring, params, headers, and response. Fastify compiles the schema with Ajv at route registration time, not per request, Ajv generates a JavaScript validator function that is essentially a long if/else chain checking each property.

This means validation cost is fixed at boot and trivial at runtime, typically under a microsecond per request. If a request fails validation, Fastify automatically returns a 400 with a structured error message containing the path to the offending field and a human-readable description. You can customize the error format with a setErrorHandler if your API contract requires a specific shape.

The same schema serves three purposes simultaneously, request validation, response serialization speedup via fast-json-stringify, and automatic OpenAPI documentation if you register @fastify/swagger. This single-source-of-truth approach is one of the biggest reasons teams switch from Express plus a pile of separate validation/docs/serialization libraries.

fastify.post('/users', {
  schema: {
    body: {
      type: 'object',
      required: ['email', 'age'],
      properties: {
        email: { type: 'string', format: 'email' },
        age: { type: 'integer', minimum: 18, maximum: 120 },
      },
    },
    response: {
      201: {
        type: 'object',
        properties: {
          id: { type: 'integer' },
          email: { type: 'string' },
        },
      },
    },
  },
}, async (request, reply) => {
  const user = await db.createUser(request.body);
  reply.code(201);
  return user;
});
💡 Pro Tip: Always declare a response schema in production, Fastify uses fast-json-stringify, which is 2-5x faster than JSON.stringify, but only when the schema is provided.
Q4

What is the difference between request.params, request.query, and request.body?

BasicRouting

Answer

request.params holds URL path parameters declared with the colon syntax in the route path (/users/:id, /posts/:slug). request.query holds the parsed querystring object from the URL after the question mark (/users?page=2&limit=10 becomes { page: '2', limit: '10' }). request.body holds the parsed request body, populated only for POST/PUT/PATCH/DELETE methods with a body content type. Fastify automatically parses application/json and application/x-www-form-urlencoded out of the box; for multipart/form-data uploads you need to register @fastify/multipart; for XML or other formats you register a custom content type parser via fastify.addContentTypeParser. All three are plain JavaScript objects.

The crucial difference from Express is type coercion via the schema: in Express, request.query.page is always the string '2' and you must parseInt it yourself, but in Fastify if you declare page as integer in the querystring schema, Ajv coerces it to a real number before the handler runs. The same applies to booleans, arrays (using the array type with collectionFormat or csv-style parsing), and dates (with format: 'date-time'). This eliminates an entire category of off-by-string-vs-number bugs that plague Express codebases.

fastify.get('/users/:id/posts', {
  schema: {
    params: { type: 'object', properties: { id: { type: 'integer' } } },
    querystring: { type: 'object', properties: { page: { type: 'integer', default: 1 } } },
  },
}, async (req) => {
  // req.params.id is a number (not a string)
  // req.query.page defaults to 1 if omitted
  return await db.posts(req.params.id, req.query.page);
});
Q5

What is a Fastify plugin and how is it different from Express middleware?

BasicPlugins

Answer

A plugin in Fastify is an async function that receives the Fastify instance and an options object as arguments. Plugins are the unit of composition in Fastify, they register routes, hooks, decorators, content type parsers, error handlers, and even other plugins. The fundamental difference from Express middleware is encapsulation: plugins create a child scope by default, so anything you register inside a plugin (routes, hooks, decorators) is only visible to that plugin's scope and its descendants, not to the parent application or sibling plugins.

This solves the classic Express problem where adding a logging middleware globally affects every route, including healthchecks where you don't want noise, and where one plugin can accidentally override another's request property. Encapsulation makes Fastify codebases predictable as they grow: you can ship a third-party plugin to production knowing it cannot accidentally pollute the rest of your app. To make a plugin's additions visible to the parent (a database connection, an auth helper, a Redis client, things you genuinely want everywhere), wrap the plugin function with fastify-plugin (conventionally imported as fp). The rule of thumb that experienced Fastify developers internalize: feature plugins (route groups) should NOT use fp, infrastructure plugins (db, redis, auth) SHOULD use fp.

// plugins/db.js
import fp from 'fastify-plugin';

async function dbPlugin(fastify, opts) {
  const pool = await createPool(opts.connectionString);
  fastify.decorate('db', pool);
  fastify.addHook('onClose', async () => pool.end());
}

// Without fp: only routes registered AFTER this plugin can use fastify.db
// With fp: fastify.db is visible globally
export default fp(dbPlugin, { name: 'db' });

Key Points

  • Plugins are encapsulated by default
  • fastify-plugin (fp) breaks encapsulation when needed
  • Plugin order matters, children see parent's decorators only
Q6

How do hooks work in Fastify and what are the most common hook types?

BasicHooks

Answer

Hooks are lifecycle callbacks that run at specific points during a request or application lifecycle. The request lifecycle hooks, in order, are: onRequest, preParsing, preValidation, preHandler, preSerialization, onSend, onResponse, onTimeout, onError. Each hook receives the request and reply objects and either an async function or a callback.

The two most-used in practice are onRequest (runs before body parsing, ideal for logging, rate limiting, IP allowlisting, and any check that does not need the request body) and preHandler (runs after validation but before your handler, ideal for authentication and authorization checks). preParsing lets you intercept and rewrite the raw request stream, used for things like decompression or end-to-end encryption. preSerialization lets you mutate the response before it is serialized to JSON, useful for adding HATEOAS links or filtering fields based on user permissions. Application-level hooks include onReady (runs once when the server is fully booted, useful for warming caches or registering with a service discovery system) and onClose (runs when the server is shutting down, essential for closing DB pools, flushing logs, and draining connections gracefully). Hooks are registered with fastify.addHook(name, handler) and respect plugin encapsulation, hooks registered inside a plugin only run for routes in that plugin's scope, which is one of Fastify's most powerful features for keeping concerns isolated.

fastify.addHook('onRequest', async (request, reply) => {
  request.startTime = Date.now();
});

fastify.addHook('onResponse', async (request, reply) => {
  const ms = Date.now() - request.startTime;
  request.log.info({ ms, url: request.url }, 'request completed');
});
Q7

What are decorators in Fastify and when should you use them?

BasicDecorators

Answer

Decorators attach values or functions to the Fastify instance, the request object, or the reply object. They are the recommended way to share resources (database connection, Redis client, auth helpers, business logic functions) across routes without resorting to module-level globals or singletons that are hard to test. Use fastify.decorate('name', value) for instance-level decorators accessible as fastify.name in any route, fastify.decorateRequest for per-request properties accessible as request.name in handlers and hooks, and fastify.decorateReply for per-reply properties.

The performance advantage is rooted in V8's optimization model: V8 uses hidden classes to optimize property access on objects, and these hidden classes are stable only if every instance has the same shape. By decorating at startup, you tell Fastify the full shape of the request and reply objects ahead of time, so V8 can generate optimized property-access code that stays hot for the lifetime of the process. Decorating inside a route handler or hook by writing request.someNewProperty = value is the classic anti-pattern, V8 sees a new hidden class on every request, deoptimizes the inline caches, and your throughput drops 20-40%. Always decorateRequest with the correct default value (null for objects, '' for strings, 0 for numbers) at startup, then mutate it in hooks.

// Good: decorate once at startup
fastify.decorate('multiply', (a, b) => a * b);
fastify.decorateRequest('userId', null);

fastify.addHook('preHandler', async (request) => {
  request.userId = await getUserIdFromToken(request.headers.authorization);
});

// Now any route can use request.userId and fastify.multiply
fastify.get('/profile', async (req) => await db.user(req.userId));
💡 Pro Tip: Always decorateRequest with a default value of the correct shape (null, '', 0), this lets V8 keep a stable hidden class for the request object.
Q8

How do you handle errors in a Fastify route?

BasicError Handling

Answer

Three options, increasing in sophistication. First, throw an error in an async handler, Fastify catches it and returns a 500 with the error's message in the response. Second, throw an instance from @fastify/sensible's httpErrors (notFound, badRequest, unauthorized, forbidden, conflict, internalServerError, and so on) to set a specific status code with a properly-formatted error response that follows the Problem Details for HTTP APIs convention (RFC 7807).

Third, register a global error handler with fastify.setErrorHandler, this is invoked for any error thrown from any handler or hook in the encapsulation scope where it was registered. Validation errors from JSON Schema automatically return 400 with a structured error message without any code from you. The setErrorHandler approach is the production standard because it lets you log errors uniformly with the request ID for traceability, redact secrets from error messages (database connection strings often leak in error messages), format the response per your API contract, emit metrics to your APM (SigNoz, Datadog), and decide which errors to expose to the client versus mask as a generic 500.

Avoid scattering try/catch in every handler, let errors bubble up and handle them centrally. The one exception is when you can recover (retry once, fall back to a cache, return a default), in which case localized try/catch is correct.

import sensible from '@fastify/sensible';
await fastify.register(sensible);

fastify.get('/users/:id', async (req, reply) => {
  const user = await db.findUser(req.params.id);
  if (!user) throw fastify.httpErrors.notFound('User does not exist');
  return user;
});

fastify.setErrorHandler((error, request, reply) => {
  request.log.error({ err: error }, 'Request failed');
  if (error.validation) return reply.status(400).send({ error: 'Invalid input', details: error.validation });
  reply.status(error.statusCode || 500).send({ error: error.message });
});
Q9

What is the role of the logger in Fastify and why is Pino used?

BasicLogging

Answer

Fastify ships with Pino, the fastest JSON logger for Node.js, as a first-class dependency, also written by Matteo Collina, the Fastify creator. The logger is enabled by passing { logger: true } or a config object to the Fastify factory. Pino logs are JSON by default and asynchronous, they write to stdout via a transport worker thread, so the request handler is never blocked on I/O even under heavy logging.

Benchmarks show Pino logs roughly 5x faster than Winston and 10x faster than Bunyan. Every request automatically gets a child logger accessible via request.log with a unique reqId injected, so request.log.info(msg) emits a log line tagged with the request ID, letting you trace a single request across all the log lines it produces in your log store. In production, ship logs to stdout and let your container runtime (Kubernetes, Docker) or a sidecar (Vector, Fluent Bit, OpenTelemetry Collector) forward them to your log store (ELK, Datadog, SigNoz, which GoodSpace itself uses).

Never write logs to files in containerized production, the container filesystem is ephemeral and your logs disappear on restart. For local development, pipe stdout to pino-pretty for human-readable colorized output. Always configure the redact option to strip authorization headers, cookies, and any field that could contain PII or secrets before logs leave the process.

const fastify = Fastify({
  logger: {
    level: process.env.LOG_LEVEL || 'info',
    transport: process.env.NODE_ENV !== 'production'
      ? { target: 'pino-pretty' }
      : undefined,
    redact: ['req.headers.authorization', 'req.headers.cookie'],
  },
});
Q10

How do you enable CORS in a Fastify application?

BasicCORS

Answer

Use the official @fastify/cors plugin, which handles preflight (OPTIONS) requests and sets the appropriate Access-Control-* response headers. Register it before any routes that need CORS support, registration order matters in Fastify because plugin encapsulation means CORS hooks only apply to routes registered after the CORS plugin within the same scope. For production, set origin to an explicit list of allowed domains.

Never set origin to true (which echoes the request Origin header back, effectively allowing any site to call your API with cookies, defeating CORS protection) or use the wildcard '*' if you need cookies. If you need to allow credentials (cookies, Authorization header), set credentials: true and origin must be a specific list, never a wildcard, the browser will reject the response otherwise. For dynamic origin lists (multi-tenant SaaS with per-tenant custom domains), origin can be a function that returns a boolean given the request Origin header.

Set maxAge to a reasonable value (24 hours = 86400 seconds is common) to let the browser cache the preflight response, reducing OPTIONS round-trips on subsequent requests. In Indian SaaS deployments where the web app and API often live on different subdomains (app.company.com and api.company.com), CORS configuration is the single most common source of 'works in postman but not in browser' bugs.

import cors from '@fastify/cors';

await fastify.register(cors, {
  origin: ['https://goodspace.ai', 'https://app.goodspace.ai'],
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  maxAge: 86400,
});
Q11

What is the difference between reply.send() and returning a value from a handler?

BasicResponses

Answer

They are equivalent for the common case: return value is identical to reply.send(value). The handler return form is more idiomatic in async functions because it composes naturally with await and lets the return type be inferred for TypeScript codebases. Use reply.send() explicitly in two scenarios: when you need to send a response before doing additional async work (like firing off a non-blocking analytics call after the response is committed), or when sending from a non-async callback (event-driven legacy code, though this is rare in 2026).

The one subtle gotcha is that if you both return a value AND call reply.send() in the same handler, Fastify logs a warning ('Reply already sent') because send was effectively called twice and the second call is a no-op but indicates a logic bug. Pick one style per route. The other related gotcha: never await reply.send(), it returns the reply object itself, not a Promise, so awaiting it is meaningless and confuses readers into thinking it might be async. For async handlers in modern Fastify code, returning the value is preferred and reply.send() is reserved for the niche cases above.

// Idiomatic: return
fastify.get('/a', async () => ({ ok: true }));

// Equivalent
fastify.get('/b', async (req, reply) => { reply.send({ ok: true }); });

// BUG: double send, Fastify warns
fastify.get('/c', async (req, reply) => {
  reply.send({ ok: true });
  return { other: true }; // ignored, warning logged
});
Q12

How do you set HTTP status codes and custom response headers in Fastify?

BasicResponses

Answer

Use reply.code(status) or its alias reply.status(status), reply.header(name, value) for a single header, and reply.headers(object) for multiple headers in one call. All three methods return the reply object so you can chain them fluently. For predictable cases, for example, every successful POST to /items should return 201, declare the default status code on the route definition rather than imperatively in the handler, which keeps the route shape declarative and lets @fastify/swagger document it correctly.

The default content-type is application/json with charset=utf-8; for plain text, HTML, CSV, or XML responses, set the content-type header explicitly with reply.type('text/html') (a shortcut for setting the Content-Type header). To send a redirect, use reply.redirect(statusCode, url), the status code defaults to 302 (temporary) if omitted, but for permanent moves you should explicitly pass 301 so search engines update their indexes. Setting cache headers correctly is often forgotten: reply.header('Cache-Control', 'public, max-age=3600') for cacheable responses and 'no-store' for sensitive data. The ETag header for conditional GETs can be set with @fastify/etag.

fastify.post('/items', async (req, reply) => {
  const item = await db.create(req.body);
  reply
    .code(201)
    .header('Location', `/items/${item.id}`)
    .header('X-RateLimit-Remaining', '99');
  return item;
});

fastify.get('/old-path', async (req, reply) => {
  return reply.redirect(301, '/new-path');
});
Q13

Explain Fastify's plugin encapsulation model and how to break it intentionally.

IntermediatePlugins

Answer

Encapsulation is Fastify's most distinctive and load-bearing feature. When you call fastify.register(plugin), Fastify creates a child context that inherits from the parent but has its own scope: any decorators, hooks, error handlers, content type parsers, or routes registered inside that plugin are scoped to it and cannot be seen by sibling plugins or by the parent application. This is structurally the opposite of Express, where everything you register is global and the only way to scope is by mounting sub-routers on a path prefix.

Encapsulation is genuinely useful, it prevents one plugin from accidentally polluting another's hooks, lets you register a third-party plugin (say, an OAuth helper with its own session middleware) without worrying that it will affect unrelated routes, and makes large codebases easier to reason about because each plugin's effects are bounded. But sometimes you genuinely want a decorator to be visible everywhere, a DB connection pool, an auth helper, a Redis client, a feature flag service. The escape hatch is fastify-plugin (imported as fp), which wraps your plugin function and tells Fastify to skip the encapsulation step. fp also lets you declare a name and a list of dependencies, which Fastify validates at boot, if your plugin requires 'db' but it was never registered, boot fails fast instead of failing at first request.

Rule of thumb: route plugins (containing only routes) should NOT use fp; infrastructure plugins (DB, auth, Redis, metrics) SHOULD use fp. When debugging 'why can't this route see my decorator' bugs, run fastify.printPlugins() to see the tree of encapsulation contexts.

// Without fp, encapsulated, can't be seen outside
async function adminRoutes(fastify) {
  fastify.get('/admin/stats', async () => stats());
}
fastify.register(adminRoutes, { prefix: '/api/v1' });

// With fp, escapes encapsulation
import fp from 'fastify-plugin';
export default fp(async function authPlugin(fastify) {
  fastify.decorate('verifyToken', async (token) => jwt.verify(token, SECRET));
}, { name: 'auth' });

Key Points

  • Plugins create a child context by default
  • fp (fastify-plugin) opts out of encapsulation
  • Route plugins: no fp. Infra plugins: use fp
Q14

Why does Fastify use schema-based serialization and how much faster is it than JSON.stringify?

IntermediatePerformance

Answer

JSON.stringify in V8 is generic, it traverses every enumerable property of an object, dispatches on type at each property, calls toString or toJSON if defined, escapes strings character by character, and handles every JavaScript type including Date, BigInt, and circular references. All this dispatching has runtime cost. Fastify uses fast-json-stringify, also written by Matteo Collina, which generates a specialized serializer function from a JSON Schema at startup.

The generated code knows exactly which properties exist, what type each is, and the exact order to write them, so it skips type checks, skips property enumeration, and emits a long chain of string concatenations. Imagine the difference between a generic interpreter and JIT-compiled code that has been specialized for one input shape. Benchmarks show 2-5x speedup for typical API responses, sometimes 10x for deeply-nested objects with many nullable fields.

The trade-off is that extra fields in your response object that are NOT in the schema are silently dropped from the serialized output. This is actually a security feature in disguise, it prevents accidentally leaking internal fields like password_hash, internal_score, stripe_customer_id, or admin_notes when you forgot to project them out at the query layer. To get the full speedup, attach a response schema to every route that handles meaningful production traffic. Fastify will fall back to JSON.stringify only for status codes where you didn't define a schema, so the safety net is there but you should not rely on it.

// Schema strips internal_score and admin_notes
fastify.get('/users/:id', {
  schema: {
    response: {
      200: {
        type: 'object',
        properties: {
          id: { type: 'integer' },
          email: { type: 'string' },
          name: { type: 'string' },
        },
      },
    },
  },
}, async (req) => {
  return await db.findUser(req.params.id); // returns full row, internal_score dropped
});
💡 Pro Tip: Run a quick benchmark with autocannon before and after adding schemas, you will usually see 30-50% more RPS on JSON-heavy endpoints.
Q15

How does the Fastify hook execution order work, especially with multiple plugins?

IntermediateHooks

Answer

Within a single plugin scope, hooks run in registration order for each lifecycle phase, first registered, first executed. Across nested plugins, parent hooks run before child hooks for incoming phases (onRequest, preParsing, preValidation, preHandler) and after child hooks for outgoing phases (preSerialization, onSend, onResponse). This is identical to how middleware stacks unwind in Express or how try/finally blocks work, outer wraps inner.

So if you have a logging onRequest in the root app and an auth onRequest in a child plugin, the logging runs first for any route inside that child, but the response logging in the root runs LAST after the child's onResponse. The catch that catches every Fastify developer at least once: a hook registered inside a plugin that uses fp (escaped encapsulation) actually attaches at the level where the fp plugin was registered, not where the function body runs. This is the most common source of 'why is my auth hook not running on these routes' bugs, the auth plugin used fp, so the preHandler hook is attached at the root level, but you registered the route plugin BEFORE the auth plugin, so the route already had its hook chain compiled without auth.

The fix is registration order: infrastructure plugins (auth, db, logging) first, route plugins last. To debug hook order, run fastify.printPlugins() to see the load tree, or temporarily add request.log.info('hook X fired') statements in each hook and trace a single request through the logs.

fastify.addHook('onRequest', async () => console.log('1. outer onRequest'));
fastify.register(async (app) => {
  app.addHook('onRequest', async () => console.log('2. inner onRequest'));
  app.addHook('onResponse', async () => console.log('3. inner onResponse'));
  app.get('/test', async () => ({}));
});
fastify.addHook('onResponse', async () => console.log('4. outer onResponse'));

// Order: 1, 2, handler, 3, 4
Q16

How do you implement JWT authentication in Fastify?

IntermediateAuthentication

Answer

Use the @fastify/jwt plugin, which wraps jsonwebtoken and integrates with Fastify's decorator and hook systems. Register it with a secret loaded from environment variables, never hardcoded in source, never committed to git. The plugin decorates the fastify instance with jwt.sign for issuing tokens and the request object with jwtVerify for verifying them.

For protected routes, decorate the fastify instance with an authenticate helper function and add it as a preHandler, if the token is missing, expired, or invalid, jwtVerify throws automatically and Fastify returns 401 without further work from you. For role-based access control (admin vs user, read-only vs write), decode the payload claims and check the role on the user object before allowing the handler to run. The standard pattern in 2026 is short-lived access tokens (15 minutes is the common Razorpay/Cred-style choice) plus long-lived refresh tokens (7-30 days) issued from a /token/refresh endpoint.

Store refresh tokens as HttpOnly, Secure, SameSite=Strict cookies, never in localStorage where any XSS bug can steal them and replay them. Use asymmetric keys (RS256) instead of HMAC (HS256) when multiple services need to verify tokens without sharing the signing secret, Fastify supports both via the algorithm option. Rotate signing keys periodically and use the jose JWK Set endpoint pattern if you have a sufficiently complex multi-service architecture.

import jwt from '@fastify/jwt';

await fastify.register(jwt, { secret: process.env.JWT_SECRET });

fastify.decorate('authenticate', async (request, reply) => {
  try {
    await request.jwtVerify();
  } catch (err) {
    reply.code(401).send({ error: 'Unauthorized' });
  }
});

fastify.get('/me', { preHandler: [fastify.authenticate] }, async (req) => {
  return { userId: req.user.sub };
});

fastify.post('/login', async (req, reply) => {
  const user = await verifyCredentials(req.body);
  const token = fastify.jwt.sign({ sub: user.id }, { expiresIn: '15m' });
  return { token };
});

Key Points

  • Short-lived access tokens (15 minutes)
  • Refresh tokens in HttpOnly cookies
  • Use preHandler hook for auth checks
  • Decorate authenticate function once, reuse everywhere
Q17

How do you organize a Fastify app with autoload to scale to dozens of routes?

IntermediateArchitecture

Answer

@fastify/autoload scans a directory and registers everything matching .js or .ts (or .cjs/.mjs in mixed codebases) as a Fastify plugin. The conventional structure that almost every production Fastify app converges on is two top-level folders: plugins/ for infrastructure (db, redis, auth, swagger, sensible, env) and routes/ for HTTP endpoints organized by resource. You register autoload twice in your bootstrap file, once for plugins (without a prefix) and once for routes (with /api/v1 or similar as the prefix).

The order matters: load plugins first, then routes, so decorators added by plugins are available when routes register. Each file in routes/ becomes a route group whose prefix matches the directory layout, routes/users/index.js mounts at /api/v1/users, routes/users/[id]/index.js mounts at /api/v1/users/:id (the brackets are autoload's convention for path parameters). This is the standard structure used in the fastify-cli generator (`npm create fastify@latest`) and most enterprise Fastify codebases including the open-source examples Walmart and Microsoft publish.

The benefits compound as the app grows: zero glue code per new feature, every new resource is a new directory, the dependency order is enforced by directory layout instead of being hidden in an index file, and new team members can find any route by name in seconds. Use autoload's options.dirNameRoutePrefix flag if you need to skip directories that don't follow the prefix convention (utility folders, shared helpers).

// app.js
import autoload from '@fastify/autoload';
import { fileURLToPath } from 'url';
import path from 'path';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

fastify.register(autoload, { dir: path.join(__dirname, 'plugins') });
fastify.register(autoload, {
  dir: path.join(__dirname, 'routes'),
  options: { prefix: '/api/v1' },
});

// routes/users/index.js mounts at /api/v1/users
export default async function (fastify) {
  fastify.get('/', async () => await fastify.db.listUsers());
  fastify.get('/:id', async (req) => await fastify.db.findUser(req.params.id));
}
Q18

How do you connect Fastify to PostgreSQL in production?

IntermediateDatabase

Answer

The mature pattern in 2026 is @fastify/postgres (which wraps node-postgres, the pg library) or @fastify/mysql for MySQL. Both expose a connection pool decorated as fastify.pg or fastify.mysql so any route or hook can access it without imports. Configure pool size based on your expected concurrency, start with 10 connections for a typical mid-size service, scale up to 20-30 if your logs show queue waits or your /metrics endpoint shows pool exhaustion.

Never share a transaction across requests; always check out a dedicated client from the pool via fastify.pg.connect(), run the BEGIN/queries/COMMIT, and release the client in a finally block. The connect-query-release pattern is the most common source of pool exhaustion bugs in Node services, forget the release and the pool fills up with leaked connections, eventually hanging the service. For complex queries you can layer a SQL builder: Knex.js is the legacy choice, Kysely is the type-safe modern choice favored in TypeScript Fastify codebases (it generates types directly from your schema).

Drizzle ORM is also gaining traction for its developer experience. For very high RPS deployments, anything above a few thousand requests per second, put PgBouncer in transaction-pooling mode in front of Postgres. Your Fastify connections then talk to PgBouncer, which multiplexes them onto far fewer real Postgres connections. This is how Razorpay, Cred, and most fintech in India scale their Postgres tier.

import postgres from '@fastify/postgres';

await fastify.register(postgres, {
  connectionString: process.env.DATABASE_URL,
  max: 10, // pool size
});

fastify.get('/orders/:id', async (req) => {
  const { rows } = await fastify.pg.query(
    'SELECT id, total, status FROM orders WHERE id = $1',
    [req.params.id]
  );
  return rows[0];
});
💡 Pro Tip: Use parameterized queries ($1, $2) always, string interpolation is a SQL injection vector and also defeats the query plan cache.
Q19

How do you write unit and integration tests for Fastify?

IntermediateTesting

Answer

Fastify ships with an inject method on the instance that simulates HTTP requests without opening a real port. This is fast (no socket overhead, no TCP handshake) and lets you assert on responses just like a real HTTP client. Use it with any test runner: node --test (the Node 20+ built-in test runner, increasingly the default), Vitest (fast, modern, ESM-friendly), or Jest (still common in legacy codebases).

The standard pattern is a build function that constructs and returns the Fastify instance without calling listen, exported from a buildApp.js module and called from both app.js (the production entry that does call listen) and from each test file. This keeps test setup minimal and ensures tests exercise the exact same plugin tree as production. For database tests, you have three options of increasing realism: (1) use an in-memory SQLite fixture or @fastify/postgres pointed at testcontainers-spawned Postgres, (2) wrap each test in a transaction that always rolls back, so tests don't pollute each other, (3) use a fresh schema per test worker for parallel test runs.

Snapshot tests on the OpenAPI spec generated by @fastify/swagger are a quick way to catch accidental API changes, pin the spec in a snapshot, and any unintentional breakage shows up as a failing test. For external dependencies (Stripe, SendGrid, AWS), use nock or msw to intercept HTTP calls at the request layer rather than mocking the SDK, this catches more bugs and is closer to production behavior.

// build.js
export function build(opts = {}) {
  const fastify = Fastify(opts);
  fastify.register(routes);
  return fastify;
}

// test/users.test.js
import { test } from 'node:test';
import assert from 'node:assert';
import { build } from '../build.js';

test('GET /users/:id', async () => {
  const app = build();
  await app.ready();
  const res = await app.inject({ method: 'GET', url: '/users/1' });
  assert.equal(res.statusCode, 200);
  assert.equal(res.json().id, 1);
  await app.close();
});
Q20

How do you implement rate limiting in Fastify?

IntermediateRate Limiting

Answer

Use @fastify/rate-limit, the official rate-limiting plugin. Register it globally for site-wide protection, or attach config per route via the route's config.rateLimit object for finer control. The default storage is in-memory, which works fine for single-instance deployments but breaks the moment you scale to multiple replicas behind a load balancer because each instance maintains its own counters.

For multi-instance production, required at any Razorpay or Postman scale, back the rate limiter with Redis using the @fastify/redis plugin. The Redis-backed store uses atomic INCR and EXPIRE commands inside a Lua script so counts are consistent across replicas without race conditions. Common policy choices in India: 60 requests per minute per IP for unauthenticated endpoints, 600 requests per minute per authenticated user, 10 requests per second for expensive endpoints like search, and 5 requests per minute for password reset and OTP endpoints.

Customize the key generator if you want to rate-limit per JWT subject rather than per IP, most SaaS apps want per-user limits because IP-based limits hurt users behind shared NATs (offices, Indian ISP CGNATs). When trustProxy is enabled, the limiter respects the X-Forwarded-For header. For attack scenarios (credential stuffing, scraping), consider a layered approach: cheap per-IP limits at the CDN/Cloudflare layer, fine-grained per-user limits in Fastify, and dynamic blocklisting via fail2ban-style log analysis.

import rateLimit from '@fastify/rate-limit';
import redis from '@fastify/redis';

await fastify.register(redis, { url: process.env.REDIS_URL });
await fastify.register(rateLimit, {
  max: 100,
  timeWindow: '1 minute',
  redis: fastify.redis,
  keyGenerator: (req) => req.user?.sub || req.ip,
});

fastify.get('/search', {
  config: { rateLimit: { max: 10, timeWindow: '1 second' } },
}, async (req) => await search(req.query.q));
Q21

How do you generate OpenAPI documentation for a Fastify API?

IntermediateDocumentation

Answer

Use @fastify/swagger plus a UI renderer: either @fastify/swagger-ui (the classic Swagger UI) or @fastify/scalar-api-reference (Scalar, a more modern and visually polished alternative). The plugin reads every route's schema definition and assembles a complete OpenAPI 3.1 document at boot time. Tags, summaries, descriptions, example responses, security requirements, and external doc links can be added to the schema object alongside body and response.

The decisive advantage over hand-written OpenAPI specs is that the spec cannot drift from the implementation, it is generated from the same JSON Schemas that validate incoming requests at runtime, so a route that handles 'POST /users with body {email, age}' produces an OpenAPI operation that matches exactly. If you add a new field to the schema, the docs update automatically with no manual step. For production, protect the docs endpoint behind auth (basic auth via @fastify/basic-auth or behind your normal JWT) or disable it entirely with conditional registration.

Exposing your full API surface, including admin endpoints, internal-only routes, and undocumented experimental APIs, to scrapers, competitors, and security researchers is rarely useful. A common pattern is to expose the public-facing endpoints at /docs and keep the full internal API surface at /internal-docs behind office-IP allowlist or VPN. Use the openapi.security and openapi.components.securitySchemes options to document auth requirements alongside the endpoint definitions.

import swagger from '@fastify/swagger';
import swaggerUI from '@fastify/swagger-ui';

await fastify.register(swagger, {
  openapi: {
    info: { title: 'GoodSpace API', version: '1.0.0' },
    servers: [{ url: 'https://api.goodspace.ai' }],
  },
});
await fastify.register(swaggerUI, { routePrefix: '/docs' });

fastify.get('/users/:id', {
  schema: {
    tags: ['users'],
    summary: 'Get a user by ID',
    params: { type: 'object', properties: { id: { type: 'integer' } } },
    response: { 200: userResponseSchema },
  },
}, getUser);
Q22

What is the difference between fastify.register() and fastify.use()?

IntermediatePlugins

Answer

fastify.register(plugin, options) is the native Fastify way to add functionality, it respects encapsulation, integrates with the async boot order via avvio, and gives the plugin access to the full Fastify API including decorators, hooks, and route definition methods. fastify.use(middleware) is a compatibility shim provided by the @fastify/middie or @fastify/express plugins that lets you mount Express-style middleware (functions with the signature (req, res, next)). Use it only when you need an Express plugin that has no Fastify equivalent, which is rare in 2026 since almost every popular middleware (cors, helmet, compression, multer, body parsers, session handling, passport strategies) has been ported to native Fastify plugins. The performance cost is small but measurable: Express-style middleware doesn't have access to the schema or request context that Fastify uses to optimize, so use is roughly 10-15% slower than equivalent native plugins, and the middleware function itself is invoked as a raw Express-style callback rather than being compiled into the hook chain.

Another subtle gotcha: middleware mounted via use runs BEFORE any Fastify hooks for that route, which can confuse error handling, an error thrown in middleware is handled by Fastify's error machinery, but logging might happen before or after depending on which hook the logger is attached to. For new code in 2026, use register only. The Fastify plugin ecosystem is mature enough that you rarely need to drop down to Express middleware.

// Native, preferred
await fastify.register(import('@fastify/cors'), { origin: 'https://goodspace.ai' });

// Express compatibility, when you must
import middie from '@fastify/middie';
import someExpressMiddleware from 'some-legacy-package';
await fastify.register(middie);
fastify.use(someExpressMiddleware());
Q23

How do you stream a large file or response in Fastify?

IntermediateStreaming

Answer

Return a Node.js Readable stream from the handler, Fastify pipes it to the response automatically and handles backpressure correctly. For static files, register @fastify/static, which handles range requests for video seeking, ETag generation, Last-Modified headers, content-type detection from file extension, and gzip pre-compressed sibling files (file.css.gz served when the client accepts gzip). For dynamic streams (CSV exports of millions of rows, LLM streaming responses, real-time activity feeds), create a Readable with stream.Readable.from(asyncGenerator), the async generator yields chunks and Fastify writes them as they arrive.

For Server-Sent Events, set the content-type to text/event-stream and yield 'data: <json>\n\n' formatted chunks (the double newline is mandatory by the SSE spec). Always set the X-Accel-Buffering: no response header if you sit behind nginx, without it, nginx buffers up to 8KB or more before flushing to the client, breaking the real-time streaming UX. Also set Cache-Control: no-cache and Connection: keep-alive headers.

Fastify automatically handles the Transfer-Encoding: chunked header. For very large file downloads (multi-gigabyte exports), stream from a database cursor rather than loading the full result into memory, this is essential for keeping memory usage flat regardless of result size. Combine with reply.raw access if you need to write headers after streaming has started (rare but useful for trailers).

import { Readable } from 'stream';

fastify.get('/users.csv', async (req, reply) => {
  async function* generate() {
    yield 'id,email,name\n';
    for await (const row of fastify.pg.queryStream('SELECT id, email, name FROM users')) {
      yield `${row.id},${row.email},${row.name}\n`;
    }
  }
  reply.type('text/csv').header('Content-Disposition', 'attachment; filename=users.csv');
  return Readable.from(generate());
});
Q24

How do you handle WebSocket connections in Fastify?

IntermediateWebSockets

Answer

Use @fastify/websocket, which wraps the ws library (the standard WebSocket implementation for Node.js) and integrates it with Fastify's route system. Register the plugin, then declare a route with websocket: true in the route options. The handler receives a connection object containing a socket you can read from and write to using the standard ws API: socket.send(msg), socket.on('message', handler), socket.on('close', handler).

For broadcasting use cases (chat rooms, live notifications, collaborative editing), maintain a Map of connected sockets keyed by user or room ID inside a fp-decorated plugin so all routes share the same registry. For multi-instance deployments, which is mandatory at any production scale because you cannot run a single-instance WebSocket server in serious production, use a pub/sub layer (Redis Pub/Sub or RabbitMQ fanout exchange) so each Fastify instance can broadcast to its locally-connected sockets when an event fires anywhere in the cluster. Don't try to share socket state across instances (the sockets themselves can't be serialized); share events instead and let each instance route them to its own connections.

Set up a ping/pong heartbeat to detect zombie connections, clients sometimes disconnect without sending a close frame, especially on mobile networks like Indian 4G that drop intermittently. Implement reconnect-with-backoff on the client side, and consider using a higher-level abstraction like Socket.IO if you need rooms, namespaces, and auto-reconnect out of the box (though Socket.IO has its own Fastify adapter and more overhead than raw ws).

import websocket from '@fastify/websocket';
await fastify.register(websocket);

const clients = new Map(); // userId -> WebSocket

fastify.get('/ws', { websocket: true }, (connection, req) => {
  const userId = req.query.userId;
  clients.set(userId, connection.socket);
  connection.socket.on('message', (msg) => {
    // broadcast to other clients
    for (const [id, sock] of clients) {
      if (id !== userId) sock.send(msg);
    }
  });
  connection.socket.on('close', () => clients.delete(userId));
});
Q25

How do you handle environment-specific configuration in Fastify?

IntermediateConfiguration

Answer

Two production-quality patterns are standard in 2026. First, @fastify/env validates a process.env-shaped JSON Schema at boot time and decorates fastify.config with the typed, validated values. If a required variable is missing or has the wrong type, the server refuses to start, this fail-fast behavior is far better than the alternative where your app boots successfully and crashes on the first request that tries to use the missing variable, often hours later in production after the deploy is already 'live'.

Second, use dotenv-flow or zod-based loaders for hierarchical .env files (.env, .env.production, .env.local) when you need finer-grained control over which file overrides which in different environments. Both approaches keep secrets out of code and out of version control. Never use the @fastify/env plugin for runtime-mutable config, its values are loaded once at boot and decorated as constants.

For feature flags that need to change at runtime without a restart, use a separate plugin (Unleash, ConfigCat, LaunchDarkly, or a custom Redis-backed flag store). In India, many companies store secrets in Infisical (open source, increasingly popular), AWS Secrets Manager, Google Secret Manager, or HashiCorp Vault and inject them as environment variables via the container runtime at startup, this works seamlessly with @fastify/env because by the time Node starts, the env is already populated. Never commit a .env file to git, and add it to .gitignore on day one of the project, git history is forever, and accidentally committed secrets are extremely painful to rotate.

import env from '@fastify/env';

const schema = {
  type: 'object',
  required: ['DATABASE_URL', 'JWT_SECRET'],
  properties: {
    PORT: { type: 'integer', default: 3000 },
    DATABASE_URL: { type: 'string' },
    JWT_SECRET: { type: 'string', minLength: 32 },
    LOG_LEVEL: { type: 'string', default: 'info' },
  },
};
await fastify.register(env, { schema, dotenv: true });
// fastify.config.PORT, fastify.config.DATABASE_URL now available everywhere
Q26

How would you architect a Fastify service to handle 20,000+ requests per second?

AdvancedArchitecture

Answer

20,000 RPS per Node.js process is achievable with Fastify but requires every layer to cooperate, and you really should think of this as 20k RPS per instance with horizontal scaling on top. First, use Node 20+ LTS and run with cluster mode via PM2 or the built-in Node Cluster API, one worker process per CPU core. Fork-based clustering bypasses Node's single-threaded limit and is the simplest way to use a multi-core machine.

Second, attach response schemas to every route, without them you lose the fast-json-stringify advantage and JSON.stringify becomes the dominant CPU cost on a typical JSON API. Third, route database access through PgBouncer in transaction-pooling mode, because direct Postgres connections cap out around 100-200 per host before context-switching overhead dominates; PgBouncer multiplexes thousands of client connections onto a small pool of real Postgres connections. Fourth, cache aggressively in Redis with cache-aside pattern, a 50ms Postgres query becomes a 1ms Redis hit; that alone can 50x your throughput on hot reads.

Use @fastify/redis with a connection pool. Fifth, profile with clinic.js or 0x to find hot spots, almost every Fastify service has one synchronous bcrypt call or synchronous JSON.parse on a large payload that's eating 30% of CPU; move it to a worker thread or replace with an async equivalent. Sixth, pin the V8 max old space to roughly 75% of your container memory limit (NODE_OPTIONS=--max-old-space-size=1536 for a 2GB container) so V8 has headroom to GC without OOM-killing.

Seventh, use HTTP keep-alive aggressively on your upstream connections to Postgres, Redis, and any HTTP services you call, connection establishment dominates latency on short requests. At Razorpay and Postman, services in this shape run behind a Layer 7 load balancer (nginx, AWS ALB, or GCP Application Load Balancer) with 4-16 Fastify instances per region, autoscaled based on CPU and request queue depth.

Key Points

  • Node cluster: one process per core
  • Response schemas everywhere (for fast-json-stringify)
  • PgBouncer in front of Postgres
  • Redis cache-aside for hot reads
  • Profile with clinic.js to find sync calls
  • Pin V8 max-old-space to container limit
Q27

How do you debug a memory leak in a Fastify production service?

AdvancedPerformance

Answer

Memory leaks in Fastify services almost always come from one of three sources, in rough order of frequency. First, unbounded in-memory caches, a Map keyed by user ID that grows forever because nothing ever removes entries. Second, event listeners or hooks registered per-request that are never removed, so the listener list grows on every request and each listener keeps closure references alive.

Third, closures inside decorateRequest or hooks that capture large objects (the entire request body, a database result set) and leak them into long-lived state. Diagnosis flow that works in production: enable --inspect on a single instance during low-traffic hours, take three heap snapshots ten minutes apart using Chrome DevTools or the new Node.js inspector protocol, then use the 'Comparison' view to see which constructors and which retained sizes grew between snapshots. If you see strings or generic objects without an obvious owner, it's usually a closure or listener leak. clinic.js heap-profiler is the easiest production-safe tool because it samples allocations without pausing the event loop, so it can run on a live serving instance for a few minutes without impacting traffic.

Once you identify the leak, common fixes: replace ad-hoc Map caches with lru-cache (which has both max size and TTL), explicitly removeListener in an onResponse hook for any listener you added in onRequest, and never store request, reply, or req.body in module-level variables. A baseline 'steady-state after warm-up' RSS that stays roughly flat over hours is your goal, V8 will grow heap to fill available memory normally so look at the trend, not the absolute value. Also set NODE_OPTIONS=--heapsnapshot-near-heap-limit=3 so Node automatically writes a heap snapshot just before OOM, which is invaluable for post-mortem analysis when production crashes at 3am.

💡 Pro Tip: Set NODE_OPTIONS=--heapsnapshot-near-heap-limit=3 in production, Node will dump a heap snapshot just before OOM, invaluable for post-mortem analysis.
Q28

Explain Fastify's avvio plugin loader and how it controls startup order.

AdvancedInternals

Answer

avvio is the async bootstrap library Matteo Collina wrote specifically for Fastify and later open-sourced for general use. It treats register() calls as a tree of async functions that must resolve before listen() returns. When you call fastify.register(pluginA) then fastify.register(pluginB), avvio guarantees that pluginA finishes initializing (including any nested registrations it does in turn) before pluginB starts.

Inside a plugin, you can register more plugins, they execute as a nested batch before the parent's after() callbacks run. This guarantee is what makes Fastify safe and predictable: by the time a request hits a route, every plugin and decorator the route depends on is already fully loaded and ready, so there's no need for ad-hoc 'is the database connected yet' checks scattered through the code. The pitfall that catches people: top-level await in your bootstrap file can race with avvio's internal queue if you await something AFTER calling register but before calling listen, the register has scheduled work that hasn't run yet, and your await sees the pre-registration state.

Always either await all the register calls in order (modern async/await style) or use fastify.after(callback) for explicit sequencing of code that needs to run between plugin loads. To inspect the load tree, run console.log(await fastify.printPlugins()), this emits an indented tree showing exactly what loaded when, with timing information per plugin. It's invaluable for debugging slow boot times (you'll often find one slow plugin that's blocking 5 seconds on a DNS lookup), for understanding which plugins are encapsulated versus global, and for sanity-checking that fp-wrapped infrastructure plugins are visible everywhere they should be.

fastify.register(dbPlugin);
fastify.register(authPlugin); // sees fastify.db (db loaded first)
fastify.register(async (app) => {
  app.register(routesPlugin); // sees both fastify.db and fastify.authenticate
});

console.log(await fastify.printPlugins()); // prints load tree with timing
Q29

How do you implement multi-tenancy in a Fastify SaaS application?

AdvancedArchitecture

Answer

Three patterns, all common in production multi-tenant systems. First, row-level tenancy: every table has a tenant_id column, and every query filters by it. In Fastify, decorate the request with the tenant ID resolved from the JWT in a preHandler hook, then have your repository or service layer require tenant_id as a mandatory function argument.

The risk is that one query missing the filter leaks data across tenants, a catastrophic bug. Mitigations: use Postgres Row-Level Security (RLS) policies so the database itself enforces tenant isolation even if app code forgets the filter; write a test that audits every query in the codebase for tenant_id presence using ESLint custom rules or AST analysis; review every PR with a 'multi-tenant safety' checklist. Second, schema-per-tenant: one Postgres schema per tenant, with SET search_path executed in an onRequest hook before the rest of the request runs.

Best isolation, scales comfortably to thousands of tenants, but gets painful at tens of thousands because migrations need to run across all schemas. Third, database-per-tenant: full isolation, expensive operationally, justified only for regulated industries (banking, healthcare, defense) where a leak is unacceptable. For most Indian SaaS, HRTech, vertical SaaS, fintech below the PSP regulatory threshold, row-level with Postgres RLS is the sweet spot. Critical implementation details specific to Fastify: resolve the tenant in a preHandler decorated with fp so it runs for every route in every plugin; decorateRequest with tenant: null upfront to keep the V8 hidden class stable; never trust a tenant_id field from the request body, always derive it from the verified JWT claim; and add the tenant_id to your log context (Pino's child logger feature) so every log line is automatically tagged for filtering and debugging.

Q30

How does Fastify achieve such high throughput compared to Express, mechanically?

AdvancedPerformance

Answer

Four mechanical reasons, each contributing a measurable share of the overall speedup. First, the router: Fastify uses find-my-way, a radix tree router that resolves a URL path in O(k) where k is the length of the URL, regardless of how many routes you have registered. Express's router walks an array of regex patterns and matches each in turn, so its complexity is O(routes), a Fastify app with 500 routes is just as fast as one with 5, but Express's lookup time grows linearly with the route table.

Second, serialization: fast-json-stringify pre-generates a JavaScript function from your response schema at boot time. The generated function has no type checks at runtime, it just concatenates string literals with property accesses and produces the JSON output directly. JSON.stringify in V8 must do a full property walk, dispatch on type at every property, and check for toJSON methods, which is much slower for predictable shapes.

Third, the hook chain: at route registration time, Fastify compiles all the hooks (onRequest, preHandler, etc.) and the handler itself into a single function chain. Each request becomes a sequence of direct function calls. Express's middleware chain rebuilds for every request because each middleware is a function that calls next(), V8 cannot optimize this as effectively.

Fourth, the reply object: Fastify decorates the reply object up front so V8's hidden class machinery can optimize property access. Express creates a fresh res object and lets middleware dynamically add properties, which constantly invalidates V8's inline caches. Combined, these four optimizations give Fastify roughly 70k-90k RPS for a simple JSON endpoint versus 30k-40k for the equivalent Express route on the same hardware (benchmarked on a c5.large EC2 instance).

The gap widens with response size, larger payloads spend more time in serialization, where Fastify's advantage is biggest. This is why Fastify is the default choice for new performance-critical Node.js services in 2026.

Key Points

  • find-my-way radix router: O(URL length) lookup
  • fast-json-stringify: pre-compiled serializer
  • Hook chain compiled into one function per route
  • Stable hidden classes for reply object
  • Combined: 2x to 3x throughput vs Express

Companies Hiring Fastify

Microsoft
Walmart Labs
Hello Fresh
Razorpay
Postman
NerdWallet
American Express

Salary Insights

Average in India
₹7-22 LPA

Frequently Asked Questions

Should I use Fastify or Express for a new Node.js API in 2026?

For a new API, Fastify is the better default. You get JSON Schema validation, OpenAPI generation, structured logging, and 2x the throughput out of the box, all features you would otherwise bolt onto Express via ajv, express-openapi, pino-http, and so on. Express remains a reasonable choice only if your team has deep Express muscle memory and the API is throwaway/internal. Every new Indian fintech (Razorpay, Cred-style) and dev-tools company (Postman) building today picks Fastify.

How much does a Fastify developer earn in India in 2026?

₹7-22 LPA for mid-to-senior backend developers with Fastify as their primary framework. The role title is usually Node.js/Backend Engineer rather than Fastify-specific, but Fastify experience is a strong signal in interviews and resume screens at performance-conscious teams. FinTech (Razorpay, Cred) and dev-tools (Postman) pay at the upper end; mainstream SaaS pays mid-range.

Is Fastify production-ready?

Fastify has been production-ready since version 3 in 2020. Version 5 (October 2024) is the current stable line and used at Microsoft, Walmart, American Express, and dozens of Indian companies. The core team includes Node.js TSC members, releases follow semver strictly, and the LTS policy is published, pick a 5.x release for any new service.

Do I need TypeScript to use Fastify?

No, but it pays off quickly. Fastify ships first-class TypeScript types out of the box, and the @sinclair/typebox or zod-to-json-schema integrations let you write a single schema that drives runtime validation AND generates compile-time types automatically. For greenfield TypeScript projects, this combination eliminates an entire class of 'I forgot to validate that field' bugs because the request object's TypeScript type is generated from the same schema that Ajv validates at runtime, they cannot disagree. Many Indian teams (Postman, Walmart Labs, several YC-funded fintech startups) use TypeBox plus Fastify as their default backend stack precisely because of this single-source-of-truth property. The downside of TypeScript is the build step and the slightly slower iteration loop, but tsx and ts-node-esm make dev-time TypeScript essentially as fast as plain Node in 2026.

How is Fastify different from NestJS?

NestJS is an opinionated framework (TypeScript decorators, modules, dependency injection à la Angular) that runs on either Express or Fastify under the hood, Fastify is the underlying HTTP framework while NestJS adds an architectural layer on top. Fastify is the underlying framework with no opinion on application architecture: you choose your own dependency wiring style (autoload, decorators, manual factories). Pick Fastify when you want full control, minimal magic, and the freedom to structure your app however you like. Pick NestJS when you want Angular-style structure, a built-in DI container, and don't mind the conventions. NestJS running on the @nestjs/platform-fastify adapter gives you NestJS's developer experience with roughly 80% of raw Fastify's performance, the loss comes from NestJS's interceptor and guard layer, not from Fastify itself. For most teams, the choice comes down to whether you value architectural conventions (NestJS) or raw performance and flexibility (Fastify).

What changed in Fastify 5 versus Fastify 4?

Fastify 5 (released October 2024) dropped support for Node 18, requiring Node 20 LTS minimum. It switched the default JSON Schema engine to Ajv 8, removed deprecated APIs (request.connection, reply.context, request.routerPath), tightened TypeScript types significantly (catching many latent bugs in user code), and made several internal optimizations to the hook chain. Migration from Fastify 4 to 5 is usually one focused afternoon for a typical mid-size app, most projects just need to upgrade their plugins to the matching v5-compatible versions and bump the Node version in CI and Dockerfiles. New projects in 2026 should start on Fastify 5.x; do not start anything new on version 4 because v4 is in maintenance mode and will not receive new features. The fastify migration guide on the official docs is excellent and walks through every breaking change with a one-line fix.

Which Indian companies are hiring Fastify developers in 2026?

Fastify experience is strongly valued at Razorpay (payment APIs), Postman (mock servers and platform services), Cred (rewards and ledger APIs), Zerodha (some tooling backends), CRED Mint, Slice, and Jupiter Money in the fintech space. In the SaaS space, Postman, Hasura, and several Y Combinator-funded Indian startups use Fastify as their default Node.js framework. For broader Node.js backend roles, mentioning Fastify experience in interviews signals you care about performance and modern patterns, even teams not using it day-to-day will react positively. Salaries range from ₹7-12 LPA for 2-4 years of experience, ₹12-18 LPA for 4-7 years, and ₹18-22+ LPA for senior backend engineers with deep Fastify or Node.js performance optimization expertise.

Introduction

Fastify has emerged as the default high-performance Node.js framework in 2026, displacing Express in greenfield projects where throughput matters and TypeScript ergonomics are a priority. With version 5.x (released October 2024) requiring Node 20 LTS and dropping legacy compatibility, the framework now delivers up to 2x the requests-per-second of Express on identical hardware, primarily through its JSON Schema-driven serialization via fast-json-stringify, its radix-tree-based find-my-way router, and a zero-overhead plugin system built on the avvio async bootstrap library. The Node.js TSC backs Fastify directly: its creator Matteo Collina is a TSC member, and many of the optimization techniques Fastify pioneered have since been incorporated into other Node.js libraries.

If you are interviewing for a Fastify role in India today, expect deep questions on the plugin encapsulation model and when to use fastify-plugin (fp), schema-based validation and serialization with Ajv and fast-json-stringify, the request lifecycle hook ordering (onRequest through onResponse), and the mechanical differences between Fastify's request/reply abstractions and Express's req/res. Many companies, Razorpay, Postman, Walmart Labs, and Microsoft in particular, also probe knowledge of TypeScript generic typing with TypeBox, @fastify/swagger for OpenAPI generation, Pino logging configuration for production observability, clustering strategies for multi-core deployments, and Redis-backed rate limiting for multi-instance services. Senior interviews dig into avvio internals, multi-tenancy patterns, and how Fastify achieves its throughput advantage at the V8 hidden-class level.

This guide covers the 30 most-asked Fastify interview questions in 2026, grouped by difficulty: 12 basic questions on fundamentals (routing, validation, plugins, hooks, decorators, error handling), 13 intermediate questions on production patterns (authentication, database integration, testing, OpenAPI, streaming, WebSockets, configuration), and 5 advanced questions on architecture and performance (high-throughput design, memory leak debugging, avvio internals, multi-tenancy, mechanical performance comparison with Express). Each answer explains the underlying mechanism, common gotchas, and includes a working code example where it adds clarity. The goal is not rote memorization but genuine understanding of why Fastify behaves the way it does, which is exactly what senior interviewers at companies hiring in the ₹15-22 LPA range are checking for.

Ready to practice Fastify interviews?

Don't just read, practice these Fastify questions live with an AI interviewer that asks follow-ups and scores your answers.

AI-powered practice
Instant feedback
Free to start
Start Free Mock Interview