Node.js and Express

What Node.js is and how it serves thousands of connections on one thread, then Express step by step: routing, the middleware pipeline, validation, async error handling, project structure, configuration, security, rate limiting, logging and graceful shutdown.

Intermediate⏱ 6 min readLesson 5 of 13#languages#nodejs#express#middleware#backend

The big idea

Node.js is the JavaScript engine from Chrome (V8) taken out of the browser, plus a library (libuv) that talks to the operating system for files, networks and timers. It lets JavaScript be a server.

Express is a thin layer on top: a way to say "when a GET /users/:id arrives, run these functions in order". Those functions are middleware, like stations on an airport security line: check the ticket, scan the bag, check the passport, and at any station you can be sent back.

An Express request passes through a pipeline of middleware to a route handler

Node.js in one picture

Drawing diagram…
FeatureWhat it means for you
Non-blocking I/OOne process can hold thousands of idle connections cheaply
Single JS threadCPU-heavy code blocks everyone (see the async lesson)
npmThe largest package registry; also the largest supply-chain risk
Same language as the frontendShare types and validation between client and server
cluster / multiple processesUse all CPU cores (or run several containers behind a load balancer)

Node vs Express

Node.js aloneWith Express
http.createServer((req, res) => …)app.get("/users/:id", handler)
Parse URL, method and body yourselfRouting, params, express.json() built in
Write your own plumbing for errorsError-handling middleware

A first server

import express from "express";

const app = express();
app.use(express.json({ limit: "100kb" })); // parse JSON bodies, with a size cap

app.get("/health", (_req, res) => res.json({ ok: true }));

app.get("/users/:id", async (req, res) => {
  const user = await users.findById(req.params.id);
  if (!user) return res.status(404).json({ error: "User not found" });
  res.json(user);
});

app.listen(3000, () => console.log("Listening on http://localhost:3000"));
Where data livesExampleRead with
Path parameter/users/42req.params.id
Query string/users?page=2req.query.page (always a string)
BodyJSON in a POSTreq.body (after express.json())
HeaderAuthorization: Bearer …req.get("authorization")

Middleware: the pipeline ⭐

A middleware is (req, res, next) => { … }. It can read/modify the request, end the response, or call next() to pass it on.

Drawing diagram…
// Application-level: runs for every request
app.use((req, res, next) => {
  const start = performance.now();
  res.on("finish", () => {
    console.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${Math.round(performance.now() - start)}ms`);
  });
  next();
});

// Route-level: only where you attach it
function requireAuth(req, _res, next) {
  const token = req.get("authorization")?.replace("Bearer ", "");
  if (!token) return next(new HttpError(401, "Missing token"));
  try {
    req.user = verifyJwt(token);
    next();
  } catch {
    next(new HttpError(401, "Invalid token"));
  }
}

app.post("/orders", requireAuth, validate(CreateOrder), createOrder);

⚠️ Order matters. Middleware runs in the order you app.use() it. Body parsing must come before routes that read req.body; the error handler must come last.

Validation and errors

import { z } from "zod";

class HttpError extends Error {
  constructor(status, message) { super(message); this.status = status; }
}

const validate = (schema) => (req, _res, next) => {
  const result = schema.safeParse(req.body);
  if (!result.success) return next(new HttpError(400, result.error.issues[0].message));
  req.body = result.data;
  next();
};

// The error handler has FOUR parameters — that's how Express recognises it
app.use((err, req, res, _next) => {
  const status = err.status ?? 500;
  if (status >= 500) req.log?.error({ err }, "request failed");
  res.status(status).json({
    error: status >= 500 ? "Internal server error" : err.message, // never leak internals
  });
});

Express 5 (the current major version) forwards rejected promises from async handlers to the error handler automatically. In Express 4 you needed try/catch + next(err) or a wrapper in every async route.

Structuring a larger app

Keep HTTP details at the edge and business rules in plain functions you can test without a server.

src/
├── app.js              # builds the express app (no listen) — easy to test
├── server.js           # reads config, calls app.listen, handles shutdown
├── config.js           # validated environment variables
├── middleware/         # auth, validate, errorHandler, requestId
└── modules/
    └── orders/
        ├── orders.routes.js      # URL → controller
        ├── orders.controller.js  # HTTP in/out only
        ├── orders.service.js     # business rules
        └── orders.repository.js  # database queries
// orders.routes.js
import { Router } from "express";
export const ordersRouter = Router();
ordersRouter.get("/", listOrders);
ordersRouter.post("/", requireAuth, validate(CreateOrder), createOrder);

// app.js
app.use("/api/orders", ordersRouter);

Configuration

// config.js — fail at startup, not at the first request
import { z } from "zod";

export const config = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
}).parse(process.env);

Load a local .env with node --env-file=.env server.js (built into Node 20.6+). Never commit .env files with real secrets.

Production essentials

ConcernTool / approach
Security headershelmet()
CORScors({ origin: ["https://app.example.com"], credentials: true }), never * with cookies
Rate limitingexpress-rate-limit (with a Redis store when you run several instances)
Structured logspino / pino-http with a request ID on every line
File uploadsmulter with size and type limits, store in object storage (S3)
Using all coresSeveral processes: containers, PM2, or node:cluster
Unhandled errorsLog, exit and let the orchestrator restart the process
import helmet from "helmet";
import rateLimit from "express-rate-limit";

app.disable("x-powered-by");
app.use(helmet());
app.use("/api/auth", rateLimit({ windowMs: 15 * 60_000, limit: 20 }));

Graceful shutdown

When Kubernetes or Docker stops your container it sends SIGTERM. Finish in-flight requests before exiting:

const server = app.listen(config.PORT);

process.on("SIGTERM", () => {
  server.close(async () => {   // stop accepting, wait for open requests
    await db.end();
    process.exit(0);
  });
  setTimeout(() => process.exit(1), 10_000).unref(); // hard stop if stuck
});

Testing a route

import request from "supertest";
import { buildApp } from "../src/app.js";

test("GET /health returns ok", async () => {
  const res = await request(buildApp()).get("/health");
  expect(res.status).toBe(200);
  expect(res.body).toEqual({ ok: true });
});

Key takeaways

  • Node.js = V8 + libuv: one JavaScript thread, non-blocking I/O, great for I/O-heavy APIs.
  • Express is routing + a middleware pipeline; order matters and the 4-argument error handler goes last.
  • Validate every request at the edge, return consistent JSON errors, and never leak stack traces.
  • Split routes → controller → service → repository; export the app separately from listen.
  • Validate config at startup, add helmet, CORS, rate limits and structured logs, and shut down gracefully on SIGTERM.