Express Fundamentals
Express is a thin routing and middleware layer over Node's HTTP server. Follow requests through global middleware, a router and a handler, and see what happens when nothing matches or a handler throws.
🎯 What you'll learn
- What Express is (and isn't)
- Routing by method and path; params vs query
- res methods and body parsing
- Global middleware with app.use() and the unopinionated design
📚 Before you start
1What Express Actually Is
Express is a thin routing and middleware layer on top of Node's built-in http module — it does not replace http.createServer, it wraps it. Every Express app, at its core, is still a single function passed to http.createServer(app) that gets called with (req, res) for every incoming request:
const express = require("express");
const app = express();
app.get("/", (req, res) => {
res.send("Hello, world!");
});
app.listen(3000, () => console.log("listening on :3000"));Express's entire job is to look at req.method and req.url, decide which of your registered handlers should run, and give you a much nicer req/res API than the raw Node objects provide (raw http's res has no .json(), no .status() — Express adds all of that as a thin convenience layer on top of the same underlying ServerResponse object).
2Routing by Method and Path
app.get("/users", (req, res) => { /* list users */ });
app.post("/users", (req, res) => { /* create a user */ });
app.get("/users/:id", (req, res) => { /* get one user */ });
app.put("/users/:id", (req, res) => { /* replace a user */ });
app.delete("/users/:id", (req, res) => { /* delete a user */ });Express registers each app.METHOD(path, handler) call into an internal list, checked in registration order against the incoming request's method and path. The first matching route wins — a route registered earlier "shadows" a later route with an overlapping pattern, which is a common source of "my handler never runs" bugs (see Common Pitfalls).
3Route Params vs. Query Params
// GET /users/42?include=orders
app.get("/users/:id", (req, res) => {
console.log(req.params.id); // "42" — from the :id segment of the PATH
console.log(req.query.include); // "orders" — from the ?include=... QUERY STRING
res.json({ id: req.params.id, include: req.query.include });
});req.params comes from named segments in the route pattern itself (:id) — it only exists because the route was declared with that placeholder. req.query comes from parsing everything after ? in the URL, is available on every request regardless of route pattern, and every value is always a string (or array of strings for repeated keys) — Express/Node never infers numbers or booleans from the query string.
4res Methods and Response Shaping
res.status(201).json({ id: "u1", name: "Ana" }); // sets status THEN sends JSON body
res.send("plain text or HTML"); // content-type inferred from what you pass
res.sendStatus(404); // shortcut: sets status AND sends its text as body
res.redirect("/login"); // 302 by default
res.set("X-Request-Id", requestId); // set a response headerres.status(code) returns res itself (so it chains into .json()/.send()), and does not send the response on its own — nothing goes out over the socket until you call .send(), .json(), .end(), or similar. Forgetting to call one of those terminal methods leaves the client hanging with no response until it times out.
5Parsing Request Bodies
app.use(express.json()); // parses application/json bodies into req.body
app.use(express.urlencoded({ extended: true })); // parses HTML form submissions
app.post("/users", (req, res) => {
console.log(req.body); // { name: "Ana", email: "ana@example.com" } — only populated
// because express.json() ran first and parsed the raw stream
});Without registering express.json() first, req.body is undefined even for a request that clearly has a JSON body — Express does not parse request bodies by default, because reading and buffering the body is itself a design choice (streaming large uploads shouldn't be forced through a JSON parser). This is the first concrete example of middleware ordering mattering, expanded on next.
6Global Middleware with app.use()
app.use((req, res, next) => {
console.log(`${req.method} ${req.url}`); // runs for EVERY request, before any route handler
next(); // MUST call this, or the request hangs forever — nothing downstream ever runs
});
app.get("/", (req, res) => res.send("home"));app.use() without a path registers a handler that runs for every request, in the order it was registered, before the matching route handler — this is Express's middleware chain, covered in depth in the dedicated 06-middleware/ topic in this group. The critical rule introduced here: every middleware function must call next() (to hand off to the next one in the chain) or send a response itself — doing neither leaves the request permanently unresolved.
7Minimal and Unopinionated by Design
Express deliberately does not prescribe a folder structure, a dependency-injection system, a validation library, or an ORM — it hands you app.get/post/..., app.use(), and a nicer req/res, and nothing else. This is the direct contrast with NestJS (covered in 02-nestjs_fundamentals/), which is built on top of Express (or Fastify) and adds an opinionated, decorator-based structure — modules, controllers, dependency injection — as a deliberate answer to the fact that large Express codebases tend to reinvent that structure inconsistently, project by project, once they outgrow a handful of route files.
⚠️ Common Pitfalls
- Registering a more general route before a more specific one that should take precedence.
app.get("/users/:id", ...)registered beforeapp.get("/users/me", ...)means/users/megets swallowed by the:idpattern ("me"matches:idjust fine) and the more specific handler never runs — order matters, most-specific-first. - Forgetting
express.json()before readingreq.body.req.bodyis silentlyundefined, not an error — easy to miss until a handler mysteriously "loses" data that was clearly sent. - Calling
res.json(...)more than once for the same request. Node throwsERR_HTTP_HEADERS_SENT— a common cause is anifbranch that sends a response but forgets toreturn, so code after it also tries to send. - Forgetting to call
next()in a middleware that doesn't itself send a response. The request hangs until the client times out — no error is thrown, which makes it easy to miss in testing. - Doing real business logic directly inside route handlers. Works fine for a tiny app, but doesn't scale — this is exactly the thin-controller principle covered in
05-controllers/.
📋 Quick Reference
app.get/post/put/delete(path, handler) -- register a route
req.params.<name> -- from :name in the route PATH (only exists if declared)
req.query.<name> -- from ?name=... in the URL, always present, always a string
res.status(code).json(obj) -- chainable; nothing sent until a terminal method is called
app.use(fn) -- global middleware, runs in registration order, before route handlers
express.json() -- MUST be app.use()'d before req.body is populated for JSON requests
next() -- hands off to the next middleware/handler; omitting it hangs the request🧠 Key takeaways
- Express = routing + middleware on top of
http.createServer; it imposes no structure. - Routes match method + path;
req.paramsfrom the path,req.queryfrom the query string. - Middleware runs in registration order; each calls
next()or ends the response. - Add
express.json()for bodies, a catch-all 404, and a 4-argument error middleware last.
🎤 Interview Q&A
Q1How does Express handle a request?core▾
It runs a chain of middleware functions (req, res, next) in registration order; each can modify req/res, end the response, or call next() to continue. Routers are middleware too. Errors go to error-handling middleware with four arguments (err, req, res, next).
- What happens if no middleware responds? The request hangs until timeout (or hits a 404 handler if defined).
Q2How do you handle async errors in Express?core▾
Express 4 doesn't catch rejected promises from async handlers; you must wrap them (express-async-errors, a wrapper, or try/catch + next(err)). Express 5 forwards rejected promises to error middleware automatically.
- Consequence of forgetting? Unhandled rejections and hanging requests.
Q3How would you structure a production Express app?core▾
Separate routes, controllers, services and data access; centralized error handling; validation middleware (Zod/Joi); security middleware (helmet, CORS config, rate limiting); structured logging (pino); config via environment; graceful shutdown.
- What's helmet? Middleware setting security headers.
Q4How do you implement graceful shutdown?senior▾
On SIGTERM: stop accepting new connections (server.close()), let in-flight requests finish with a timeout, close DB pools and queues, then exit. Fail readiness probes first so load balancers stop sending traffic.
- Why does it matter in Kubernetes? Pods get SIGTERM on every deploy; abrupt exits drop requests.
Q5Express vs Fastify vs NestJS?core▾
Express: minimal, huge ecosystem, unopinionated. Fastify: faster, schema-based validation and serialization, plugin encapsulation. NestJS: opinionated framework with modules, DI and decorators, built on Express or Fastify, suited for large teams.
- Why is Fastify faster? Optimized routing and JSON serialization compiled from schemas.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.