Fastify in Production — Blazing Fast APIs With Schema Validation and Plugin Architecture

Sanjeev SharmaSanjeev Sharma
8 min read

Advertisement

Introduction

Express dominates Node.js APIs, but Fastify is 2-3x faster and architecturally superior. Its schema-first validation, plugin encapsulation system, and hook lifecycle prevent the common pitfalls that plague Express applications at scale. This post covers building bulletproof production Fastify services: from schema validation and plugin isolation to JWT auth, rate limiting, graceful shutdown, and structured logging.

Why Fastify Outperforms Express

Fastify's performance advantage comes from three sources: schema-based JSON serialization, a compiled route matcher, and no unnecessary middleware abstractions. The biggest win is JSON Schema validation — schemas compile to optimized code at startup, so validation adds near-zero runtime overhead.

// Express: manual validation, slow JSON serialization
const express = require('express')
const app = express()
 
app.post('/users', (req, res) => {
  const { email, age } = req.body
  if (!email || age < 0) {
    return res.status(400).json({ error: 'Invalid' })
  }
  res.json({ email, age })
})
 
// Fastify: schema-validated, serialization optimized
const Fastify = require('fastify')
const fastify = Fastify({ logger: true })
 
fastify.post('/users', {
  schema: {
    body: {
      type: 'object',
      required: ['email', 'age'],
      properties: {
        email: { type: 'string', format: 'email' },
        age: { type: 'integer', minimum: 0 },
      },
    },
    response: {
      200: {
        type: 'object',
        properties: {
          email: { type: 'string' },
          age: { type: 'integer' },
        },
      },
    },
  },
}, async (request, reply) => {
  const { email, age } = request.body
  // Already validated — no manual checks needed
  return { email, age }
})
 
// Benchmark: Express ~10k req/s, Fastify ~28-30k req/s

JSON Schema for Request and Response Validation

JSON Schema in Fastify serves double duty: it validates incoming requests and optimizes outgoing serialization. The response schema tells Fastify exactly what fields to serialize, skipping the general-purpose JSON stringify.

const createUserSchema = {
  body: {
    type: 'object',
    required: ['email', 'age'],
    additionalProperties: false, // reject unknown fields
    properties: {
      email: {
        type: 'string',
        format: 'email',
        description: 'User email address',
      },
      age: {
        type: 'integer',
        minimum: 18,
        maximum: 150,
      },
      name: {
        type: 'string',
        minLength: 1,
        maxLength: 255,
      },
    },
  },
  response: {
    201: {
      type: 'object',
      properties: {
        id: { type: 'integer' },
        email: { type: 'string' },
        age: { type: 'integer' },
        active: { type: 'boolean' },
        createdAt: { type: 'string', format: 'date-time' },
      },
    },
    400: {
      type: 'object',
      properties: {
        error: { type: 'string' },
        message: { type: 'string' },
      },
    },
  },
}
 
fastify.post('/users', { schema: createUserSchema }, async (request, reply) => {
  const { email, age, name } = request.body
  const user = await db.users.create({ email, age, name, active: true })
  reply.code(201).send(user)
})

additionalProperties: false is a security win — it prevents unknown fields from leaking through.

Plugin Encapsulation and Scope

Plugins are Fastify's killer feature. Each plugin gets its own scope: middleware, decorators, and hooks defined inside a plugin do not leak to other plugins or the root application. This enables true feature isolation.

const Fastify = require('fastify')
const fastify = Fastify()
 
// Global hook: runs for ALL routes
fastify.addHook('preHandler', async (request, reply) => {
  request.startTime = Date.now()
})
 
// Plugin 1: User routes with isolated auth
async function userPlugin(app) {
  // Hook only for routes in this plugin
  app.addHook('preHandler', async (request, reply) => {
    const token = request.headers.authorization
    if (!token) {
      reply.code(401).send({ error: 'Unauthorized' })
    }
    request.user = await verifyToken(token)
  })
 
  app.get('/users/:id', async (request, reply) => {
    const user = await db.users.findById(request.params.id)
    return user
  })
}
 
// Plugin 2: Public routes — no auth hook
async function publicPlugin(app) {
  app.get('/health', async () => ({ status: 'ok' }))
  app.get('/version', async () => ({ version: '1.0.0' }))
}
 
fastify.register(userPlugin, { prefix: '/api' })
fastify.register(publicPlugin)

The auth hook only runs for routes registered inside userPlugin. Public routes bypass it entirely.

Decorators for Dependency Injection

Decorators attach services to the Fastify instance, making them available across all routes without global state.

const fp = require('fastify-plugin')
 
// Database plugin (fp bypasses encapsulation to share across plugins)
async function databasePlugin(fastify, options) {
  const pool = new Pool({ connectionString: options.connectionString })
  fastify.decorate('db', pool)
 
  fastify.addHook('onClose', async () => {
    await pool.end()
  })
}
 
async function cachePlugin(fastify, options) {
  const redis = new Redis(options.redisUrl)
  fastify.decorate('cache', redis)
 
  fastify.addHook('onClose', async () => {
    await redis.quit()
  })
}
 
// Register shared plugins
fastify.register(fp(databasePlugin), { connectionString: process.env.DATABASE_URL })
fastify.register(fp(cachePlugin), { redisUrl: process.env.REDIS_URL })
 
// Use in routes
fastify.get('/users/:id', async (request, reply) => {
  const cached = await fastify.cache.get(`user:${request.params.id}`)
  if (cached) return JSON.parse(cached)
 
  const user = await fastify.db.query(
    'SELECT * FROM users WHERE id = $1',
    [request.params.id]
  )
  await fastify.cache.setex(`user:${request.params.id}`, 300, JSON.stringify(user.rows[0]))
  return user.rows[0]
})

Hook Lifecycle: preHandler, onSend, onError

Hooks execute at specific lifecycle points. Master them for authentication, response transformation, and centralized error handling.

// preHandler: before the route handler — use for auth, rate limiting
fastify.addHook('preHandler', async (request, reply) => {
  const token = request.headers.authorization?.split(' ')[1]
  if (!token) {
    reply.code(401).send({ error: 'Missing token' })
    return
  }
  request.user = verifyToken(token)
})
 
// onSend: after handler, before sending — use for response augmentation
fastify.addHook('onSend', async (request, reply, payload) => {
  reply.header('X-Request-ID', request.id)
  reply.header('X-Response-Time', `${Date.now() - request.startTime}ms`)
  return payload
})
 
// onError: centralized error handling
fastify.addHook('onError', async (request, reply, error) => {
  fastify.log.error({ error, url: request.url, method: request.method })
})
 
// Custom error handler
fastify.setErrorHandler(async (error, request, reply) => {
  if (error.validation) {
    return reply.code(400).send({
      error: 'Validation Error',
      message: error.message,
      details: error.validation,
    })
  }
  if (error.statusCode) {
    return reply.code(error.statusCode).send({ error: error.message })
  }
  reply.code(500).send({ error: 'Internal Server Error' })
})

JWT Auth and Rate Limiting

Standard production plugins for authentication and rate limiting:

const fastify = require('fastify')({ logger: true })
 
// JWT plugin
fastify.register(require('@fastify/jwt'), {
  secret: process.env.JWT_SECRET,
  sign: { expiresIn: '7d' },
})
 
// Rate limiting with Redis store
fastify.register(require('@fastify/rate-limit'), {
  max: 100,
  timeWindow: '15 minutes',
  redis: new Redis(process.env.REDIS_URL),
  keyGenerator: (request) => request.user?.id || request.ip,
})
 
// Login route
fastify.post('/login', async (request, reply) => {
  const { email, password } = request.body
  const user = await verifyCredentials(email, password)
  if (!user) {
    return reply.code(401).send({ error: 'Invalid credentials' })
  }
  const token = fastify.jwt.sign({ id: user.id, email: user.email })
  return { token }
})
 
// Protected route
fastify.get('/profile', {
  preHandler: [fastify.authenticate],
}, async (request, reply) => {
  return { user: request.user }
})
 
// Decorate with authenticate shorthand
fastify.decorate('authenticate', async (request, reply) => {
  await request.jwtVerify()
})

Graceful Shutdown

Handle SIGTERM properly to drain in-flight requests before exiting:

let isShuttingDown = false
let activeRequests = 0
 
fastify.addHook('onRequest', async () => {
  activeRequests++
})
 
fastify.addHook('onResponse', async () => {
  activeRequests--
})
 
// Refuse new requests during shutdown
fastify.addHook('onRequest', async (request, reply) => {
  if (isShuttingDown) {
    reply.code(503).send({ error: 'Service shutting down' })
  }
})
 
async function shutdown(signal) {
  console.log(`Received ${signal}, shutting down...`)
  isShuttingDown = true
 
  // Stop accepting new connections
  await fastify.close()
 
  // Wait for in-flight requests (30s timeout)
  const timeout = Date.now() + 30000
  while (activeRequests > 0 && Date.now() < timeout) {
    console.log(`Waiting for ${activeRequests} requests to complete...`)
    await new Promise(resolve => setTimeout(resolve, 1000))
  }
 
  process.exit(activeRequests > 0 ? 1 : 0)
}
 
process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))

Production Logging With Pino

Fastify ships with pino by default. Configure it for structured production logging:

const fastify = require('fastify')({
  logger: process.env.NODE_ENV === 'production'
    ? {
        level: 'info',
        serializers: {
          req(request) {
            return {
              method: request.method,
              url: request.url,
              userId: request.user?.id,
            }
          },
        },
      }
    : {
        level: 'debug',
        transport: {
          target: 'pino-pretty',
          options: { colorize: true, translateTime: 'SYS:standard' },
        },
      },
})
 
// Structured log in a route
fastify.get('/orders/:id', async (request, reply) => {
  const { id } = request.params
  fastify.log.info({ orderId: id, userId: request.user?.id }, 'Fetching order')
 
  try {
    const order = await db.orders.findById(id)
    if (!order) {
      fastify.log.warn({ orderId: id }, 'Order not found')
      return reply.code(404).send({ error: 'Not found' })
    }
    return order
  } catch (err) {
    fastify.log.error({ err, orderId: id }, 'Failed to fetch order')
    throw err
  }
})

Key Takeaways

  • Fastify achieves 28-30k req/s vs Express at 10-12k req/s — the difference matters at scale
  • JSON Schema validation compiles at startup and adds near-zero runtime overhead while also generating OpenAPI docs
  • Plugin encapsulation is Fastify's most important architectural feature — hooks and decorators inside a plugin do not leak
  • fastify-plugin (fp) bypasses encapsulation deliberately for shared infrastructure like databases and caches
  • additionalProperties: false in body schemas prevents unknown field injection attacks
  • Graceful shutdown must track active requests and wait for them to drain before process exit
  • Pino structured logging is faster than console.log and produces machine-parseable JSON in production

Conclusion

Fastify's speed comes from intentional design: schema validation, plugin isolation, and hooks. Build it correctly and you get a system that is 2-3x faster than Express while being more maintainable. The schema-first approach means less validation code, automatic documentation, and optimized serialization — all for free. The plugin system means features stay isolated and the codebase stays organized as it grows.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading