Skip to content

Fix: Fastify Not Working — 404, Plugin Encapsulation, and Schema Validation Errors

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

Quick Answer

How to fix Fastify issues — route 404 from plugin encapsulation, reply already sent, FST_ERR_VALIDATION, request.body undefined, @fastify/cors, hooks not running, and TypeScript type inference.

The Error

You register a route in Fastify but it returns 404:

fastify.register(async function plugin(fastify) {
  fastify.decorate('db', myDatabase);
});

fastify.get('/users', async (request, reply) => {
  return fastify.db.findAll(); // TypeError: Cannot read properties of undefined (reading 'findAll')
});

Or the server crashes mid-request:

FastifyError [Error]: Reply was already sent
    FST_ERR_REP_ALREADY_SENT

Or request.body is undefined even though you sent JSON:

fastify.post('/data', async (request, reply) => {
  console.log(request.body); // undefined
});

Or TypeScript gives you unknown types everywhere:

fastify.post('/users', async (request) => {
  const email = request.body.email; // Error: Object is of type 'unknown'
});

All of these trace back to a small set of Fastify-specific concepts that trip up developers coming from Express.

Why This Happens

Fastify uses a plugin encapsulation model that doesn’t exist in Express. When you call fastify.register(), Fastify creates a new child scope. Routes and decorators registered in that scope are invisible to sibling scopes and to the parent scope. Child scopes inherit from parents, but parents can’t see into children.

This encapsulation is intentional — it makes large apps modular and prevents plugins from accidentally clobbering each other. But it’s the root cause of most “my code worked yesterday and doesn’t today” Fastify bugs.

The second concept that drives a lot of Fastify-specific errors is schema-as-source-of-truth. Fastify ships a JSON Schema validator (Ajv) by default. Every route can declare a body, params, querystring, and response schema, and Fastify validates and serializes against it on every request. Validation errors return 400 automatically; response serialization is generated from the schema to avoid the cost of generic JSON.stringify reflection. This is the feature that makes Fastify substantially faster than Express on typical JSON workloads — but it’s also why you’ll get FST_ERR_VALIDATION errors that have nothing to do with your handler code.

Understanding encapsulation and the schema pipeline solves about 80% of Fastify problems.

Fix 1: Route Returns 404 — Plugin Encapsulation

If you register a route inside a plugin and get 404, or register a decorator inside a plugin and it’s undefined when you use it outside, the encapsulation scope is the culprit.

The problem:

// Plugin registers a decorator
fastify.register(async function dbPlugin(fastify) {
  fastify.decorate('db', { query: () => {} });
  // Decorator is scoped to this plugin — invisible to parent
});

// Parent scope tries to use it — doesn't exist here
fastify.get('/users', async (request, reply) => {
  return fastify.db.query(); // TypeError: fastify.db is undefined
});

Fix: Use fastify-plugin to break encapsulation for shared utilities:

const fp = require('fastify-plugin');

const dbPlugin = fp(async function(fastify) {
  fastify.decorate('db', { query: () => {} });
  // fp() makes this decorator available to the parent and all siblings
});

fastify.register(dbPlugin);

fastify.get('/users', async (request, reply) => {
  return fastify.db.query(); // Works — decorator escaped scope
});

fastify-plugin (fp) adds a special symbol to your plugin that tells Fastify to skip encapsulation. The decorator gets promoted to the parent scope and is available everywhere registered after it.

Rule of thumb: Use fp() for plugins that add decorators, database connections, or global hooks. Don’t use fp() for plugins that define routes with a prefix — fp makes the prefix option meaningless because there’s no scope to apply it to.

Common Mistake: Developers coming from Express assume all registered plugins share a flat namespace. In Fastify, each fastify.register() call is a new isolated scope. This is the single most important concept to internalize. If something is undefined when you expect it to be defined, draw out the scope tree — the answer is almost always there.

Scoped routes with prefixes work correctly:

// This is correct — routes stay in their own scope with the prefix
fastify.register(async function(fastify) {
  fastify.get('/list', handler);    // Responds at /api/list
  fastify.post('/create', handler); // Responds at /api/create
}, { prefix: '/api' });

Fix 2: FST_ERR_REP_ALREADY_SENT — Reply Was Already Sent

This error means your route handler attempted to send a response twice. Fastify (unlike Express) prevents this — the second send throws FST_ERR_REP_ALREADY_SENT.

The most common cause: mixing return with reply.send():

// WRONG — sends the response twice
fastify.get('/data', async (request, reply) => {
  const data = await fetchData();
  reply.send(data);    // First send
  return data;         // Second send — throws FST_ERR_REP_ALREADY_SENT
});

In Fastify async routes, returning a value from the handler is equivalent to calling reply.send(). Pick one style and stick with it:

// Style 1: return the value (recommended for async routes)
fastify.get('/data', async (request, reply) => {
  const data = await fetchData();
  return data; // Fastify calls reply.send(data) for you
});

// Style 2: call reply.send() explicitly (and don't return a value)
fastify.get('/data', async (request, reply) => {
  const data = await fetchData();
  reply.send(data);
  // No return after this
});

Another common cause: a hook sends a response and the route handler also sends one:

fastify.addHook('preHandler', async (request, reply) => {
  if (!request.headers.authorization) {
    reply.code(401).send({ error: 'Unauthorized' }); // Sends response here
    // Execution continues into the route handler unless you return
  }
});

fastify.get('/protected', async (request, reply) => {
  return { secret: 'data' }; // Also sends — FST_ERR_REP_ALREADY_SENT
});

In async hooks, you must return reply after sending to stop execution:

fastify.addHook('preHandler', async (request, reply) => {
  if (!request.headers.authorization) {
    reply.code(401).send({ error: 'Unauthorized' });
    return reply; // Stops the request lifecycle — route handler won't run
  }
});

Fix 3: request.body Is Undefined

You’re sending JSON but request.body is undefined in your handler. The two most common causes:

1. Missing Content-Type: application/json header:

Fastify won’t parse the body unless the request includes a Content-Type header that matches a registered parser. JSON is handled by default, but only when the header is present.

# Wrong — no Content-Type
curl -X POST http://localhost:3000/data -d '{"name":"Alice"}'

# Correct
curl -X POST http://localhost:3000/data \
  -H "Content-Type: application/json" \
  -d '{"name":"Alice"}'

In JavaScript/TypeScript fetch:

// Wrong
fetch('/data', { method: 'POST', body: JSON.stringify({ name: 'Alice' }) });

// Correct
fetch('/data', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Alice' })
});

2. Accessing request.body in the wrong lifecycle hook:

Body parsing happens after onRequest and preParsing. If you try to read request.body in those early hooks, it’s always undefined:

// WRONG — body not parsed yet
fastify.addHook('onRequest', async (request, reply) => {
  console.log(request.body); // undefined, always
});

// CORRECT — body is available from preValidation onward
fastify.addHook('preValidation', async (request, reply) => {
  console.log(request.body); // Has the parsed body
});

Fastify hook order: onRequestpreParsingpreValidationpreHandler → route handler → onSendonResponse.

Fix 4: FST_ERR_VALIDATION — Schema Validation Fails

When you define a schema and a request doesn’t match it, Fastify rejects the request with a 400 and an error like:

{
  "statusCode": 400,
  "error": "Bad Request",
  "message": "body must have required property 'email'"
}

This is correct behavior — the client sent a bad request. But there are cases where the schema itself causes the error:

TypeBox strict mode error ("unknown keyword: 'kind'"):

If you use TypeBox schemas without the type provider, Ajv’s strict mode rejects TypeBox’s internal kind keyword:

Error: strict mode: unknown keyword: "kind"

The fix is to use @fastify/type-provider-typebox and configure the instance properly:

const Fastify = require('fastify');
const { TypeBoxTypeProvider } = require('@fastify/type-provider-typebox');
const { Type } = require('@sinclair/typebox');

const fastify = Fastify().withTypeProvider(TypeBoxTypeProvider);

fastify.post('/users', {
  schema: {
    body: Type.Object({
      email: Type.String({ format: 'email' }),
      name: Type.String({ minLength: 1 })
    })
  }
}, async (request) => {
  return { created: request.body.email }; // Fully typed
});

Customize the validation error response:

By default, the error message includes Ajv’s raw validation output. To return a cleaner error:

fastify.setErrorHandler((error, request, reply) => {
  if (error.validation) {
    reply.status(400).send({
      statusCode: 400,
      error: 'Validation Error',
      details: error.validation.map(v => ({
        field: v.instancePath.replace('/', ''),
        message: v.message
      }))
    });
    return;
  }
  reply.send(error);
});

Fix 5: TypeScript — request.body Is unknown

Without a type provider or generic parameters, Fastify types request.body as unknown. You have two options:

Option 1: Generic interface (works without extra dependencies):

import Fastify, { FastifyRequest } from 'fastify';

interface CreateUserBody {
  email: string;
  name: string;
}

const fastify = Fastify();

fastify.post<{ Body: CreateUserBody }>('/users', async (request) => {
  const { email, name } = request.body; // Typed correctly
  return { email };
});

Option 2: Type provider with TypeBox (recommended — types flow from schema):

import Fastify from 'fastify';
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
import { Type } from '@sinclair/typebox';

const fastify = Fastify().withTypeProvider<TypeBoxTypeProvider>();

fastify.post('/users', {
  schema: {
    body: Type.Object({
      email: Type.String(),
      name: Type.String()
    }),
    params: Type.Object({ id: Type.String() }),
    querystring: Type.Object({ page: Type.Number() })
  }
}, async (request) => {
  // All of these are fully typed without manual interface definitions:
  request.body.email;        // string
  request.params.id;         // string
  request.query.page;        // number
});

Type custom decorators on FastifyRequest:

declare module 'fastify' {
  interface FastifyRequest {
    userId: string;
  }
}

fastify.decorateRequest('userId', '');

fastify.addHook('preHandler', async (request) => {
  request.userId = extractUserIdFromToken(request.headers.authorization);
});

fastify.get('/profile', async (request) => {
  return { userId: request.userId }; // Typed
});

Fix 6: CORS Not Working with @fastify/cors

Register the plugin before defining routes:

const fastify = require('fastify')();
const cors = require('@fastify/cors');

// Register BEFORE routes
await fastify.register(cors, {
  origin: ['https://app.example.com', 'https://example.com:3000'],
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']
});

fastify.get('/api/data', handler);

The CORS setup is simpler than Express cors middleware, but the wildcard-with-credentials trap is the same. The main Fastify-specific difference is that you must await the register call before defining routes.

The three most common CORS mistakes in Fastify:

1. Wildcard with credentials:

// WRONG — browsers reject this combination
await fastify.register(cors, { origin: '*', credentials: true });

// CORRECT — specify exact origins when using credentials
await fastify.register(cors, {
  origin: ['https://app.example.com'],
  credentials: true
});

2. Port missing from allowed origin:

https://example.com and https://example.com:3000 are different origins. Include the port:

// WRONG if your frontend runs on port 3000
origin: 'https://example.com'

// CORRECT
origin: 'https://example.com:3000'

// Or match any port with a regex:
origin: /^https:\/\/example\.com(:\d+)?$/

3. Unlike Express, Fastify doesn’t handle OPTIONS automatically. The @fastify/cors plugin handles this for you — but only if it’s registered. If you’re seeing CORS errors on preflight (OPTIONS) requests, confirm the plugin is registered before the routes generating those errors.

Fix 7: Hooks Not Running

If your onRequest, preHandler, or other hooks aren’t executing for certain routes, the issue is almost always scope or registration order.

Hook registered after the route it should cover:

// WRONG — route is registered before the hook
fastify.get('/protected', protectedHandler);
fastify.addHook('preHandler', authHook); // Too late — doesn't apply to /protected
// CORRECT — hook registered first
fastify.addHook('preHandler', authHook);
fastify.get('/protected', protectedHandler); // Hook applies

Hook in wrong scope:

A hook registered inside a plugin only applies to routes in that plugin’s scope and its descendants. It does not apply to routes in sibling plugins or the parent scope:

// Hook registered in plugin A
fastify.register(async function pluginA(fastify) {
  fastify.addHook('preHandler', logHook);
  fastify.get('/a', handler); // logHook runs ✓
});

// Plugin B — logHook does NOT run here
fastify.register(async function pluginB(fastify) {
  fastify.get('/b', handler); // logHook doesn't apply ✗
});

To apply a hook globally, register it at the root scope before registering any plugins:

// Root scope — applies to all routes
fastify.addHook('preHandler', authHook);

fastify.register(pluginA); // authHook applies
fastify.register(pluginB); // authHook applies

Pro Tip: Don’t use arrow functions in hooks if you need access to this (the Fastify instance). Arrow functions lose the this binding:

// WRONG — this is undefined
fastify.addHook('preHandler', async (request, reply) => {
  this.myDecorator; // undefined
});

// CORRECT
fastify.addHook('preHandler', async function(request, reply) {
  this.myDecorator; // Works
});

Fastify vs Express, Koa, Hono, NestJS, Elysia, h3: Schema Validation Built In

Node.js web frameworks split along a few axes: raw throughput, schema integration, ergonomics, and TypeScript-first design. A short comparison frames where Fastify sits.

Fastify. JSON Schema first-class. Plugin encapsulation as the core abstraction. Ajv validation and serialization built in. Throughput consistently near the top of the Node benchmark charts. Good TypeScript story via type providers. Pick Fastify when you want validated, fast HTTP without bolting on a separate validation library. The encapsulation model has a learning curve, but pays off in large apps.

Express. The default, the easy one, the one every tutorial uses. No built-in validation, no built-in serialization, no schema concept. You add express-validator or zod-express-middleware and body-parser. Throughput is meaningfully lower than Fastify under JSON load. Middleware ordering is implicit (whatever you app.use() first runs first). Pick Express when familiarity wins over performance and you’re fine wiring everything yourself. CORS behavior maps to roughly the same set of problems — see Fix: Express CORS Not Working.

Koa. Async-first redesign of Express by the same team. Smaller core, middleware via async/await from day one, no built-in router (you add koa-router). No schema integration. Pick Koa when you want Express’s flexibility but cleaner async semantics and don’t need raw speed.

Hono. Ultra-light, runtime-agnostic. Runs on Cloudflare Workers, Bun, Deno, Vercel Edge, Node, AWS Lambda — same code, same API. Built-in zod adapter, OpenAPI generator, and a small footprint (~10KB). Pick Hono when you target edge runtimes, want minimal cold start, or want one framework that runs everywhere. Trade-off: smaller plugin ecosystem than Express or Fastify.

NestJS. Opinionated, decorator-heavy, Angular-flavored architecture. Built on top of Express by default but supports Fastify as the underlying HTTP engine. Dependency injection, modules, guards, interceptors. Pick NestJS for large teams that want enforced architecture, microservice patterns, and TypeScript-everywhere conventions. Trade-off: significant boilerplate; the framework itself is heavier than the runtime it sits on.

Elysia. Bun-first framework. End-to-end type safety driven by inference, validation built in via TypeBox-like schemas, very high throughput on Bun. The catch: it’s designed for Bun and runs less well on Node. Pick Elysia when you’ve committed to Bun and want Fastify-class ergonomics without the encapsulation model.

h3. The HTTP layer underneath Nitro and Nuxt. Tiny, universal (Node/Bun/Deno/Workers), composable via event handlers rather than middleware. No built-in validation; pair with zod or Valibot. Pick h3 when building meta-frameworks or extremely lightweight services.

Quick decision table:

NeedPick
Schema validation + serialization built inFastify, Elysia
Familiarity, tutorials, plugin ecosystemExpress
Edge runtimes, one codebase everywhereHono, h3
Bun-native, end-to-end inferenceElysia
Large team, enforced structureNestJS
Minimal core, async-firstKoa

Built-in schema is the single biggest practical difference between Fastify and the older Node frameworks. On Express or Koa, you add a validator (Zod, Joi, class-validator) and a separate serializer. The pieces work but they don’t share types or speed. On Fastify the schema does both jobs and turns response building into a precompiled function call. On Hono and Elysia the schema also produces inferred handler types. If you’re choosing today and “every request has a known JSON shape” is true, schema-first frameworks save real time. If your handlers do arbitrary work and Content-Type varies wildly, Express’s no-opinion approach is less in the way.

For unhandled async rejections in handler code, the recovery patterns are the same across frameworks — see Fix: Node Unhandled Rejection Crash — but Fastify surfaces them as 500 responses rather than crashing the process by default.

Still Not Working?

Fastify v4 → v5 Breaking Changes

If you upgraded to Fastify v5 and existing code broke, check these specific changes:

request.connection removed (use request.socket):

// v4
const ip = request.connection.remoteAddress;

// v5
const ip = request.socket.remoteAddress;

reply.getResponseTime() removed (use reply.elapsedTime):

// v4
const ms = reply.getResponseTime();

// v5
const ms = reply.elapsedTime;

Custom loggers no longer accepted:

// v4 — worked
const fastify = Fastify({ logger: pino() });

// v5 — throws an error
// Pass logger: true (or configure pino separately)
const fastify = Fastify({ logger: true });

Node.js version requirement: Fastify v5 requires Node.js v20+. If you’re on Node.js 18 or earlier, either upgrade Node or stay on Fastify v4.

FST_ERR_DEC_ALREADY_PRESENT on Hot Reload

During development with hot reload tools, Fastify may try to register the same decorator twice as modules reload. The error:

FastifyError: The decorator 'myDecorator' has already been added!
    FST_ERR_DEC_ALREADY_PRESENT

This happens because the Fastify instance persists between hot reloads but the plugin re-registers. The fix is to check before decorating, or to properly dispose and recreate the Fastify instance on reload:

if (!fastify.hasDecorator('myDecorator')) {
  fastify.decorate('myDecorator', value);
}

Alternatively, use frameworks like Fastify’s fastify-cli which handle server lifecycle management correctly.

Route conflicts (method+path already defined)

Fastify throws at startup if two routes use the same method and path:

FST_ERR_DUP_ROUTES: Routes with the same method and URL already exist

Unlike Express, which silently uses the first matching route, Fastify fails fast. Check for duplicate GET /users, POST /data, etc. registrations across your plugins.

async/await with callback-style plugins

Fastify v5 requires consistent async usage. If you mix callback plugins with async ones, you may see routes registered in a callback plugin never appearing:

// WRONG in v5 — callback style
fastify.register(function plugin(fastify, opts, done) {
  fastify.get('/route', handler);
  done();
});

// CORRECT — async/await
fastify.register(async function plugin(fastify) {
  fastify.get('/route', handler);
});

For TypeScript errors where properties don’t exist on Fastify types, see TypeScript property does not exist on type. For more on async errors in Node.js generally, see node unhandled rejection crash — Fastify surfaces unhandled async errors as 500 responses, so tracking them is important for production.

If you’re deploying Fastify behind a reverse proxy, misconfigured proxy headers can cause CORS or IP detection issues. See nginx 502 bad gateway for common proxy configuration problems.

Request Logs Show No Body Even When Body Exists

By default Fastify’s logger doesn’t serialize request.body because logging untrusted payloads is unsafe. If you need bodies in logs for debugging, add a serializer at the onRequest or preHandler hook stage rather than reading request.body in onRequest (where it’s still undefined). Better: use the serializerOpts.censor keys to strip sensitive fields before logging. Avoid request.raw.body — it’s the parsed buffer Node provides, not what Fastify’s parsers produced.

reply.send() Returning Plain [Object Object]

This happens when you serialize without a response schema and the object contains a Map, Set, BigInt, or class instance. Fastify falls back to JSON.stringify, which can’t serialize those types. Either add a response schema (which strips unknown shapes safely) or convert the object before sending: reply.send(JSON.parse(JSON.stringify(obj))) as a last-resort sanity check. For BigInt specifically, set a custom serializer or convert to strings explicitly.

Schema Compilation Slow at Startup with Many Routes

For apps with hundreds of routes, Ajv compilation can add several seconds to startup. Enable Ajv’s cache option in ajv config or precompile schemas with a shared $id and use $ref. Production deployments can also use Fastify’s addSchema to register reusable definitions once and reference them everywhere — duplicate inline schemas are compiled separately and bloat startup time.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles