Speeding Up JSON Responses in Fastify with Declared Schemas
Learn how declaring a JSON Schema for a Fastify route’s response lets the framework compile a custom serializer that strips unwanted fields, validates output, and feeds OpenAPI docs—all without hand‑rolled stringify logic.
14 Dec 2025, 19:36 UTC

The problem: over‑fetching and hidden costs
Many handlers return ORM model instances or plain objects that contain more data than the API contract promises—fields like passwordHash, internal timestamps, or computed properties. When Fastify sends the response with the default JSON.stringify, it walks the entire object on every request, serializing fields the client never asked for and potentially leaking sensitive data.
Thesis: let the schema drive serialization
Fastify lets you attach a JSON Schema to a route’s response. When a schema is present for a status code, the framework compiles it with fast-json-stringify into a purpose‑built serializer function. At request time, that function:
- Serializes only the properties named in the schema (allowlist effect).
- Runs faster than generic
JSON.stringifybecause it avoids reflective property enumeration. - Throws a serialization error if the handler returns a value that does not conform to the schema, giving you early feedback.
The same schema can also be used for request validation (via Ajv) and, through the @fastify/swagger plugin, for generating an OpenAPI document—providing a single source of truth for validation, output shape, and documentation.
Worked example: a GET route that hides extra fields
Assume you are using Fastify v4.x (the behavior described is stable across v4 releases; check your major version with npm ls fastify).
// server.js
import Fastify from 'fastify'
import swagger from '@fastify/swagger'
const fastify = Fastify({ logger: true })
// Register Swagger to see the OpenAPI output later
fastify.register(swagger, { openapi: { info: { title: 'User API', version: '1.0.0' } } })
// Define a reusable response schema
const userResponseSchema = {
type: 'object',
properties: {
id: { type: 'integer' },
name: { type: 'string' },
email:{ type: 'string', format: 'email' }
},
required: ['id', 'name', 'email'],
additionalProperties: false // enforce allowlist
}
// Register the schema so it can be $ref‑ed
fastify.addSchema(userResponseSchema, 'userResponse')
// Route definition
fastify.get('/users/:id', {
schema: {
params: { type: 'object', properties: { id: { type: 'integer' } }, required: ['id'] },
response: {
200: { $ref: 'userResponse#' } // reference the registered schema
}
}
}, async (request, reply) => {
// Imagine this comes from an ORM; it contains extra fields we do not want to expose
const dbUser = {
id: parseInt(request.params.id, 10),
name: 'Ada Lovelace',
email: '[contact removed]',
passwordHash: 'e3b0c442...', // should never appear in the response
createdAt: new Date().toISOString()
}
// Fastify will serialize according to the schema; extra fields are dropped silently
return dbUser
})
// Start the server
const start = async () => {
try {
await fastify.listen({ port: 3000, host: '0.0.0.0' })
fastify.swagger() // generate OpenAPI doc
fastify.log.info(`Swagger available at http://localhost:3000/documentation`)
} catch (err) {
fastify.log.error(err)
process.exit(1)
}
}
start()
When you call GET /users/1, the HTTP response body will be:
{ "id": 1, "name": "Ada Lovelace", "email": "[contact removed]" }The
passwordHashandcreatedAtfields are omitted because they are not listed in the schema, and no extra properties are allowed (additionalProperties: false). If you accidentally removeadditionalProperties: false, those fields would appear again, demonstrating why the flag matters for leak prevention.Trade‑offs and limitations
- Maintenance contract: The schema becomes the source of truth for what the client receives. If you add a new column to the ORM model and forget to update the schema, the field will be silently omitted, which can look like a missing‑data bug.
- Serialization errors surface at runtime: A mismatch (e.g., returning a string where the schema expects an integer) will throw only when the route is first hit, potentially after deployment. Cover each schema‑driven route with tests that exercise realistic payloads.
- Version‑sensitive defaults: Ajv’s handling of
additionalPropertiesand type coercion changed between Fastify v3 and v4. Verify the behavior against the major version you run (npm ls fastifyshows the installed version). - Only affects JSON: If you need to serialize other content types (e.g., CSV, protobuf), you must provide a custom serializer via
reply.type('application/csv').send(...)or similar; the response schema does not apply.
Practical verification steps
- Run the example code above locally.
- Request
http://localhost:3000/users/1and confirm the response lackspasswordHashandcreatedAt. - Temporarily set
additionalProperties: truein the schema and repeat the request; observe that the extra fields now appear. - Write a simple test (using
fastify.inject) that asserts the response JSON matches the schema and fails when the handler returns a non‑conforming value. - Check the generated OpenAPI at
http://localhost:3000/documentation; the/users/{id}operation should reference the same schema, proving the single‑source‑of‑truth claim.
Actionable closing
If your API currently returns ORM rows or plain objects with more data than the contract states, start by adding a response schema to one high‑traffic route. Measure the difference in CPU usage or latency with a load‑testing tool (e.g., autocannon) before and after the schema—your environment will dictate the exact gain. Keep the schema under version control, treat it as a contract, and enforce its correctness with automated tests. Over time, extend the practice to all routes; you’ll gain faster JSON output, automatic field allowlists, and ready‑to‑publish OpenAPI docs without duplicating effort.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.