Node.js From Zero
Express and Fastify: Middleware, Routing and Validation
The middleware idea, Express 5 and Fastify side by side, error handling middleware, validation with schemas.
On this page
- Words used in this lesson
- What a framework adds on top of node:http
- Middleware, from zero
- The same API in Express 5
- The same API in Fastify 5
- Running both with curl
- Express: middleware order and routes
- Error handling in Express 5
- Fastify: the request lifecycle and hooks
- Validation with JSON Schema in Fastify
- Express validation: you bring the library
- Logging per request
- Express or Fastify? A fair comparison
- Try it
- Interview questions
- Key takeaways
- What's next
In one line: a web framework gives you two things that raw node:http does not: a chain of small functions (middleware) that every request walks through, and a router that sends each request to the right handler. Express and Fastify both do this. They differ in how they parse input, how they validate it, and how they handle errors.
In the last lesson, An HTTP Server From Scratch, we built a server with node:http and routed by hand with if (req.url === ...). It worked, but every new feature meant more if blocks. Reading a JSON body, checking input, logging, handling errors: we wrote all of it ourselves, and we wrote it again for every route.
This lesson shows how two popular frameworks remove that repeated work. We will build the same small API twice, once in Express 5 and once in Fastify 5, and run both side by side with curl. Then we dig into the three ideas that matter most: middleware, error handling, and validation with schemas.
If you want the "why" of validating input at the edge, read Backend Lesson 3 (validation at the edge) and Backend Lesson 4 (operational vs programmer errors). In the next lesson, Errors, Logging and Graceful Shutdown, we go deeper into what to do when things go wrong in production.
What I ran / checked. Everything here ran on Linux with Node.js v22.22.0, Express 5.3.0, Fastify 5.12.5, pino 10.4.0 and pino-pretty 13.2.0 (the versions npm install gave me today). I pasted real output. Timings change from run to run. Sources I read (in my own words): the Fastify docs files for Validation and Serialization, Hooks and Logging from the fastify/fastify repository; the Express History.md change log (5.0.0 to 5.3.0) from the expressjs/express repository; the README of the router package that ships inside Express 5 (it explains how promises returned by handlers are treated); and the Node.js http docs. Could not reach: the Express guide pages (the migration guide and the error-handling guide) were not available from my machine, so every Express 5 behaviour below is something I ran myself or read in History.md, not something I copied from the guide. I did not benchmark the two frameworks, so I make no speed claims.
Words used in this lesson
- A framework is a library that gives your program its basic shape. You fill in handlers, and the framework calls them.
- Middleware is a function that runs in the middle of a request. It can read the request, change it, answer it, or pass it on to the next function.
- A route is a pair of "method + path" (like
GET /users/:id) and the handler that answers it. - A route parameter is a changing part of a path, like the
:idin/users/:id. - A handler is the function that finally answers a request.
- A body parser is middleware that reads the raw bytes of a request body and turns them into a JavaScript value.
- A schema is a written description of the shape data must have, for example "an object with a string
nameof at least 2 characters". - JSON Schema is a standard format for writing such a description in JSON.
- Validation means checking that data matches the shape you expect, and rejecting it if not.
- A hook (Fastify) is a function that runs at a fixed step of a request, such as before the handler runs.
- A plugin (Fastify) is a function that adds routes, hooks or decorations to the app, inside its own private scope.
- Encapsulation means a plugin's hooks and settings stay inside it and do not leak out to its siblings.
- An error handler is the one place that turns a thrown error into an HTTP response.
- Coercion means quietly converting a value to another type, such as the text
"7"to the number7.
What a framework adds on top of node:http
From lesson 4 you know the base. http.createServer gives you a function (req, res). Everything else is up to you. A framework adds a thin, repeatable structure:
- Routing. A table of method + path to handler, with path parameters (
/users/:id) filled in for you. - Middleware. A pipeline of functions for shared work: logging, parsing bodies, checking login.
- Helpers.
res.json(...),res.status(404),req.params,req.query. - Error handling. A way to say "when anything throws, do this".
None of this is magic. It is plain functions calling plain functions. To prove that, let us build the middleware idea in 15 lines with no library.
Middleware, from zero
The idea: a list of functions. Each gets (req, res, next). Calling next() means "go to the next function in the list".
function createApp() { const stack = []; return { use(fn) { stack.push(fn); }, run(req, res) { let i = 0; const next = () => { const fn = stack[i++]; if (fn) fn(req, res, next); }; next(); }, };}const app = createApp();app.use((req, res, next) => { console.log("A before"); next(); console.log("A after"); });app.use((req, res, next) => { console.log("B before"); next(); console.log("B after"); });app.use((req, res) => { console.log("handler"); });app.run({}, {});A beforeB beforehandlerB afterA afterRead the output slowly. The request goes in through A, then B, then the handler. After the handler, control comes back out in reverse order: B, then A. This "in and then out" shape is why middleware can time a request: record the time before next(), and compute the difference after it.
Two rules follow from this tiny model, and both are real traps in Express:
- Order matters. Middleware runs in the order you registered it.
- If you do not call
next()and do not answer, the request hangs. The browser just waits.
A middleware must do exactly one of three things: answer the request, call next(), or pass an error to next(err). Doing none of these leaves the request hanging until the client gives up.
The same API in Express 5
Our small API has three routes: a health check, read a user, create a user. We also want a logger (method, URL, status, time) and basic input checking. First, Express.
import express from "express";const app = express();const users = new Map([[1, { id: 1, name: "Ada" }]]);app.use(express.json());app.use((req, res, next) => { // logger middleware const start = process.hrtime.bigint(); res.on("finish", () => { const ms = Number(process.hrtime.bigint() - start) / 1e6; console.log(`${req.method} ${req.url} -> ${res.statusCode} (${ms.toFixed(1)} ms)`); }); next();});app.get("/health", (req, res) => res.json({ ok: true }));app.get("/users/:id", (req, res) => { const user = users.get(Number(req.params.id)); if (!user) return res.status(404).json({ error: "not found" }); res.json(user);});app.post("/users", (req, res) => { const { name } = req.body ?? {}; if (typeof name !== "string" || name.length < 2) { return res.status(400).json({ error: "name must be a string of 2+ chars" }); } const user = { id: users.size + 1, name }; users.set(user.id, user); res.status(201).json(user);});app.listen(3001, () => console.log("express on 3001"));Line by line, the new things:
express.json()is a body parser middleware. It reads the body, and if theContent-Typeisapplication/json, it puts the parsed value inreq.body.- The logger listens for the response
finishevent, then prints one line. It callsnext()right away, so the request keeps flowing. req.params.idholds the:idpart. It is always a string, so we convert withNumber(...).res.status(404).json(...)sets the status and sends JSON in one chain.
The same API in Fastify 5
Now the same API in Fastify.
import Fastify from "fastify";const app = Fastify();const users = new Map([[1, { id: 1, name: "Ada" }]]);app.addHook("onResponse", async (req, reply) => { console.log(`${req.method} ${req.url} -> ${reply.statusCode} (${reply.elapsedTime.toFixed(1)} ms)`);});app.get("/health", async () => ({ ok: true }));app.get("/users/:id", async (req, reply) => { const user = users.get(Number(req.params.id)); if (!user) return reply.code(404).send({ error: "not found" }); return user;});app.post("/users", { schema: { body: { type: "object", required: ["name"], properties: { name: { type: "string", minLength: 2 } }, }, },}, async (req, reply) => { const user = { id: users.size + 1, name: req.body.name }; users.set(user.id, user); return reply.code(201).send(user);});await app.listen({ port: 3002 });console.log("fastify on 3002");Differences you can already see:
- A Fastify handler can simply return a value. Fastify sends it as JSON. No
res.jsonneeded. - There is no body-parser line. Fastify parses JSON by default.
- The validation is not code inside the handler. It is a
schemaobject next to the route. The handler only runs if the data already matched. - The logger is a hook (
onResponse) instead of a middleware that listens tofinish.
Running both with curl
I started both servers and sent the same requests to each. First the successful ones.
=== port 3001 (Express)HTTP/1.1 200 OKContent-Type: application/json; charset=utf-8Content-Length: 11{"ok":true}GET /users/1 -> {"id":1,"name":"Ada"}GET /users/9 -> 404 {"error":"not found"}POST /users {"name":"Bo"} -> 201 {"id":2,"name":"Bo"}=== port 3002 (Fastify)HTTP/1.1 200 OKcontent-type: application/json; charset=utf-8content-length: 11{"ok":true}GET /users/1 -> {"id":1,"name":"Ada"}GET /users/9 -> 404 {"error":"not found"}POST /users {"name":"Bo"} -> 201 {"id":2,"name":"Bo"}For the happy path they are the same. (Express writes headers like Content-Type; Fastify writes them in lowercase. HTTP header names are case-insensitive, so this is only a look difference.) The difference shows up with bad input. I sent {"name":"B"} (too short) and then {bad (broken JSON) to both:
=== Express, name too shortHTTP/1.1 400 Bad Request{"error":"name must be a string of 2+ chars"}=== Fastify, name too shortHTTP/1.1 400 Bad Request{"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"body/name must NOT have fewer than 2 characters"}=== Fastify, broken JSONHTTP/1.1 400 Bad Request{"statusCode":400,"code":"FST_ERR_CTP_INVALID_JSON_BODY","error":"Bad Request","message":"Body is not valid JSON but content-type is set to 'application/json'"}And the Express answer to broken JSON?
=== Express, broken JSONHTTP/1.1 400 Bad Request...</html>It returned an HTML page, and its server console printed a full stack trace for a client mistake. Nobody wants an HTML error page from a JSON API. It also never printed our logger line for that request. Both facts have a reason, and the reason is the next topic.
Middleware order bit us here. The JSON body parser ran before our logger. It failed, so it jumped straight to the error handling, and our logger (registered after it) never ran. If you want to log every request, put the logger first.
The Fastify console printed one log line per request, including the bad ones:
GET /health -> 200 (3.5 ms)GET /users/1 -> 200 (0.4 ms)GET /users/9 -> 404 (0.6 ms)POST /users -> 201 (3.0 ms)POST /users -> 400 (2.0 ms)POST /users -> 400 (2.9 ms)Express: middleware order and routes
Express keeps one ordered list. Each app.use(...) and each app.get(...) is an entry in it. A request walks the list from the top until something answers. Here is a short proof. I put a logger before a route and another after it:
app.use((req,res,next)=>{ console.log("logger A"); next(); });app.get("/hello", (req,res)=>res.send("hi"));app.use((req,res,next)=>{ console.log("logger B (after route)"); next(); });// then I called /hello and /otherlogger A <- for /hellologger A <- for /otherlogger B (after route) <- only for /otherFor /hello, the route answered, so the request never reached logger B. For /other, no route matched, so it fell all the way through. This is also how a 404 handler works in Express: a normal middleware placed after all your routes, which only runs if nothing above answered.
Route paths changed in Express 5
Express 5 uses a newer version of the path matching library, so a few old path styles stop working. I tested the most common one, the wildcard:
app.get("/files/*", handler) // Express 4 styleOLD *: Missing parameter name at index 8: /files/*; visit https://git.new/pathToRegexpError for infoIn Express 5 a wildcard needs a name, and an optional part uses braces:
app.get("/files/*path", (req, res) => res.json(req.params));app.get("/opt{/:id}", (req, res) => res.json(req.params));GET /files/a/b/c.txt -> {"path":["a","b","c.txt"]}GET /opt -> {}GET /opt/7 -> {"id":"7"}Notice that *path gives back an array of path pieces, not one string. If you upgrade an Express 4 app, search for * in your route strings first. (The 5.0.0 entry in History.md also lists other breaking changes, for example res.status() now accepts only whole numbers from 100 to 999, and res.redirect('back') was removed.)
req.body is undefined unless you add a parser
Another point that surprises beginners. With no body parser, req.body is simply undefined:
POST /n with a=1 -> {"t":"undefined","v":null}That is why I wrote req.body ?? {} in the handler above. If you forget express.json(), every body-reading route breaks in the same quiet way.
Error handling in Express 5
This is the part that changed most in Express 5, and the part most interviews ask about.
How errors flow
There are three ways an error can start in an Express handler:
- You throw in a normal (sync) handler.
- You throw or reject in an
asynchandler. - You call
next(err)yourself.
In Express 5, all three reach the same place. The router package inside Express 5 checks what your handler returns. If it is a promise that rejects, the router calls next(error) for you (its README says so, and I confirmed it by running). In older Express 4, a rejected promise in an async handler was not caught, and the request just hung (this is from my own knowledge of Express 4; I did not install Express 4 for this lesson). This is one of the best reasons to use version 5.
An error-handling middleware
An error handler is a middleware with four arguments. Express tells it apart from a normal one by counting the arguments, so you must write all four, even if you do not use next. It goes last, after all routes.
class HttpError extends Error { constructor(status, message) { super(message); this.status = status; this.expose = true; }}const app = express();app.use(express.json());app.get("/sync-throw", () => { throw new Error("sync boom"); });app.get("/async-throw", async () => { await Promise.resolve(); throw new Error("async boom"); });app.get("/teapot", () => { throw new HttpError(418, "I am a teapot"); });// 404 handler: a normal middleware placed AFTER all routesapp.use((req, res) => res.status(404).json({ error: `no route ${req.method} ${req.path}` }));// error handler: exactly 4 args, placed LASTapp.use((err, req, res, next) => { const status = err.status ?? err.statusCode ?? 500; if (status >= 500) console.error("ERROR", err.message); res.status(status).json({ error: status >= 500 ? "internal error" : err.message });});I called each route with curl:
/sync-throw -> HTTP/1.1 500 Internal Server Error {"error":"internal error"}/async-throw -> HTTP/1.1 500 Internal Server Error {"error":"internal error"}/teapot -> HTTP/1.1 418 I'm a Teapot {"error":"I am a teapot"}/nope -> HTTP/1.1 404 Not Found {"error":"no route GET /nope"}POST /x with {bad -> HTTP/1.1 400 Bad Request {"error":"Expected property name or '}' in JSON at position 1 (line 1 column 2)"}(server console: ERROR sync boom / ERROR async boom)What this proves:
- The sync throw and the async throw are both caught by the same handler. No
try/catchneeded in the routes. - Our own
HttpErrorcarries a status, so the handler can choose the right response. - The broken-JSON error from the body parser carries
status: 400too (it is a known operational error), so the same handler returns a clean 400 JSON instead of that HTML page. - For a 500, we log the real message on the server but send the client a generic text. This matters for security, as the next experiment shows.
The default handler leaks details
What if you write no error handler? I made a route that throws new Error("secret detail") and called it:
(NODE_ENV not set)HTTP/1.1 500 Internal Server ErrorContent-Type: text/html; charset=utf-8<pre>Error: secret detail<br> at file:///tmp/.../express-default.mjs:3:38<br> ... at Layer.handleRequest (/tmp/.../node_modules/router/lib/layer.js:152:17) ...The default handler sent the error message and the file paths of my server to the client. With NODE_ENV=production I ran it again:
(NODE_ENV=production)HTTP/1.1 500 Internal Server ErrorContent-Type: text/html; charset=utf-8(secret detail appears 0 times in the body)So Express hides the stack when NODE_ENV is production. That is good, but relying on one environment variable is fragile. Write your own error handler, always return JSON from an API, and never send err.message for 5xx errors.
Trap: forgetting the 4th argument. (err, req, res) => ... with only three arguments is treated as a normal middleware, not an error handler. It will never be called for errors. Always write (err, req, res, next).
Trap: the error handler placed too early. It must come after the routes it should cover. A handler registered before the routes will not see their errors.
Fastify: the request lifecycle and hooks
Fastify has no single "list of middleware". Instead it has a fixed lifecycle, and you attach hooks to its steps. I attached a hook to every step that matters and logged its name, then sent one valid request and one invalid request.
valid request: onRequest -> preParsing -> preValidation -> preHandler -> (handler) -> preSerialization -> onSend -> onResponseinvalid request: onRequest -> preParsing -> preValidation -> preSerialization -> onSend -> onResponse(That is the order I measured. The Fastify docs list the same hook names.) The invalid request skipped preHandler and the handler, because validation failed and Fastify produced the error response itself. This is the big idea: the steps have fixed names and a fixed order, so there is no "I put my middleware in the wrong place" bug.
What each hook is good for, in plain words:
| Hook | Runs | Typical use |
|---|---|---|
onRequest | first, before the body is read | auth checks, request ids |
preParsing | before the body is parsed | change or inspect the raw body stream |
preValidation | before the schema check | fix up input before it is checked |
preHandler | after validation, right before your handler | load the user, check permissions on the parsed data |
preSerialization | before the reply is turned into JSON | change the response object |
onSend | just before bytes are sent | change the final payload or headers |
onResponse | after the reply was sent | metrics, access logs |
Plugins and encapsulation
Fastify groups code into plugins. A plugin is an async function that receives the app (called an instance). Hooks added inside a plugin apply only inside that plugin. That is encapsulation. Here is a tiny example: an /admin area that needs a token, and a public route that does not.
const app = Fastify();app.get("/public", async () => ({ area: "public" }));app.register(async (admin) => { admin.addHook("onRequest", async (req, reply) => { if (req.headers["x-token"] !== "secret") return reply.code(401).send({ error: "no token" }); }); admin.get("/stats", async () => ({ area: "admin", users: 42 }));}, { prefix: "/admin" });I tested it with app.inject (Fastify's built-in way to call routes without opening a port):
/public 200 {"area":"public"}/admin/stats 401 {"error":"no token"}/admin/stats (token) 200 {"area":"admin","users":42}The token check protects /admin/* and does not touch /public. In Express you would get the same result with a router: app.use("/admin", checkToken, adminRouter). Express does it by path. Fastify does it by scope.
Validation with JSON Schema in Fastify
Fastify takes a JSON Schema for body, querystring, params and headers. It compiles the schema into a fast checking function once, when the server starts. Let us tighten our route and also add a response schema:
app.post("/users", { schema: { body: { type: "object", required: ["name"], additionalProperties: false, properties: { name: { type: "string", minLength: 2 }, age: { type: "integer", minimum: 0 }, }, }, response: { 200: { type: "object", properties: { id: { type: "integer" }, name: { type: "string" } } }, }, },}, async (req) => ({ id: 1, name: req.body.name, passwordHash: "should-not-leak" }));My handler returns an object with a passwordHash field on purpose. The call:
POST {"name":"Bo","age":"7","extra":1} -> {"id":1,"name":"Bo"}Three things happened without any code from us:
"age":"7"(a string) was accepted and turned into the number7. That is coercion."extra":1was silently removed, because the schema hasadditionalProperties: false. It was not rejected.- The response schema dropped
passwordHashfrom the reply. A response schema acts as a filter, which protects you from leaking a field by accident. It also makes serialisation faster.
Coercion is helpful and surprising
The Fastify docs say Fastify uses a custom Ajv setup (Ajv is the library that checks JSON Schema) with coercion turned on. I tested how far it goes with a schema of age: integer and tags: array of string:
{ age: "7" } -> 200 body.age is now the number 7{ age: 7.5 } -> 400 body/age must be integer{ age: true } -> 200 body.age is now 1 <- surprise{ tags: "solo" } -> 200 body.tags is now ["solo"] <- surprise{ age: "abc" } -> 400 body/age must be integerLook at the two surprises. A boolean true became 1, and a lone string became a one-item array. Those values pass validation, and your handler then sees the converted value. This is usually helpful (query strings are always text, so ?page=2 becomes the number 2). But if your rule is "reject anything that is not exactly an integer", you must turn coercion off in the Ajv options or write a stricter schema, for example type: "integer" plus a pattern check in a preValidation hook.
Trap: validation can change your data. Fastify's default Ajv settings coerce types, and with additionalProperties: false they also remove unknown fields instead of rejecting them (I checked: {name, extra} reached the handler as {name}; without that schema keyword, extra stayed). Always read the value your handler actually receives, and test with weird input like true, null, "7" and [].
Custom error shape
By default the 400 error has Fastify's shape (statusCode, code, error, message). To use your own shape, set an error handler. When validation fails, Fastify puts the details in err.validation.
app.setErrorHandler((err, req, reply) => { if (err.validation) { return reply.code(400).send({ error: "bad input", details: err.validation.map(v => `${v.instancePath || "/"} ${v.message}`), }); } req.log.error(err); reply.code(500).send({ error: "internal error" });});POST {"name":"B"} -> {"error":"bad input","details":["/name must NOT have fewer than 2 characters"]}GET /boom (handler throws "db password is hunter2") -> {"error":"internal error"}The same two rules as in Express: validation errors tell the client what to fix, and unexpected errors are logged on the server and answered with a generic message.
Express validation: you bring the library
Express has no built-in validation. You write the checks yourself (as we did with typeof name !== "string") or use a library. Popular choices are Zod, Joi, or Ajv itself. The pattern is always the same: a middleware that validates req.body and either calls next() or answers 400.
const validate = (schema) => (req, res, next) => { const result = schema.safeParse(req.body); // e.g. a Zod schema if (!result.success) return res.status(400).json({ error: "bad input", issues: result.error.issues }); req.body = result.data; // keep the checked, cleaned version next();};app.post("/users", validate(userSchema), handler);I did not install Zod for this lesson, so treat that snippet as a pattern sketch and not a tested example. The point is the shape: one reusable validation middleware per route, instead of if blocks inside handlers. For the reasoning behind it, see Backend Lesson 3.
Logging per request
Both frameworks can log every request, but Fastify does it for you. With Fastify({ logger: true }), Fastify uses pino (a fast JSON logger, covered in the next lesson). I made one request with logging on:
{"level":30,"reqId":"req-1","req":{"method":"GET","url":"/hi","host":"localhost:80","remoteAddress":"127.0.0.1"},"msg":"incoming request"}{"level":30,"reqId":"req-1","step":"inside handler","msg":"working"}{"level":30,"reqId":"req-1","res":{"statusCode":200},"responseTime":4.62,"msg":"request completed"}Every line for one request carries the same reqId. In a handler, req.log is a logger that already knows the request id. That makes it easy to find all lines of one request later. Express has no built-in logger like this. People add pino-http or morgan as middleware. I did not run those two for this lesson.
Express or Fastify? A fair comparison
Both are mature and used in production. This table is based on what I ran and on the docs I read. It is not a benchmark.
| Topic | Express 5 | Fastify 5 |
|---|---|---|
| Handler style | (req, res), you call res.json | async (req, reply), you can just return |
| Body parsing | Add express.json() | Built in for JSON |
| Validation | Bring your own | JSON Schema built in |
| Errors in async handlers | Caught (new in 5) | Caught |
| Request logging | Add a library | Built in (pino) |
| Structure | One ordered list of middleware | Fixed lifecycle hooks + plugins |
| Learning curve | Very small, huge number of tutorials | A few more ideas (schemas, plugins) |
| Default error output | HTML page unless you add a handler | JSON error object |
My practical advice: for a quick service or a team that already knows Express, Express 5 is fine. For a new JSON API where input checking and logging matter from day one, Fastify gives you more out of the box. Whichever you pick, the habits are identical: validate at the edge, one central error handler, structured logs.
Try it
- Copy the Express app. Move the logger below
express.json()and send broken JSON. Does the logger print? Move it above and try again. - Add a route
GET /boomthat throws, to the Express app, with no error handler. Call it with and withoutNODE_ENV=production. What does the client see each time? - In the Fastify app, add
age: { type: "integer" }to the schema and send{"name":"Bo","age":true}. What does the handler receive? Then find a way to reject it. - In Express 5, write a route
/docs/*that matches/docs/a/b. Printreq.params. - Write a tiny
requireTokenmiddleware for Express that checksx-tokenand protects only/admin. Test it with curl.
Answers
- With the logger below the parser, broken JSON gives a 400 and no log line, because the parser fails first and Express skips ahead to error handling. With the logger above, the line is printed (the
finishevent still fires for the error response). - Without
NODE_ENV=productionthe client gets an HTML page with the message and stack paths. Withproductionit gets a plain error page with no message. Best fix: add your own JSON error handler. - The handler receives
1, nottrue, because of coercion. To reject it, checktypeofin apreValidationhook, or turn off type coercion in the Ajv options, or use a schema that matches the exact input you accept. app.get("/docs/*path", (req, res) => res.json(req.params)). The output is{"path":["a","b"]}.app.use("/admin", (req, res, next) => req.get("x-token") === "secret" ? next() : res.status(401).json({ error: "no token" }));placed before the admin routes. Remember: if it neither answers nor callsnext, the request hangs.
Interview questions
What is middleware, and how does Express run it? (Must know)
Middleware is a function (req, res, next) that sits between the request and the final handler. Express keeps an ordered list. A request walks down the list. Each function can end the request, call next() to continue, or call next(err) to jump to error handling. Because it is just an ordered list, order matters: a logger must be registered before the things it should log, and a 404 handler must come after all routes. A common bug is a middleware that does none of the three things, which makes the request hang.
How is an error-handling middleware different from a normal one? (Must know)
It has four parameters, (err, req, res, next). Express uses the argument count to recognise it. It must be registered after the routes, and it is only called when something threw or called next(err). If I write it with three parameters, Express treats it as a normal middleware and never sends errors to it. In it I choose the status, log server errors, and send a safe message to the client.
What changed about async errors in Express 5? (Must know)
In Express 4, an async handler that threw or rejected was not caught by Express. The promise rejection was unhandled and the request hung. In Express 5, the router checks if a handler returns a promise, and if it rejects, calls next(error) automatically. I ran this: both a sync throw and an async throw reached my error handler and gave a clean 500. So with Express 5 I no longer need a wrapper around every async route. I still have to write the error handler myself.
What happens if you do not write an error handler in Express? (Trap)
Express uses a built-in default handler. It returns an HTML page. Without NODE_ENV=production, I saw it include the error message and stack with my server file paths. With production it hides the details. The trap is trusting that variable. I always write my own handler that returns JSON, logs the real error, and sends a generic message for 5xx errors.
Why did my request logger not print for a request with broken JSON? (Trap)
Because the order of middleware matters. The JSON body parser was registered before the logger. When the parser failed, Express skipped the remaining normal middleware and went to error handling, so the logger never ran. The fix is to register the logger first. I show this in the lesson with real curl output.
Describe Fastify's request lifecycle. (Must know)
Fastify runs a fixed sequence: onRequest, preParsing, parsing, preValidation, validation, preHandler, the handler, preSerialization, onSend, and onResponse. I attach hooks to the steps I need. If validation fails, Fastify skips preHandler and the handler and builds the error response itself. I confirmed this order by logging each hook. The benefit over a free-form middleware list is that the order is fixed and documented.
What is plugin encapsulation in Fastify? (Senior signal)
A plugin registered with app.register gets its own scope. Hooks, decorators and settings added inside it apply to that plugin and its children, not to its siblings or the parent. I used this to put an auth hook on /admin only. It stops one team's hook from silently affecting another team's routes. If I need something shared everywhere, I register it at the root, or wrap it with fastify-plugin to deliberately break encapsulation.
Fastify validates with JSON Schema. What surprises can this cause? (Trap)
The default Ajv setup coerces types. In my test, "7" became 7, true became 1, and a single string became a one-item array for an array field. Extra properties can also be removed instead of rejected when additionalProperties is false. So the handler can receive a different value than the client sent. I test with strange inputs and, when I need strict rules, I adjust the Ajv options or add an explicit check.
What is a response schema good for? (Senior signal)
It describes what the reply may contain. Fastify serialises the reply using it, and it drops fields that are not listed. In my example, a passwordHash field returned by the handler was not sent to the client. That protects against accidental leaks. It also lets Fastify use a faster serialiser than a generic JSON.stringify. I still would not rely on it as my only protection, because the secret should not be in the object at all.
How do route paths differ between Express 4 and 5? (Trap)
Express 5 uses a newer path matching library. A bare * is no longer allowed: in my test app.get("/files/*") threw "Missing parameter name". I must write a named wildcard such as /files/*path, and it gives an array of segments in req.params.path. Optional segments use braces, like /opt{/:id}. When upgrading, I search the codebase for * and ? in route strings first.
Why does req.body come out as undefined in Express? (Must know)
Express does not parse bodies by default. I need express.json() for JSON or express.urlencoded() for forms. Without them, req.body is undefined, as I saw in my test. Also, the parser only fills req.body when the Content-Type header matches. A client that forgets the header gets an empty body, so I write req.body ?? {} and validate.
How would you choose between Express and Fastify for a new service? (Senior signal)
I would look at the team and the needs. Express 5 is small, everyone knows it, and its async error handling is now good. Fastify gives schema validation, structured logging, a defined lifecycle and plugins out of the box, which saves work for a JSON API. I would not choose based on speed claims alone. I would measure my own endpoints, because the database usually dominates. The habits (validate at the edge, one error handler, structured logs, graceful shutdown) are the same in both.
Key takeaways
- Middleware is a chain of
(req, res, next)functions. A request goes in through them in order and comes back out in reverse. Each one must answer, callnext(), or pass an error. - In Express, order is everything: logger first, parsers next, routes, then the 404 handler, then the 4-argument error handler last.
- Express 5 catches rejected promises from async handlers and sends them to your error handler. Express 5 also changed path syntax: use
*path, not*. - The default Express error page can reveal your error message and file paths. Write your own JSON error handler and never send internal messages to clients.
- Fastify has a fixed lifecycle (
onRequesttoonResponse), plugins with encapsulation, and JSON Schema validation built in. - Schema validation can coerce types (
"7"to7,trueto1). Test with strange input. - A response schema filters what you send, which prevents accidental leaks.
- Both frameworks need the same habits: validate at the edge, one central error handler, one log line per request.
What's next
Next: Errors, Logging and Graceful Shutdown. We will separate errors you expect from bugs, see what Node does with an unhandled rejection, write structured logs with pino, add health checks, and shut a server down on SIGTERM without dropping a single in-flight request. We will prove it with curl and kill. Practise now: add a custom error handler to both apps you built here, and send each of them five different pieces of bad input.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Node.js & Express Interview Questions · Full-Stack Questions
- API Folder Structure: Routes, Controllers, Services, Repositories · Backend in Depth
- Error Handling: Operational vs Programmer Errors · Backend in Depth
Part of the study guide
Related series