Introduction to Fastify
Fastify is a high-performance HTTP framework for Node.js, designed with speed and developer experience at its core. It serves as an alternative to Express, Koa, and Hapi, but distinguishes itself through its exceptional throughput, low overhead, and a powerful plugin system. Fastify draws inspiration from the best ideas of existing frameworks while solving their shortcomings—particularly around request validation, serialization, and extensibility.
At its foundation, Fastify uses fast-json-stringify for rapid JSON serialization and find-my-way for efficient route matching via a radix tree structure. These components allow Fastify to handle tens of thousands of requests per second on modest hardware, often outperforming comparable Node.js frameworks by significant margins.
Why Fastify Matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Understanding why Fastify exists—and why you might choose it—helps frame the entire learning path. Here are the core reasons:
Performance Without Compromises
Fastify consistently ranks among the fastest Node.js web frameworks in benchmarks. It achieves this not through micro-optimizations that sacrifice usability, but through architectural decisions: schema-based serialization that avoids runtime reflection, a radix-tree router that minimizes route matching overhead, and careful attention to closure allocations and promise handling.
Schema-Driven Development
Fastify treats JSON Schema as a first-class citizen. You define schemas for request bodies, query strings, parameters, and response payloads. These schemas serve dual purposes: they validate incoming data and compile optimized serializers for outgoing data. This means validation and serialization become nearly free at runtime—the heavy lifting happens at startup when schemas are compiled.
Encapsulation Through Plugins
Fastify's plugin system is arguably its most powerful feature. Plugins create isolated scopes with their own hooks, decorators, and state. This encapsulation prevents cross-contamination between different parts of your application, enabling true separation of concerns. A plugin can register its own routes, apply its own hooks, and decorate the server—all without affecting sibling plugins.
Developer Experience
Despite its performance focus, Fastify provides a delightful developer experience: expressive route definitions, comprehensive TypeScript support, built-in logging via Pino, automatic error handling with customizable error formatters, and extensive official plugins for common needs like CORS, cookie parsing, multipart handling, and WebSocket support.
Setting Up Your First Fastify Server
Let's begin with installation and a minimal server. Create a new directory and initialize a project:
mkdir fastify-learning-path
cd fastify-learning-path
npm init -y
npm install fastify
Now create a file named server.js with the following content:
// server.js
const fastify = require('fastify')({
logger: {
transport: {
target: 'pino-pretty',
options: {
colorize: true
}
},
level: 'info'
}
});
// Declare a simple route
fastify.get('/', async (request, reply) => {
return { hello: 'world' };
});
// Start the server
const start = async () => {
try {
await fastify.listen({ port: 3000, host: '0.0.0.0' });
console.log('Server listening on http://localhost:3000');
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();
Run the server with node server.js and visit http://localhost:3000. You'll receive a JSON response: {"hello":"world"}. Notice several things already: the response is automatically serialized to JSON, the content-type header is set correctly, and if you inspect the logs, Fastify outputs structured, colorful logs via Pino.
The logger option is worth special attention. Fastify ships with Pino as its built-in logger, and enabling it gives you structured logging across all requests. In production, you'd typically omit pino-pretty and let your log aggregation system handle raw JSON logs. For development, the pretty printer makes logs human-readable.
Core Concepts: Routes and Handlers
Routes in Fastify are defined using HTTP method-specific functions: fastify.get(), fastify.post(), fastify.put(), fastify.delete(), and so on. Each accepts a path string, an optional options object, and a handler function.
Basic Route with Parameters
// Named parameters using :param syntax
fastify.get('/users/:userId', async (request, reply) => {
const { userId } = request.params;
return { userId, message: `Fetching user ${userId}` };
});
// Multiple parameters
fastify.get('/posts/:category/:postId', async (request, reply) => {
const { category, postId } = request.params;
return { category, postId };
});
Parameters are extracted from the URL path and placed into request.params. Fastify uses the colon-prefix syntax familiar from Express, but under the hood it compiles these routes into a radix tree for O(1) matching time regardless of how many routes you register.
Query Strings and Request Body
// Accessing query string parameters
fastify.get('/search', async (request, reply) => {
const { q, limit } = request.query;
// q = search term, limit = number of results
return { results: [], query: q, limit };
});
// POST handler with JSON body
fastify.post('/users', async (request, reply) => {
const { name, email } = request.body;
// request.body is automatically parsed from JSON
return { created: true, user: { name, email } };
});
Fastify automatically parses JSON request bodies when the content-type is application/json. For other content types like application/x-www-form-urlencoded or multipart/form-data, you'll need to register the appropriate plugin (e.g., @fastify/formbody or @fastify/multipart).
The Reply Object
The reply object gives you fine-grained control over the response. You can set status codes, headers, and send responses explicitly:
fastify.get('/custom-response', async (request, reply) => {
reply.status(201);
reply.header('X-Custom-Header', 'my-value');
reply.header('Cache-Control', 'no-cache');
// Send a response explicitly
return reply.send({ status: 'created' });
});
// Setting cookies (requires @fastify/cookie plugin)
fastify.get('/set-cookie', async (request, reply) => {
reply.cookie('sessionId', 'abc123', {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 3600
});
return { message: 'Cookie set' };
});
When you return a value from a handler, Fastify automatically calls reply.send() with that value. The explicit reply.send() approach is useful when you need to set headers or status codes before sending, or when working with streams and non-JSON responses.
Schema Validation: The Fastify Superpower
Schema validation is what truly sets Fastify apart. By defining JSON Schema for your inputs and outputs, you get automatic, ultra-fast validation and serialization. Let's explore this step by step.
Validating Request Bodies
// Define a schema for the request body
const userBodySchema = {
type: 'object',
required: ['name', 'email'],
properties: {
name: {
type: 'string',
minLength: 2,
maxLength: 100
},
email: {
type: 'string',
format: 'email'
},
age: {
type: 'integer',
minimum: 0,
maximum: 150
}
},
additionalProperties: false
};
fastify.post('/users', {
schema: {
body: userBodySchema,
response: {
201: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
email: { type: 'string' }
}
}
}
}
}, async (request, reply) => {
const { name, email, age } = request.body;
// Data is already validated at this point
const user = { id: 'usr_123', name, email };
reply.status(201);
return user;
});
With this schema in place, Fastify will reject any request body that doesn't match the schema with a 400 status code and a descriptive error message. The validation happens before your handler runs, so you can trust that request.body contains valid data. The response schema compiles a serializer that produces only the specified properties in the correct types.
Validating Query Strings and Parameters
fastify.get('/users/:userId', {
schema: {
params: {
type: 'object',
required: ['userId'],
properties: {
userId: {
type: 'string',
pattern: '^[a-zA-Z0-9_-]{3,32}$'
}
}
},
querystring: {
type: 'object',
properties: {
fields: {
type: 'string',
pattern: '^(name|email|age)(,(name|email|age))*$'
},
include: {
type: 'string',
enum: ['profile', 'settings', 'posts']
}
}
}
}
}, async (request, reply) => {
const { userId } = request.params;
const { fields, include } = request.query;
return { userId, fields, include };
});
This validation guards against malformed or malicious input at the framework level. The pattern property uses regular expressions for string validation, while enum restricts values to a specific set.
Response Serialization Performance
When you provide a response schema, Fastify compiles a dedicated serializer function using fast-json-stringify. This serializer knows exactly what properties to include and their types, avoiding the overhead of generic JSON.stringify reflection. For APIs returning large or complex objects, this can yield a 2-5x speedup in serialization.
// Without schema: JSON.stringify runs every time
fastify.get('/slow', async (request, reply) => {
const largeObject = { /* ... many properties ... */ };
return largeObject; // Generic serialization
});
// With schema: compiled serializer runs once at startup
fastify.get('/fast', {
schema: {
response: {
200: {
type: 'object',
properties: {
id: { type: 'number' },
title: { type: 'string' },
// Only needed properties
}
}
}
}
}, async (request, reply) => {
// Fastify uses precompiled serializer
return { id: 42, title: 'Hello', extraField: 'ignored' };
// 'extraField' will be stripped from the response
});
Notice that properties not defined in the response schema are automatically stripped. This is a security and performance win: you never accidentally leak internal data, and the serialized output is minimal.
The Plugin System and Encapsulation
Fastify's plugin architecture is the mechanism for organizing code into reusable, encapsulated units. Understanding plugins is essential for building maintainable applications at any scale.
What Is a Plugin?
A plugin is a function that receives the Fastify instance, an options object, and a callback. It can register routes, add hooks, define decorators, and register child plugins. Each plugin creates its own encapsulated context.
// A basic plugin that registers routes
const userRoutes = (fastify, options, done) => {
// This fastify instance is scoped to this plugin
fastify.get('/users', async (request, reply) => {
return [{ name: 'Alice' }, { name: 'Bob' }];
});
fastify.post('/users', async (request, reply) => {
// Create user logic
return { created: true };
});
done(); // Signal that the plugin is ready
};
// Register the plugin on the main server
fastify.register(userRoutes, { prefix: '/api/v1' });
The prefix option prepends a path prefix to all routes registered within that plugin. Here, the routes become /api/v1/users. This is how you version APIs or group related endpoints.
Encapsulation in Action
Encapsulation means that hooks, decorators, and state set in one plugin don't leak into sibling plugins. This is crucial for large applications:
// Plugin A: adds authentication check
fastify.register((instance, opts, done) => {
instance.addHook('onRequest', async (request, reply) => {
// This hook only runs for routes in this plugin
const token = request.headers.authorization;
if (!token) {
reply.status(401).send({ error: 'Unauthorized' });
}
});
instance.get('/profile', async (request, reply) => {
return { user: 'authenticated user' };
});
done();
}, { prefix: '/secure' });
// Plugin B: public routes without auth
fastify.register((instance, opts, done) => {
instance.get('/health', async (request, reply) => {
return { status: 'ok' };
});
done();
}, { prefix: '/public' });
The onRequest hook in Plugin A only affects routes defined within that plugin's scope. The /public/health route in Plugin B remains unaffected. This scoping is enforced by Fastify's architecture—each plugin gets its own set of hooks that form a chain only for its own routes.
Composing Plugins
Plugins can register other plugins, creating a hierarchy of encapsulated contexts:
const databasePlugin = (fastify, opts, done) => {
// Simulate database connection
const db = {
query: (sql) => Promise.resolve([{ id: 1 }])
};
fastify.decorate('db', db);
done();
};
const repositoryPlugin = (fastify, opts, done) => {
fastify.register(databasePlugin);
fastify.decorate('userRepository', {
findAll: () => fastify.db.query('SELECT * FROM users'),
findById: (id) => fastify.db.query('SELECT * FROM users WHERE id = ?', [id])
});
done();
};
fastify.register(repositoryPlugin);
fastify.get('/users', async (request, reply) => {
// Access decorated repository
const users = await fastify.userRepository.findAll();
return users;
});
This composition pattern allows you to build layered architectures where each plugin depends on lower-level plugins, and all state is properly scoped.
Async/Await Plugin Style
Modern Fastify supports async plugin functions, which often read more naturally:
const asyncPlugin = async (fastify, opts) => {
// Setup logic here
const connection = await createDatabaseConnection(opts.url);
fastify.decorate('connection', connection);
// No need for done() callback
fastify.addHook('onClose', async (instance) => {
await connection.close();
});
};
fastify.register(asyncPlugin, { url: 'postgres://...' });
When you use an async function as a plugin, Fastify waits for the returned promise to resolve before considering the plugin ready. This is cleaner than the callback-based done() approach and works well with modern async patterns.
Hooks: The Lifecycle System
Fastify exposes a rich lifecycle hook system that lets you intercept requests at various stages. Understanding the order and purpose of each hook is critical for building middleware-like functionality correctly.
The Lifecycle Order
Here is the full lifecycle order for a request in Fastify:
1. onRequest - Raw request arrives, before body parsing
2. preParsing - Before body parsing (if content-type is present)
3. preValidation - After body parsing, before schema validation
4. preHandler - After validation, just before the route handler
5. handler - Your route handler executes
6. preSerialization - Before response serialization (if a schema exists)
7. onSend - Before the response is sent over the wire
8. onResponse - After the response has been sent
9. onError - If an error occurs at any point
Practical Hook Examples
// onRequest: raw request inspection, authentication
fastify.addHook('onRequest', async (request, reply) => {
const userAgent = request.headers['user-agent'];
request.log.info({ userAgent }, 'incoming request');
});
// preValidation: custom validation logic after body parsing
fastify.addHook('preValidation', async (request, reply) => {
// Rate limiting check
const clientIp = request.ip;
const isRateLimited = await checkRateLimit(clientIp);
if (isRateLimited) {
reply.status(429).send({ error: 'Too many requests' });
}
});
// preHandler: last chance before handler runs
fastify.addHook('preHandler', async (request, reply) => {
// Load user from database based on authenticated session
if (request.session && request.session.userId) {
request.user = await loadUser(request.session.userId);
}
});
// onSend: modify the payload before it's sent
fastify.addHook('onSend', async (request, reply, payload) => {
// Add a response timestamp header
reply.header('X-Response-Time', Date.now() - request.startTime);
// You can modify the payload string here if needed
return payload;
});
// onResponse: logging after response is sent
fastify.addHook('onResponse', async (request, reply) => {
request.log.info({
statusCode: reply.statusCode,
path: request.url
}, 'request completed');
});
Hook Scoping with Plugins
As mentioned earlier, hooks are scoped to the plugin where they're registered. This is incredibly powerful for building middleware chains that only apply to specific route groups:
// Admin routes with authentication and audit logging
fastify.register(async (adminScope) => {
adminScope.addHook('onRequest', async (request, reply) => {
// Admin authentication check
if (!request.headers['x-admin-key']) {
reply.status(403).send({ error: 'Forbidden' });
}
});
adminScope.addHook('onResponse', async (request, reply) => {
// Audit log for admin actions
await auditLog.record(request.url, reply.statusCode);
});
adminScope.get('/admin/users', async (request, reply) => {
return await getAllUsers();
});
adminScope.delete('/admin/users/:id', async (request, reply) => {
await deleteUser(request.params.id);
return { deleted: true };
});
}, { prefix: '/admin' });
The authentication and audit logging hooks only apply to routes within the admin scope. Other parts of your application remain completely unaffected.
Decorators: Extending the Fastify Instance
Decorators allow you to attach custom properties and methods to the Fastify instance, request, and reply objects. They're the recommended way to share functionality across your application.
Decorating the Server Instance
// Decorate the fastify instance with a utility method
fastify.decorate('sayHello', function(name) {
return `Hello, ${name}!`;
});
// Use the decorated method anywhere
console.log(fastify.sayHello('World')); // "Hello, World!"
// Decorate with a database client
fastify.decorate('prisma', new PrismaClient());
// Add an onClose hook to clean up the decorator
fastify.addHook('onClose', async (instance) => {
await instance.prisma.$disconnect();
});
Decorating the Request Object
// Add a 'currentUser' getter to every request
fastify.decorateRequest('currentUser', null);
// Populate it in a hook
fastify.addHook('preHandler', async (request, reply) => {
const sessionToken = request.cookies?.session;
if (sessionToken) {
request.currentUser = await verifySessionToken(sessionToken);
}
});
// Use it in handlers
fastify.get('/me', async (request, reply) => {
if (!request.currentUser) {
reply.status(401).send({ error: 'Not authenticated' });
return;
}
return { user: request.currentUser };
});
Decorating the Reply Object
// Add a convenient 'success' method to reply
fastify.decorateReply('success', function(data = {}) {
this.status(200);
this.header('Content-Type', 'application/json');
return this.send({
success: true,
data
});
});
// Use it in handlers
fastify.get('/items', async (request, reply) => {
const items = await fetchItems();
return reply.success({ items });
});
Decorators must be registered before the routes that use them, and they follow encapsulation rules: a decorator registered in a plugin is available to that plugin and its descendants, but not to siblings or parents.
Error Handling
Fastify provides structured error handling that integrates deeply with the schema validation and lifecycle systems.
Built-in Error Handling
When schema validation fails, Fastify automatically sends a 400 response with a detailed error object. When a handler throws an error, Fastify catches it and sends a 500 response. You can customize this behavior:
// Custom error handler
fastify.setErrorHandler((error, request, reply) => {
const statusCode = error.statusCode || 500;
request.log.error(error);
reply.status(statusCode).send({
error: {
code: statusCode,
message: error.message,
// Only include stack trace in development
...(process.env.NODE_ENV === 'development' && { stack: error.stack })
}
});
});
Creating Custom Errors
// Fastify provides a utility for creating errors with status codes
const createError = require('@fastify/error');
const UserNotFoundError = createError('USER_NOT_FOUND', 'User with id %s was not found', 404);
const ValidationError = createError('VALIDATION_ERROR', '%s', 400);
fastify.get('/users/:id', async (request, reply) => {
const user = await findUser(request.params.id);
if (!user) {
throw new UserNotFoundError(request.params.id);
}
return user;
});
The @fastify/error utility creates error classes that carry a status code and a formatted message. Throwing these errors triggers your custom error handler (or the default one) with the appropriate status code.
Handling Async Errors
Fastify handles promise rejections in async handlers automatically. You don't need to wrap handler bodies in try/catch unless you want to transform the error:
fastify.get('/data', async (request, reply) => {
// If this promise rejects, Fastify catches it
// and passes it to setErrorHandler
const data = await fetchDataFromExternalService();
return data;
});
// Transforming errors in the handler
fastify.get('/data-safe', async (request, reply) => {
try {
const data = await fetchDataFromExternalService();
return data;
} catch (err) {
// Transform the error before it reaches the error handler
request.log.warn(err, 'External service failed');
throw new createError('EXTERNAL_SERVICE_ERROR', 'Temporary unavailability', 503);
}
});
Building a Complete REST API
Let's put everything together by building a complete REST API for a task management system. This example demonstrates routes, schemas, plugins, hooks, decorators, and error handling in a cohesive application.
// server.js - Complete REST API for Task Management
const fastify = require('fastify')({ logger: true });
// --- Database decorator (simulated) ---
fastify.decorate('tasks', new Map([
['1', { id: '1', title: 'Learn Fastify', completed: false, createdAt: new Date().toISOString() }],
['2', { id: '2', title: 'Build an API', completed: false, createdAt: new Date().toISOString() }]
]));
let nextId = 3;
// --- Schemas ---
const taskSchema = {
type: 'object',
properties: {
id: { type: 'string' },
title: { type: 'string', minLength: 1, maxLength: 200 },
completed: { type: 'boolean' },
createdAt: { type: 'string', format: 'date-time' }
}
};
const createTaskBody = {
type: 'object',
required: ['title'],
properties: {
title: { type: 'string', minLength: 1, maxLength: 200 },
completed: { type: 'boolean', default: false }
},
additionalProperties: false
};
const taskParams = {
type: 'object',
required: ['id'],
properties: {
id: { type: 'string', pattern: '^[0-9]+$' }
}
};
// --- Global hooks ---
fastify.addHook('onRequest', async (request, reply) => {
request.startTime = Date.now();
});
fastify.addHook('onResponse', async (request, reply) => {
const duration = Date.now() - request.startTime;
request.log.info({ duration, statusCode: reply.statusCode }, 'request handled');
});
// --- Error handler ---
fastify.setErrorHandler((error, request, reply) => {
request.log.error(error);
const statusCode = error.statusCode || 500;
reply.status(statusCode).send({
success: false,
error: {
code: statusCode,
message: error.message
}
});
});
// --- Routes ---
// GET /tasks - List all tasks
fastify.get('/tasks', {
schema: {
response: {
200: {
type: 'array',
items: taskSchema
}
}
}
}, async (request, reply) => {
const tasks = Array.from(fastify.tasks.values());
return tasks;
});
// GET /tasks/:id - Get a single task
fastify.get('/tasks/:id', {
schema: {
params: taskParams,
response: {
200: taskSchema,
404: {
type: 'object',
properties: {
error: { type: 'string' }
}
}
}
}
}, async (request, reply) => {
const task = fastify.tasks.get(request.params.id);
if (!task) {
reply.status(404);
return { error: 'Task not found' };
}
return task;
});
// POST /tasks - Create a task
fastify.post('/tasks', {
schema: {
body: createTaskBody,
response: {
201: taskSchema
}
}
}, async (request, reply) => {
const id = String(nextId++);
const task = {
id,
title: request.body.title,
completed: request.body.completed || false,
createdAt: new Date().toISOString()
};
fastify.tasks.set(id, task);
reply.status(201);
return task;
});
// PUT /tasks/:id - Update a task
fastify.put('/tasks/:id', {
schema: {
params: taskParams,
body: {
type: 'object',
properties: {
title: { type: 'string', minLength: 1, maxLength: 200 },
completed: { type: 'boolean' }
},
minProperties: 1,
additionalProperties: false
},
response: {
200: taskSchema
}
}
}, async (request, reply) => {
const task = fastify.tasks.get(request.params.id);
if (!task) {
reply.status(404);
return { error: 'Task not found' };
}
Object.assign(task, request.body);
fastify.tasks.set(request.params.id, task);
return task;
});
// DELETE /tasks/:id - Delete a task
fastify.delete('/tasks/:id', {
schema: {
params: taskParams,
response: {
200: {
type: 'object',
properties: {
deleted: { type: 'boolean' }
}
}
}
}
}, async (request, reply) => {
const deleted = fastify.tasks.delete(request.params.id);
if (!deleted) {
reply.status(404);
return { error: 'Task not found' };
}
return { deleted: true };
});
// --- Start ---
const start = async () => {
try {
await fastify.listen({ port: 3000, host: '0.0.0.0' });
console.log('Task API running on http://localhost:3000');
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();
This example demonstrates schema validation on inputs and outputs, proper error handling, lifecycle hooks for timing and logging, and a clean RESTful design. The response schemas ensure that only the specified fields are serialized, and the request schemas guard against invalid data.
Advanced Patterns
Content Negotiation
Fastify can respond with different formats based on the Accept header:
fastify.get('/data', async (request, reply) => {
const data = { message: 'Hello' };
// Check what the client accepts
const accepts = request.accepts();
if (accepts.type('application/json')) {
return data; // JSON is default
} else if (accepts.type('text/html')) {
reply.header('Content-Type', 'text/html');
return `${data.message}
`;
}
});
Streaming Responses
For large payloads, Fastify supports streaming responses directly:
const fs = require('fs');
const { pipeline } = require('stream/promises');
fastify.get('/large-file', async (request, reply) => {
const fileStream = fs.createReadStream('./large-data.csv');
reply.header('Content-Type', 'text/csv');
reply.header('Content-Disposition', 'attachment; filename="data.csv"');
// Send the stream directly
return reply.send(fileStream);
});
Route-Level Caching with ETags
fastify.get('/cached-data', {
schema: {
response: {
200: {
type: 'object',
properties: {
data: { type: 'string' },
version: { type: 'number' }
}
}
}
}
}, async (request, reply) => {
const resource = await fetchExpensiveResource();
// Generate ETag based on resource version
const etag = `"${resource.version}"`;
reply.header('ETag', etag);
// Check if client sent matching ETag
if (request.headers['if-none-match'] === etag) {
reply.status(304);
return; // No body for 304
}
return resource;
});
Graceful Shutdown
Fastify handles graceful shutdown automatically, but you should clean up resources:
// Register cleanup hooks
fastify.addHook('onClose', async (instance) => {
await instance.db.close();
await instance.cache.disconnect();
});
// Fastify listens for SIGTERM and SIGINT by default
// but you can also manually call:
// await fastify.close();
Testing Fastify Applications
Fastify is designed for testability. The framework provides a way to inject requests without actually starting an HTTP server, making tests fast and deterministic.
// __tests__/tasks.test.js
const buildApp = require('../app'); // Your app builder function
describe('Tasks API', () => {
let app;
beforeEach(() => {
app = buildApp({ logger: false });
});
afterEach(async () => {
await app.close();
});
test('GET /tasks returns array', async () => {
const response = await app.inject({
method: 'GET',
url: '/tasks'
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.payload);
expect(Array.isArray(body)).toBe(true);
});
test('POST /tasks creates a task', async () => {
const response = await app.inject({
method: 'POST',
url: '/tasks',
payload: { title: 'Test task' },
headers: { 'content-type': 'application/json' }
});
expect(response.statusCode).toBe(201);
const task = JSON.parse(response.payload);
expect(task.title).toBe('Test task');
expect(task.id).toBeDefined();
});
test('Schema validation rejects invalid body', async () => {
const response = await app.inject({
method: 'POST',
url: '/tasks',
payload: { badField: true },
headers: { 'content-type': 'application/json' }
});
expect(response.statusCode).