Skip to main content

πŸš‚ 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 http module
  • 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.

The Express request-response cycle A client request enters the Express app, passes through a chain of middleware functions, reaches a route handler that builds a response, and the response returns to the client. Client browser / app Express Application Middleware chain Route handler Routing matches method + path Response request response returns to client
Figure 1 β€” A request enters the app, flows through middleware, is routed to a handler, and the response travels back. Every Express feature plugs into some point on this loop.

Step by step:

  1. A client sends an HTTP request to the Express server.
  2. Express creates enhanced req (request) and res (response) objects.
  3. The request passes through the middleware stack, in the order middleware was registered.
  4. Routing matches the request's method and path to a handler.
  5. 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 with app.get(key) (yes, app.get is 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.

flowchart LR A[Request] --> B[Logger] B -->|"next()"| C[Body parser] C -->|"next()"| D[Auth check] D -->|"next()"| E[Route handler] E --> F[Response] D -->|not authorized| F
🏭 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

CategoryBound toTypical use
Application-levelapp.use() / app.METHOD()Logging, body parsing, global concerns
Router-levelrouter.use()Concerns scoped to one router
Error-handlingapp.use((err, req, res, next) => …)Catching and formatting errors
Built-in / third-partyexpress.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.

flowchart TD Client[Client] <--> Routes[Routes Layer] Routes <--> Controllers[Controllers Layer] Controllers <--> Services[Services Layer] Services <--> Data[Data Access Layer] Data <--> DB[(Database)]
LayerResponsibilityKnows about
RoutesMap method + path to a controllerHTTP verbs, URLs
ControllersRead req, call a service, shape resHTTP, not the database
ServicesBusiness rules, orchestrationDomain logic, not HTTP
Data accessQueries and persistenceThe 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.

FrameworkPhilosophyStrengthsTrade-offsBest for
ExpressMinimal, flexibleLightweight, huge ecosystem, gentle learning curveYou assemble structure yourselfAPIs, microservices, small–medium apps
NestJSStructured, Angular-inspiredBuilt-in architecture, TypeScript, dependency injectionSteeper curve, more boilerplateLarge enterprise systems
KoaModern minimal coreNative async/await, tiny footprint, clean error flowSmaller ecosystemModern apps wanting a lean base
FastifyPerformance-focusedVery fast, schema-based validation, pluginsNewer, different plugin modelHigh-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:

  1. In an empty folder run npm init -y then npm install express.
  2. Create app.js with a server that listens on process.env.PORT || 3000.
  3. Add an application-level middleware that stamps req.startTime = Date.now().
  4. Add a second middleware that, on the response's finish event, sets a header X-Response-Time in milliseconds β€” or logs it if headers are already sent.
  5. Add a GET /hello route that returns JSON, and start the server.
  6. Visit http://localhost:3000/hello and 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 || 3000 so 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 http module 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

πŸš€ 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.