Published on

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

Authors

Introduction

Express dominates, but Fastify is 2-3x faster and architecturally superior. Its schema validation, plugin system, and hook lifecycle prevent common production pitfalls. This post covers building bulletproof production Fastify services.

Why Fastify Is Faster Than Express

Fastify's performance advantage comes from three sources: avoids unnecessary abstractions, schema validation compiles to optimized code, and the router is faster.

// Express: simple but slow
import express from 'express';

const app = express();

app.post('/users', (req, res) => {
  const { email, age } = req.body;

  // No validation—anything goes
  // Schema must be checked manually
  if (!email || age < 0) {
    return res.status(400).json({ error: 'Invalid' });
  }

  res.json({ email, age });
});

// Fastify: fast and validated
import Fastify from '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 by schema
    reply.send({ email, age });
  }
);

// Benchmark shows 2-3x throughput advantage
// Express: ~10k req/s
// Fastify: ~30k req/s

JSON Schema for Request/Response Validation

Fastify uses JSON Schema to validate and serialize data automatically. This also serves as documentation.

import Fastify, { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';

const fastify = Fastify({
  logger: {
    level: 'info',
    transport: {
      target: 'pino-pretty',
    },
  },
});

interface User {
  id: number;
  email: string;
  age: number;
  active: boolean;
}

// Comprehensive schema with validation and documentation
const createUserSchema = {
  body: {
    type: 'object',
    required: ['email', 'age'],
    properties: {
      email: {
        type: 'string',
        format: 'email',
        description: 'User email address',
      },
      age: {
        type: 'integer',
        minimum: 18,
        maximum: 150,
        description: 'User age',
      },
      name: {
        type: 'string',
        minLength: 1,
        maxLength: 255,
        description: 'Full name',
      },
    },
    additionalProperties: false,
  },
  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' },
      },
    },
  },
};

fastify.post<{ Body: { email: string; age: number; name?: string } }>(
  '/users',
  { schema: createUserSchema },
  async (request: FastifyRequest, reply: FastifyReply) => {
    const { email, age, name } = request.body;

    // Type-safe and validated
    const user: User = {
      id: 1,
      email,
      age,
      active: true,
    };

    reply.code(201).send(user);
  }
);

// Array response schema
const listUsersSchema = {
  response: {
    200: {
      type: 'array',
      items: {
        type: 'object',
        properties: {
          id: { type: 'integer' },
          email: { type: 'string' },
          age: { type: 'integer' },
        },
      },
    },
  },
};

fastify.get<{ Querystring: { skip?: string; limit?: string } }>(
  '/users',
  { schema: listUsersSchema },
  async (request, reply) => {
    const skip = parseInt(request.query.skip || '0');
    const limit = parseInt(request.query.limit || '10');

    const users: User[] = [
      { id: 1, email: 'a@example.com', age: 30, active: true },
      { id: 2, email: 'b@example.com', age: 25, active: true },
    ];

    reply.send(users.slice(skip, skip + limit));
  }
);

Plugin Encapsulation and Scope

Plugins are Fastify's killer feature. Each plugin gets its own scope—middleware, decorators, and hooks don't leak.

import Fastify, { FastifyInstance } from 'fastify';

const fastify = Fastify();

// Global hook: applies to all routes
fastify.addHook('preHandler', async (request, reply) => {
  console.log('Global pre-handler');
});

// Plugin 1: User routes with isolated scope
async function userPlugin(app: FastifyInstance): Promise<void> {
  // Decorate routes only for this plugin
  app.decorate('getUserService', () => ({
    findById: (id: number) => ({ id, name: 'User' }),
  }));

  // Hook only for this plugin
  app.addHook('preHandler', async (request, reply) => {
    console.log('User plugin pre-handler');
  });

  app.get('/users/:id', async (request, reply) => {
    const { id } = request.params as { id: string };
    // userService only available in this plugin
    const service = (app as any).getUserService();
    const user = service.findById(parseInt(id));
    reply.send(user);
  });
}

// Plugin 2: Admin routes with different rules
async function adminPlugin(app: FastifyInstance): Promise<void> {
  // Different middleware/decorators for admin scope
  app.addHook('preHandler', async (request, reply) => {
    // Admin authentication
    if (!request.headers['x-admin-token']) {
      reply.code(401).send({ error: 'Unauthorized' });
    }
  });

  app.get('/admin/stats', async (request, reply) => {
    reply.send({ users: 1000, requests: 50000 });
  });
}

// Register plugins with prefix
fastify.register(userPlugin);
fastify.register(adminPlugin, { prefix: '/api' });

// Plugin isolation means:
// - GET /users/1 → userPlugin pre-handler runs
// - GET /api/admin/stats → adminPlugin pre-handler runs
// - Decorators don't leak between plugins
// - No global middleware pollution

Decorators for Dependency Injection

Decorators attach services to the Fastify instance, available across routes.

import Fastify, { FastifyInstance } from 'fastify';
import postgres from '@fastify/postgres';

declare module 'fastify' {
  interface FastifyInstance {
    dbPool: any;
    userService: UserService;
    logger: any;
  }
}

class UserService {
  constructor(private db: any) {}

  async createUser(email: string, age: number): Promise<any> {
    const result = await this.db.query(
      'INSERT INTO users (email, age) VALUES ($1, $2) RETURNING *',
      [email, age]
    );
    return result.rows[0];
  }

  async getUser(id: number): Promise<any> {
    const result = await this.db.query('SELECT * FROM users WHERE id = $1', [
      id,
    ]);
    return result.rows[0];
  }
}

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

// Register database plugin
fastify.register(postgres, {
  connectionString: 'postgres://user:pass@localhost/db',
});

// After database connection, decorate with service
fastify.decorate('userService', new UserService(fastify.pg));

fastify.post('/users', async (request, reply) => {
  const { email, age } = request.body as { email: string; age: number };

  // Access injected service
  const user = await fastify.userService.createUser(email, age);
  reply.code(201).send(user);
});

fastify.get('/users/:id', async (request, reply) => {
  const { id } = request.params as { id: string };
  const user = await fastify.userService.getUser(parseInt(id));

  if (!user) {
    reply.code(404).send({ error: 'Not found' });
  } else {
    reply.send(user);
  }
});

Hooks Lifecycle: preHandler, onSend, onError

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

import Fastify, { FastifyRequest, FastifyReply } from 'fastify';

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

// preHandler: before route handler
// Use for authentication, authorization, input transformation
fastify.addHook(
  'preHandler',
  async (request: FastifyRequest, reply: FastifyReply) => {
    // Parse JWT
    const token = request.headers.authorization?.split(' ')[1];
    if (!token) {
      reply.code(401).send({ error: 'Missing token' });
      return;
    }

    try {
      request.user = verifyToken(token);
    } catch (err) {
      reply.code(401).send({ error: 'Invalid token' });
    }
  }
);

// onSend: after handler, before sending response
// Use for response transformation, compression, logging
fastify.addHook(
  'onSend',
  async (request: FastifyRequest, reply: FastifyReply, payload: any) => {
    // Transform response
    if (typeof payload === 'object') {
      payload.timestamp = new Date().toISOString();
      payload.requestId = request.id;
    }

    return payload;
  }
);

// onError: when handler throws
// Use for centralized error handling
fastify.addHook(
  'onError',
  async (request: FastifyRequest, reply: FastifyReply, error: Error) => {
    fastify.log.error({ error, url: request.url });

    if (error.message.includes('validation')) {
      reply.code(400).send({ error: 'Validation failed' });
    } else {
      reply.code(500).send({ error: 'Internal error' });
    }
  }
);

// Example: all hooks in sequence
fastify.get('/data', async (request, reply) => {
  // 1. preHandler executes (validate token)
  // 2. Route handler executes
  return { data: 'success' };
  // 3. onSend executes (add timestamp)
  // 4. Response sent
});

declare global {
  namespace Express {
    interface Request {
      user?: any;
    }
  }
}

function verifyToken(token: string): any {
  return { id: 1, email: 'user@example.com' };
}

fastify-jwt and fastify-rate-limit

Standard plugins for authentication and rate limiting.

import Fastify from 'fastify';
import fastifyJwt from '@fastify/jwt';
import fastifyRateLimit from '@fastify/rate-limit';
import Redis from 'ioredis';

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

// JWT plugin: automatic token validation
fastify.register(fastifyJwt, {
  secret: 'your-secret-key-min-32-characters!!!',
  sign: {
    expiresIn: '7d',
  },
});

// Rate limiting with Redis store (production)
const redis = new Redis('redis://localhost:6379');

fastify.register(fastifyRateLimit, {
  max: 100,
  timeWindow: '15 minutes',
  cache: 10000,
  allowList: ['127.0.0.1'],
  redis: redis,
});

// Login endpoint: returns JWT
fastify.post(
  '/login',
  { schema: { body: { type: 'object', required: ['email'] } } },
  async (request, reply) => {
    const { email } = request.body as { email: string };

    // Verify credentials (simplified)
    const user = { id: 1, email };

    const token = fastify.jwt.sign(user);
    reply.send({ token });
  }
);

// Protected route: requires valid JWT
fastify.get('/profile', async (request, reply) => {
  // fastify-jwt automatically validates and decodes
  await request.jwtVerify();

  const user = (request.user as any) || {};
  reply.send({ profile: user });
});

// Rate limited endpoint
fastify.get('/api/data', { rateLimit: { max: 10, timeWindow: '1 minute' } }, async (request, reply) => {
  reply.send({ data: 'expensive operation' });
});

Graceful Shutdown

Handle SIGTERM properly to drain in-flight requests and close connections.

import Fastify from 'fastify';
import { promisify } from 'util';

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

let activeRequests = 0;

// Track active requests
fastify.addHook('onRequest', async (request, reply) => {
  activeRequests++;
});

fastify.addHook('onResponse', async (request, reply) => {
  activeRequests--;
});

fastify.get('/data', async (request, reply) => {
  // Simulate work
  await new Promise((resolve) => setTimeout(resolve, 1000));
  reply.send({ data: 'ok' });
});

const start = async (): Promise<void> => {
  try {
    await fastify.listen({ port: 3000, host: '0.0.0.0' });
    console.log('Server started on port 3000');
  } catch (err) {
    fastify.log.error(err);
    process.exit(1);
  }
};

const shutdown = async (signal: string): Promise<void> => {
  console.log(`\nReceived ${signal}, shutting down gracefully...`);

  // Stop accepting new connections
  await fastify.close();

  // Wait for in-flight requests (timeout after 30s)
  const maxWait = 30000;
  const startTime = Date.now();

  while (activeRequests > 0) {
    if (Date.now() - startTime > maxWait) {
      console.warn('Shutdown timeout exceeded, forcing exit');
      break;
    }
    console.log(`Waiting for ${activeRequests} requests to complete...`);
    await new Promise((resolve) => setTimeout(resolve, 1000));
  }

  // Close database connections, etc.
  process.exit(activeRequests > 0 ? 1 : 0);
};

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

start().catch(console.error);

Production Logging with pino

Fastify comes with pino by default. Configure it for production observability.

import Fastify from 'fastify';
import pino from 'pino';

const pinoConfig =
  process.env.NODE_ENV === 'production'
    ? {
        level: 'info',
        transport: {
          target: 'pino/file',
          options: {
            destination: '/var/log/app.log',
            mkdir: true,
          },
        },
      }
    : {
        level: 'debug',
        transport: {
          target: 'pino-pretty',
          options: {
            colorize: true,
            translateTime: 'SYS:standard',
            ignore: 'pid,hostname',
          },
        },
      };

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

fastify.get('/health', async (request, reply) => {
  fastify.log.debug('Health check');
  reply.send({ status: 'ok' });
});

fastify.get('/data/:id', async (request, reply) => {
  const { id } = request.params as { id: string };

  fastify.log.info(
    { userId: id, path: request.url },
    'Fetching user data'
  );

  try {
    const data = await fetchData(id);
    reply.send(data);
  } catch (err) {
    fastify.log.error({ err, userId: id }, 'Failed to fetch data');
    reply.code(500).send({ error: 'Internal error' });
  }
});

async function fetchData(id: string): Promise<any> {
  return { id, name: 'User' };
}

fastify.listen({ port: 3000 });

Checklist

  • ✓ Use JSON Schema for validation—it's 10x faster than manual validation
  • ✓ Build plugins for feature isolation: users, auth, admin, etc.
  • ✓ Use decorators (dependency injection) instead of global middleware
  • ✓ Implement all three hooks: preHandler, onSend, onError
  • ✓ Add fastify-jwt for token management
  • ✓ Use fastify-rate-limit with Redis for production scaling
  • ✓ Implement graceful shutdown: close server, drain requests, exit
  • ✓ Configure pino for production logging (structured JSON, not console.log)

Conclusion

Fastify's speed comes from intentional design: schema validation, plugin isolation, and hooks. Build it correctly and you get a system that's 2-3x faster than Express while being more maintainable. The learning curve pays dividends.