π Express.js Architecture Overview
Node.js gives you a raw HTTP server and not much else. Express is the thin, battle-tested layer that turns that raw server into an organized application β with routing, a middleware pipeline, and a structure that scales from a weekend script to a production API. This lesson maps the whole machine before you start pulling levers.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain what Express.js is and why it sits on top of Node's built-in
httpmodule - Trace a request through the Express request-response cycle and middleware pipeline
- Describe the role of the application object (
app) and the four categories of middleware - Organize an Express project into a layered architecture (routes β controllers β services β data access)
- Compare Express to Nest, Koa, and Fastify and choose the right tool for a job
Estimated Time: 35β45 minutes β’ Difficulty: BeginnerβIntermediate
Hands-on: Build and run a minimal Express server, then instrument it with a timing middleware.
In This Lesson
What Is Express.js?
Express.js is a minimal, unopinionated web framework for Node.js. "Minimal" means it ships with a small core β routing, middleware, and a few HTTP helpers β and leaves everything else (validation, authentication, database access) to packages you choose. "Unopinionated" means it doesn't force a folder layout or a pattern on you; that freedom is both its greatest strength and the thing beginners most often misuse.
To see the value Express adds, look at the same "hello world" server written with Node's raw http module versus with Express:
Raw Node.js
const http = require('http');
const server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/hello') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ message: 'Hello World!' }));
} else {
res.writeHead(404);
res.end('Not Found');
}
});
server.listen(3000);
Express
const express = require('express');
const app = express();
app.get('/hello', (req, res) => {
res.json({ message: 'Hello World!' });
});
app.listen(3000);
Express replaced the manual if ladder over req.method and req.url with a declarative app.get('/hello', ...), added res.json(), and handles unmatched routes for you. Multiply that convenience across dozens of routes and you understand why Express became the de facto standard for Node web apps and APIs.
π Key Terms
Route: a pairing of an HTTP method + URL path with a handler function.
Middleware: a function that runs during the request-response cycle, with access to req, res, and next.
Handler: the final middleware in a route that produces the response.
Application object (app): the central object returned by express() that holds your settings, middleware, and routes.
π The railway analogy. Think of Express as a railway network. Routes are tracks leading to destinations; middleware functions are the stations a train stops at along the way; the request is a train carrying cargo (data); the response is the train returning with processed goods; and the app is the central control system coordinating the whole network.
The Request-Response Cycle
Everything Express does happens inside a single, repeating cycle. A client sends an HTTP request; Express wraps it in enhanced req and res objects; the request travels through middleware and lands on a matching route handler; the handler sends a response; the cycle ends.
Step by step:
- A client sends an HTTP request to the Express server.
- Express creates enhanced
req(request) andres(response) objects. - The request passes through the middleware stack, in the order middleware was registered.
- Routing matches the request's method and path to a handler.
- The handler produces a response (
res.json(),res.send(),res.render(), β¦) which ends the cycle.
β οΈ The cycle must end exactly once
Every request must produce one response. If no middleware or handler ever responds, the client hangs until it times out. If two of them respond, you get the infamous Error: Cannot set headers after they are sent. Send a response or call next() β never both for the same request.
The Application Object
Calling express() returns the application object, conventionally named app. It is the hub of your program: you register middleware on it, define routes on it, configure settings on it, and finally tell it to listen for connections.
const express = require('express');
const app = express();
// Configuration β key/value settings
app.set('trust proxy', 1);
// Middleware β runs for every request
app.use(express.json());
// A route
app.get('/', (req, res) => {
res.send('Hello World!');
});
// Start listening
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
Notice process.env.PORT || 3000. Hosting platforms inject the port to bind through the PORT environment variable; falling back to 3000 keeps local development simple. Hard-coding a port is one of the most common reasons a working app fails to deploy.
The app object's main responsibilities:
- Configure settings via
app.set(key, value)and read them withapp.get(key)(yes,app.getis overloaded β with one string argument it reads a setting; with a path and handler it defines a route). - Register middleware with
app.use(). - Define routes with
app.get(),app.post(),app.put(),app.delete(), etc. - Start the HTTP server with
app.listen().
The Middleware Pipeline
Middleware is the single most important concept in Express. A middleware function receives three arguments β req, res, and next β and can do one of two things: end the cycle by sending a response, or call next() to pass control to the next function in the stack.
π The assembly-line analogy. Middleware works like stations on an assembly line. Each station performs one focused operation on the product (the request), then passes it down the line. Any station can reject a defective product early (send a 401) or enrich it (attach req.user). The last station β the route handler β ships the finished product.
Here is a small app that shows three middleware working together β a logger for every request, a request-timer, and a route-specific auth guard:
const express = require('express');
const app = express();
// 1. Application-level middleware: logs every request
app.use((req, res, next) => {
console.log(`${new Date().toISOString()} ${req.method} ${req.url}`);
next();
});
// 2. Application-level middleware: stamp a start time on req
app.use((req, res, next) => {
req.startTime = Date.now();
next();
});
// 3. Route-specific middleware: an auth guard before the handler
const requireToken = (req, res, next) => {
if (req.query.token !== 'secret') {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
};
app.get('/protected', requireToken, (req, res) => {
const ms = Date.now() - req.startTime;
res.json({ message: 'Protected content', responseTimeMs: ms });
});
app.listen(3000, () => console.log('Listening on 3000'));
The four categories of middleware
| Category | Bound to | Typical use |
|---|---|---|
| Application-level | app.use() / app.METHOD() | Logging, body parsing, global concerns |
| Router-level | router.use() | Concerns scoped to one router |
| Error-handling | app.use((err, req, res, next) => β¦) | Catching and formatting errors |
| Built-in / third-party | express.json(), helmet(), β¦ | Common tasks via reusable packages |
Express ships a handful of built-in middleware and there is a rich ecosystem of third-party ones. A sensible production baseline looks like this:
const express = require('express');
const morgan = require('morgan'); // request logging
const helmet = require('helmet'); // secure HTTP headers
const cors = require('cors'); // cross-origin requests
const compression = require('compression'); // gzip responses
const app = express();
app.use(helmet());
app.use(cors());
app.use(compression());
app.use(morgan('dev'));
app.use(express.json()); // parse JSON bodies
app.use(express.urlencoded({ extended: true })); // parse form bodies
app.use(express.static('public')); // serve static files
β οΈ Order matters β a lot
Middleware runs in the order you register it. If you define a route before app.use(express.json()), that route's req.body will be undefined. Register body parsers, security headers, and loggers near the top of your file, before your routes.
Layered Architecture
Express itself is minimal, so you supply the structure. As an app grows past a single file, the community-standard answer is a layered architecture that separates concerns: routes decide where a request goes, controllers translate HTTP into calls, services hold the business logic, and the data-access layer talks to the database.
| Layer | Responsibility | Knows about |
|---|---|---|
| Routes | Map method + path to a controller | HTTP verbs, URLs |
| Controllers | Read req, call a service, shape res | HTTP, not the database |
| Services | Business rules, orchestration | Domain logic, not HTTP |
| Data access | Queries and persistence | The database only |
The payoff: each layer can be tested and changed in isolation. You can swap MongoDB for PostgreSQL by rewriting only the data-access layer, or reuse a service from both an HTTP route and a background job. A typical folder layout that reflects these layers:
project-root/
βββ public/ # static assets (CSS, images, client JS)
βββ src/
β βββ config/ # env + configuration
β βββ controllers/ # HTTP-facing request handlers
β βββ middleware/ # custom middleware
β βββ models/ # data models / schemas
β βββ routes/ # route definitions
β βββ services/ # business logic
β βββ utils/ # helpers
β βββ app.js # wires middleware + routes together
βββ tests/
βββ .env
βββ .gitignore
βββ package.json
Express vs Other Frameworks
Express is one of several Node.js frameworks. Knowing where it sits helps you pick deliberately rather than by habit.
| Framework | Philosophy | Strengths | Trade-offs | Best for |
|---|---|---|---|---|
| Express | Minimal, flexible | Lightweight, huge ecosystem, gentle learning curve | You assemble structure yourself | APIs, microservices, smallβmedium apps |
| NestJS | Structured, Angular-inspired | Built-in architecture, TypeScript, dependency injection | Steeper curve, more boilerplate | Large enterprise systems |
| Koa | Modern minimal core | Native async/await, tiny footprint, clean error flow | Smaller ecosystem | Modern apps wanting a lean base |
| Fastify | Performance-focused | Very fast, schema-based validation, plugins | Newer, different plugin model | High-throughput APIs |
β Why Express still dominates
Maturity (in production since 2010), an unmatched ecosystem of compatible middleware, a massive community, and a small surface area you can learn in an afternoon. Even NestJS runs on Express under the hood by default. Learn Express well and the others become dialects.
Hands-on Exercise
ποΈ Build and instrument a minimal server
Objective: create a small Express app and add a middleware that measures how long each request takes and returns it in a response header.
Instructions:
- In an empty folder run
npm init -ythennpm install express. - Create
app.jswith a server that listens onprocess.env.PORT || 3000. - Add an application-level middleware that stamps
req.startTime = Date.now(). - Add a second middleware that, on the response's
finishevent, sets a headerX-Response-Timein milliseconds β or logs it if headers are already sent. - Add a
GET /helloroute that returns JSON, and start the server. - Visit
http://localhost:3000/helloand confirm the timing appears.
π‘ Hint
Headers must be set before the body is sent. The clean approach is to capture start at the top, then attach res.on('finish', ...) to log the duration. To put the value in a header instead, override res.json/res.send or compute duration synchronously before responding in the handler.
β Sample solution
const express = require('express');
const app = express();
// Stamp a start time on every request
app.use((req, res, next) => {
req.startTime = Date.now();
next();
});
// Log how long the response took once it is finished
app.use((req, res, next) => {
res.on('finish', () => {
const ms = Date.now() - req.startTime;
console.log(`${req.method} ${req.url} -> ${res.statusCode} (${ms}ms)`);
});
next();
});
app.get('/hello', (req, res) => {
const ms = Date.now() - req.startTime;
res.set('X-Response-Time', `${ms}ms`);
res.json({ message: 'Hello World!' });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Running on ${PORT}`));
π― Quick Quiz
Question 1: What must a middleware function do so the request does not hang?
Question 2: Why does req.body come back undefined in a route?
Question 3: In a layered architecture, which layer should contain business rules?
Best Practices
β Do
- Register security, logging, and body-parsing middleware before your routes.
- Bind to
process.env.PORT || 3000so the app deploys anywhere. - Split routes into modules with
express.Router()as the app grows. - Keep controllers thin β push logic into services.
- Add a single error-handling middleware last, with the
(err, req, res, next)signature.
β οΈ Don't
- Don't send more than one response per request.
- Don't put database queries directly in route handlers for anything beyond a demo.
- Don't forget
next()β a middleware that neither responds nor calls it silently stalls the request. - Don't hard-code secrets or ports; read them from the environment.
Summary & Quiz
π Key Takeaways
- Express is a thin, unopinionated layer over Node's
httpmodule that adds routing, middleware, and HTTP helpers. - Every feature plugs into the request-response cycle, which must end in exactly one response.
- The application object (
app) configures settings, registers middleware, defines routes, and starts the server. - Middleware runs in registration order; each function responds or calls
next(). - A layered architecture (routes β controllers β services β data) keeps growing apps maintainable.
π Further Reading
- Express.js Official Documentation
- MDN β Express/Node.js server-side tutorial
- Express β Using middleware
π What's Next?
Now that you can see the whole machine, the next lesson zooms into the two levers you'll pull most: routing and middleware. You'll master route parameters, query strings, express.Router(), and a proper error-handling pipeline.
π Great start!
You have the architecture in your head. Time to wire up real routes.