- Published on
Fastify in Production — Blazing Fast APIs With Schema Validation and Plugin Architecture
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
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
- JSON Schema for Request/Response Validation
- Plugin Encapsulation and Scope
- Decorators for Dependency Injection
- Hooks Lifecycle: preHandler, onSend, onError
- fastify-jwt and fastify-rate-limit
- Graceful Shutdown
- Production Logging with pino
- Checklist
- Conclusion
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.