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.

Lesson 0515 min read#nodejs#express#fastify#middleware

On this page

  1. Words used in this lesson
  2. What a framework adds on top of node:http
  3. Middleware, from zero
  4. The same API in Express 5
  5. The same API in Fastify 5
  6. Running both with curl
  7. Express: middleware order and routes
  8. Error handling in Express 5
  9. Fastify: the request lifecycle and hooks
  10. Validation with JSON Schema in Fastify
  11. Express validation: you bring the library
  12. Logging per request
  13. Express or Fastify? A fair comparison
  14. Try it
  15. Interview questions
  16. Key takeaways
  17. 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 :id in /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 name of 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 number 7.

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:

  1. Routing. A table of method + path to handler, with path parameters (/users/:id) filled in for you.
  2. Middleware. A pipeline of functions for shared work: logging, parsing bodies, checking login.
  3. Helpers. res.json(...), res.status(404), req.params, req.query.
  4. 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".

JavaScript
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({}, {});
TEXT
A beforeB beforehandlerB afterA after

Read 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.

JavaScript
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 the Content-Type is application/json, it puts the parsed value in req.body.
  • The logger listens for the response finish event, then prints one line. It calls next() right away, so the request keeps flowing.
  • req.params.id holds the :id part. It is always a string, so we convert with Number(...).
  • 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.

JavaScript
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.json needed.
  • There is no body-parser line. Fastify parses JSON by default.
  • The validation is not code inside the handler. It is a schema object 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 to finish.

Running both with curl

I started both servers and sent the same requests to each. First the successful ones.

TEXT
=== 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:

TEXT
=== 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?

TEXT
=== 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:

TEXT
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:

JavaScript
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 /other
TEXT
logger A          <- for /hellologger A          <- for /otherlogger B (after route)   <- only for /other

For /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:

JavaScript
app.get("/files/*", handler)        // Express 4 style
TEXT
OLD *: Missing parameter name at index 8: /files/*; visit https://git.new/pathToRegexpError for info

In Express 5 a wildcard needs a name, and an optional part uses braces:

JavaScript
app.get("/files/*path", (req, res) => res.json(req.params));app.get("/opt{/:id}",  (req, res) => res.json(req.params));
TEXT
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:

TEXT
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:

  1. You throw in a normal (sync) handler.
  2. You throw or reject in an async handler.
  3. 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.

JavaScript
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:

TEXT
/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/catch needed in the routes.
  • Our own HttpError carries a status, so the handler can choose the right response.
  • The broken-JSON error from the body parser carries status: 400 too (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:

TEXT
(NODE_ENV not set)HTTP/1.1 500 Internal Server ErrorContent-Type: text/html; charset=utf-8<pre>Error: secret detail<br> &nbsp; &nbsp;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:

TEXT
(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.

TEXT
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:

HookRunsTypical use
onRequestfirst, before the body is readauth checks, request ids
preParsingbefore the body is parsedchange or inspect the raw body stream
preValidationbefore the schema checkfix up input before it is checked
preHandlerafter validation, right before your handlerload the user, check permissions on the parsed data
preSerializationbefore the reply is turned into JSONchange the response object
onSendjust before bytes are sentchange the final payload or headers
onResponseafter the reply was sentmetrics, 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.

JavaScript
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):

TEXT
/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:

JavaScript
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:

TEXT
POST {"name":"Bo","age":"7","extra":1}   ->   {"id":1,"name":"Bo"}

Three things happened without any code from us:

  1. "age":"7" (a string) was accepted and turned into the number 7. That is coercion.
  2. "extra":1 was silently removed, because the schema has additionalProperties: false. It was not rejected.
  3. The response schema dropped passwordHash from 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:

TEXT
{ 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 integer

Look 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.

JavaScript
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" });});
TEXT
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.

JavaScript
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:

TEXT
{"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.

TopicExpress 5Fastify 5
Handler style(req, res), you call res.jsonasync (req, reply), you can just return
Body parsingAdd express.json()Built in for JSON
ValidationBring your ownJSON Schema built in
Errors in async handlersCaught (new in 5)Caught
Request loggingAdd a libraryBuilt in (pino)
StructureOne ordered list of middlewareFixed lifecycle hooks + plugins
Learning curveVery small, huge number of tutorialsA few more ideas (schemas, plugins)
Default error outputHTML page unless you add a handlerJSON 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

  1. Copy the Express app. Move the logger below express.json() and send broken JSON. Does the logger print? Move it above and try again.
  2. Add a route GET /boom that throws, to the Express app, with no error handler. Call it with and without NODE_ENV=production. What does the client see each time?
  3. 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.
  4. In Express 5, write a route /docs/* that matches /docs/a/b. Print req.params.
  5. Write a tiny requireToken middleware for Express that checks x-token and protects only /admin. Test it with curl.

Answers

  1. 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 finish event still fires for the error response).
  2. Without NODE_ENV=production the client gets an HTML page with the message and stack paths. With production it gets a plain error page with no message. Best fix: add your own JSON error handler.
  3. The handler receives 1, not true, because of coercion. To reject it, check typeof in a preValidation hook, or turn off type coercion in the Ajv options, or use a schema that matches the exact input you accept.
  4. app.get("/docs/*path", (req, res) => res.json(req.params)). The output is {"path":["a","b"]}.
  5. 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 calls next, 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, call next(), 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 (onRequest to onResponse), plugins with encapsulation, and JSON Schema validation built in.
  • Schema validation can coerce types ("7" to 7, true to 1). 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

Part of the study guide

Related series