Picture a small shop that takes orders through a web page. A customer fills in a form, the browser sends the order as JSON, and a Node server has to check the fields, save the order, and answer with the new order id. The work itself is small. The ceremony around it is not: parsing the body, checking that every required field is present, sending back the right status code when something is missing, and keeping the routes readable as they grow. Express leaves most of that to you, which is fine until the third route starts looking like the first two. That gap is where Fastify earns a look.

What I would check before using it

My first check is validation. Fastify lets me describe the shape of a request body as a JSON schema, and it rejects bad requests before my handler runs. That saves manual checks in every route, and the client gets a clear 400 instead of a crash later. If the API only takes one or two simple fields, the schema costs more setup than it saves.

My second check is speed. Fastify is widely quoted as a very fast Node framework. I treat that as a bonus, not a reason to switch, because a small shop API is rarely slow at the framework level; it is slow at the database, or at a call to another service. Pick Fastify for the developer experience; let the speed be a pleasant extra.

My third check is the plugin system. Fastify routes live in plugins, which keeps a growing API organised: one plugin for orders, one for customers, each with its own prefix. A flat file of routes turns into a mess around the tenth endpoint. The trade-off is learning encapsulation: a plugin's decorations stay hidden from the rest of the app unless you register them the right way.

My fourth check is the ecosystem. Express has more middleware and more answers already written for it. Fastify covers the common pieces, CORS, static files, and websockets, but a niche Express middleware may have no Fastify twin.

My verdict, as judgement: use Fastify for a JSON API with several endpoints when you want validation and structure built in. Skip it for a single tiny endpoint, or when your team knows Express and the deadline is tight. The recommendation flips the moment the API shrinks to one route, or the project needs an Express-only library you cannot replace.

One small shop, served end to end

The example is a small shop orders API. It runs on my own machine, with no real shop and no internet service behind it, and it does the three jobs a real orders page needs: list the orders, load one order by id, and create a new order. The new order route carries a JSON schema, so Fastify checks the body before the handler runs. The test uses fastify.inject(), which calls the routes in memory, so no separate client process is needed.

Here is the whole server:

const Fastify = require("fastify"); const fastify = Fastify({ logger: false }); const orders = [ { id: 101, customer: "Ayesha", item: "Canvas tote bag", qty: 2, total: 36.0, status: "shipped" }, { id: 102, customer: "Bilal", item: "Ceramic mug", qty: 1, total: 14.5, status: "processing" }, { id: 103, customer: "Sara", item: "Notebook set", qty: 3, total: 27.75, status: "delivered" }, ]; fastify.get("/orders", async () => orders); fastify.get("/orders/:id", async (request, reply) => { const order = orders.find((o) => o.id === Number(request.params.id)); if (!order) { reply.code(404); return { error: "Order not found", id: Number(request.params.id) }; } return order; }); const orderSchema = { body: { type: "object", required: ["customer", "item", "qty", "total"], properties: { customer: { type: "string" }, item: { type: "string" }, qty: { type: "integer", minimum: 1 }, total: { type: "number", minimum: 0 }, }, }, }; fastify.post("/orders", { schema: orderSchema }, async (request, reply) => { const nextId = Math.max(...orders.map((o) => o.id)) + 1; const order = { id: nextId, status: "processing", ...request.body }; orders.push(order); reply.code(201); return order; }); module.exports = fastify; 

Two details in that code are worth a pause. First, the handler returns the data instead of calling reply.send(), and Fastify serialises the returned value as JSON. That is the normal Fastify style, and it keeps handlers short. Second, the schema sits beside the route, not inside it, so the validation rules are visible at a glance and the handler only deals with a body that already passed.

Here is the test script, using inject() to call each route:

const fastify = require("./server"); async function main() { await fastify.ready(); const list = await fastify.inject({ method: "GET", url: "/orders" }); console.log("GET /orders status:", list.statusCode); console.log("Body:", list.body); const one = await fastify.inject({ method: "GET", url: "/orders/102" }); console.log("\nGET /orders/102 status:", one.statusCode); console.log("Body:", one.body); const missing = await fastify.inject({ method: "GET", url: "/orders/999" }); console.log("\nGET /orders/999 status:", missing.statusCode); console.log("Body:", missing.body); const bad = await fastify.inject({ method: "POST", url: "/orders", payload: { customer: "Hassan", item: "Leather wallet", qty: 1 }, }); console.log("\nPOST /orders (missing total) status:", bad.statusCode); console.log("Body:", bad.body); const created = await fastify.inject({ method: "POST", url: "/orders", payload: { customer: "Hassan", item: "Leather wallet", qty: 1, total: 42.5 }, }); console.log("\nPOST /orders (complete) status:", created.statusCode); console.log("Body:", created.body); await fastify.close(); } main().catch((err) => { console.error(err); process.exit(1); }); 

This is the real output from running it:

GET /orders status: 200 Body: [{"id":101,"customer":"Ayesha","item":"Canvas tote bag","qty":2,"total":36,"status":"shipped"},{"id":102,"customer":"Bilal","item":"Ceramic mug","qty":1,"total":14.5,"status":"processing"},{"id":103,"customer":"Sara","item":"Notebook set","qty":3,"total":27.75,"status":"delivered"}] GET /orders/102 status: 200 Body: {"id":102,"customer":"Bilal","item":"Ceramic mug","qty":1,"total":14.5,"status":"processing"} GET /orders/999 status: 404 Body: {"error":"Order not found","id":999} POST /orders (missing total) status: 400 Body: {"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"body must have required property 'total'"} POST /orders (complete) status: 201 Body: {"id":104,"status":"processing","customer":"Hassan","item":"Leather wallet","qty":1,"total":42.5} 

The thing that went wrong was the first POST. I sent an order without a total, expecting the handler to run and the order to be saved with a missing field. Fastify never let the handler run: the schema caught the missing field first and answered with a 400 and the message body must have required property 'total'. That is the schema doing its job, and it is also the moment a beginner usually gets confused, because the handler code looks fine and never executes. The fix is not in the handler at all. Send the missing field, and the same route answers 201 with the new order, id 104, as the last block of output shows.

The server also runs for real on a port. I started it listening on http://127.0.0.1:4174 and called it with curl. A GET for order 103 answered 200 with Sara's notebook set, and the same incomplete POST answered 400 with the identical validation body. The inject tests and the live server agree, which is the point: inject is a fast way to test the same routes the live server serves.

Common mistakes and how to fix them

First, sending a body that fails the schema. The server answers 400 with body must have required property 'total' and the code FST_ERR_VALIDATION, and the handler never runs. The fix is in the request, not the handler: send every required field, and read the message, which names the missing one. Check the schema first whenever a POST fails this way.

Second, forgetting to return anything from a handler. Fastify does not hang and does not complain. It answers 200 with an empty body and a content-length of 0, which looks like success to the client while carrying no data. The fix is to return the object, as in the example above, or call reply.send() with it. If a route answers 200 with nothing in it, check the handler's return first.

Third, posting JSON without a Content-Type header. Fastify answers 415 with Unsupported Media Type and the code FST_ERR_CTP_INVALID_MEDIA_TYPE, because it only parses a body when it knows the media type. The fix is to send Content-Type: application/json with the request, which curl and inject both do when you pass a JSON payload the usual way.

The verdict, after running it

Fastify does what it claims where it counts: the schema catches a bad order before the handler runs, the handlers stay short because they return values instead of managing responses, and the API is testable without starting a server. The cost is a second framework to learn and less ready-made middleware than Express. If your next project is a JSON API with more than a couple of endpoints, build one route in Fastify and compare it with the Express version. If the Fastify version is shorter and the validation errors are clearer, that is your answer. If it is a single endpoint and the team already knows Express, stay where you are.

Further reading

Originally published at https://practicaldevblog.blogspot.com/2026/11/fastify-fast-node-api-without-ceremony.html