AlgoInsight

Free lesson · Node.js Deep Dive

Express: What It Is, What It Isn't

Medium9 min readnodejsjavascriptbackend

Lesson 10.1 - Express: What It Is, What It Isn't

When people say "Node.js," they often mean "Node.js with Express." The two are so commonly paired that engineers learning Node treat Express as part of the language. It isn't. Express is an npm package - one of many web frameworks - that happens to have won the popularity contest in 2010 and has held it since.

This lesson is the careful framing. By the end you should be able to articulate what Express is at a code level (a thin function-composition framework for HTTP), what it explicitly chooses not to do, and what the consequences of those choices are for your application.

I'll be direct: I think Express is a fine choice for many projects, and a poor default choice for new projects in 2026. Both can be true. The point of this lesson is to make you the kind of engineer who can tell the difference.


What Express Actually Is

Strip away the surface, and Express is roughly 1,500 lines of code that wraps Node's http module with three additions:

  1. A routing layer. Match GET /users/:id to a handler function.
  2. A middleware chain. Compose handler functions into a pipeline that processes each request.
  3. Convenience helpers. res.json(), req.params, req.query, etc.

That's it. The Express team has been deliberate about not adding more over the years - no built-in session handling, no validation, no ORM, no template engine bundled. Express is the framework you bring your own everything to.

Here's the entire core mental model:

flowchart LR
    R[Request] --> M1[Middleware 1<br/>e.g., body parser]
    M1 --> M2[Middleware 2<br/>e.g., auth check]
    M2 --> M3[Middleware 3<br/>e.g., route handler]
    M3 --> Res[Response]

    style R fill:#34d399,stroke:#10b981,color:#000
    style Res fill:#a78bfa,stroke:#8b5cf6,color:#000
    style M1 fill:#fbbf24,stroke:#f59e0b,color:#000
    style M2 fill:#fbbf24,stroke:#f59e0b,color:#000
    style M3 fill:#fbbf24,stroke:#f59e0b,color:#000
A flow diagram of the Express request lifecycle. A request enters and passes through a chain of middleware functions. Each middleware can read or modify the request and response, then call next to pass control to the next middleware. The final middleware typically sends the response. Express is, at its core, a function composition framework where each piece in the chain has the signature of req, res, next.

Every middleware has the same signature: (req, res, next) => .... It can read the request, modify the request, modify the response, send the response, or call next() to pass control. That's the entire model. Routing is just middleware that's path-conditioned.

Once you internalize this, Express becomes simple. The rest of the framework is conveniences and routing sugar on top of this one idea.


The "Unopinionated" Claim

Express's marketing has long described it as "fast, unopinionated, minimalist." Two of those three are accurate. "Unopinionated" deserves examination.

Express does have opinions. It has a strong opinion that middleware composition is the right model. It has a strong opinion that the request and response objects can be mutated freely. It has a strong opinion that routing should be in code, not in configuration. It has a strong opinion that asynchronous operations should be the caller's problem to track.

What Express doesn't do is give you opinions about how you structure your application. There's no recommended directory layout. No prescribed pattern for separating routes from business logic. No standard for configuration. No baked-in approach to dependency injection. No conventions about where validation happens.

The consequences are direct: a typical Express codebase looks like its team. Two Express apps written by different teams have nothing in common beyond the framework's middleware signature. This is good for greenfield startups iterating fast - you make decisions as you go. It's bad for hiring, for moving engineers between teams, for onboarding, and for code reviews where reviewers and authors have different mental models.

The opposite pattern is opinionated frameworks - Rails, Django, NestJS, Spring. They tell you where things go. New engineers walking into a Rails codebase already know where the controllers, models, and routes live. The discipline is built in.

Express makes you build that discipline yourself. If you do, you have a clean codebase. If you don't, you have an Express codebase.


What Express Does For You

The conveniences worth knowing:

Routing. app.get('/users/:id', handler) matches the method and path, extracts URL parameters. Without Express, you'd write a router yourself (Module 5 covered this).

Middleware chaining. app.use(middleware) registers a function in the chain. Express calls each in order.

Parsed request data. With express.json() middleware, req.body is parsed. With express.urlencoded(), form data is parsed. With router params, req.params is populated. With query strings, req.query is parsed.

Response helpers. res.json(obj) sets content type and stringifies. res.send(html) infers content type. res.status(404).json(...) chains cleanly. res.redirect() for 30x responses.

Static files. app.use(express.static('public')) serves files from a directory. Handles content types, ETags, range requests, and conditional requests.

Subapp mounting. app.use('/api', apiApp) mounts another Express app at a path prefix. Useful for modular composition.

That's the practical surface. Once you have these, you can build most HTTP APIs.


What Express Explicitly Doesn't Do

The omissions matter as much as the inclusions:

No validation. req.body is parsed but unchecked. If the route expects { name: string, age: number } and the client sends { name: 123, age: "old" }, Express doesn't notice. You bring your own validation library (Zod, Joi, ajv).

No serialization beyond JSON. res.json(data) calls JSON.stringify directly. If your data has Dates, BigInts, or other non-JSON types, you handle them yourself. Fastify, in contrast, can use a schema to do typed, validated serialization.

No async error handling (in Express 4). If a handler awaits and the promise rejects, Express 4 doesn't catch it. The error becomes unhandled. Express 5 fixes this; Express 4 needs a workaround (Lesson 10.4 covers it in detail).

No dependency injection. Whatever services your handlers need - database connection, cache client, config - you wire up yourself, usually via module imports or closures.

No request schemas. No way to declaratively say "this route takes these inputs and produces this output." Bring your own (OpenAPI tooling, Zod schemas, tRPC, GraphQL).

No baked-in logging. Use morgan, or pino-http, or roll your own middleware.

No baked-in metrics. Same.

No structure for tests. You can test Express apps fine, but the framework doesn't prescribe how.

No graceful shutdown helpers. You implement SIGTERM handlers and connection draining yourself.

This list isn't a criticism - Express is deliberately small. But every omission is a decision you now own. The cumulative effect is significant.


A Minimal Express App

const express = require('express');
const app = express();

app.use(express.json());

app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

app.get('/users/:id', async (req, res) => {
  const user = await db.findUser(req.params.id);
  if (!user) return res.status(404).json({ error: 'Not found' });
  res.json(user);
});

app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).json({ error: 'Internal error' });
});

app.listen(3000);

Six things to notice:

  1. express() returns an app - itself a function with methods attached.
  2. app.use(express.json()) registers the JSON body parser as middleware.
  3. app.get(path, handler) registers a route handler for GET.
  4. req.params.id is the URL parameter, populated by the routing layer.
  5. The fourth middleware has four arguments - that's Express's signature for an error handler. The order matters.
  6. app.listen(3000) is a shortcut for http.createServer(app).listen(3000).

Eight lines of substance produce a working API. This is why Express is popular - the on-ramp is gentle.


A Tour of Common Production Setup

A more realistic Express app:

const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const morgan = require('morgan');
const compression = require('compression');

const app = express();

// Security headers
app.use(helmet());

// CORS
app.use(cors({ origin: ALLOWED_ORIGINS, credentials: true }));

// Logging
app.use(morgan('combined'));

// Compression
app.use(compression());

// Body parsing
app.use(express.json({ limit: '1mb' }));

// Routes
app.use('/api/users', require('./routes/users'));
app.use('/api/orders', require('./routes/orders'));

// 404 handler
app.use((req, res) => res.status(404).json({ error: 'Not found' }));

// Error handler
app.use((err, req, res, next) => {
  req.log?.error({ err }, 'Request failed');
  res.status(err.statusCode || 500).json({ error: err.message });
});

app.listen(PORT);

The middleware stack reads top-to-bottom:

  1. helmet sets security-related response headers (X-Frame-Options, Content-Security-Policy, etc.).
  2. cors handles cross-origin requests (Lesson 9.4).
  3. morgan logs each request.
  4. compression gzips responses.
  5. express.json parses JSON request bodies.
  6. Routes handle specific paths.
  7. 404 handler catches unmatched routes.
  8. Error handler catches any thrown error.

Notice that none of these come from Express itself - they're separate packages you install. This is the BYO-everything model. The benefit: you can swap any of them. The cost: you have to know which ones to install.


The "It's Just JavaScript" Trap

A common refrain: "Express is great because it's just JavaScript." The implication is that knowing JavaScript means knowing Express.

This is half-true. The API surface is small, so a JavaScript engineer can read Express code easily. But "easy to read" isn't "easy to write correctly." The pitfalls in Express are mostly in what's not obvious from the code:

A new engineer reading the codebase will understand the syntax. Understanding the system requires reading the framework documentation and absorbing several gotchas. "It's just JavaScript" is a comfortable myth.


Express 5 vs Express 4

Express 4 was the dominant version for almost a decade. Express 5 finally released in 2024-2025 after years of incremental work. The differences matter:

If you're starting a new Express project today, use Express 5. If you're maintaining an Express 4 project, the migration is usually small but worth doing - the async-error fix alone justifies it.

We'll cover the async-error story in detail in Lesson 10.4.


When Express Is the Right Answer

The honest evaluation:

Express is a good default when:

Express may not be the right default when:

For a new greenfield service in 2026, my honest recommendation is to evaluate at minimum Fastify alongside Express. We'll cover the comparison properly in Lesson 10.6.


What I Want You to Take From This Lesson

Five things.

One: Express is a thin function-composition framework for HTTP. Routes are middleware; everything is middleware.

Two: "Unopinionated" means you make all the architecture decisions yourself. Bring discipline or accept the consequences.

Three: Express does very little out of the box. Almost every feature beyond routing comes from third-party packages.

Four: Express 5 fixed the async-error problem. Use Express 5 for new projects.

Five: Express is a good default for some projects and a poor default for others. Evaluate honestly; don't pick by reflex.


Try It

Exercise: Warmup (10 minutes). Install Express 5. Build the minimal app from this lesson - a /health endpoint and a /users/:id endpoint. Hit them with curl. Verify the JSON responses.

Exercise: Core (20 minutes). Compare the same endpoint built three ways: (a) raw http.createServer, (b) Express, (c) Fastify. Note the lines of code for each. Note what each gives you for free. Note where you have to do more work.

Exercise: Core (15 minutes). Build an Express app with the production-style middleware stack (helmet, cors, morgan, compression, express.json). Send a request. Observe the response headers - note which were added by which middleware.

Exercise: Stretch (45 minutes). Read the Express source code (node_modules/express/lib/). Specifically, read application.js, router/index.js, and request.js. Aim for a high-level grasp, not memorization. Notice how small the framework actually is.


What's Next

Lesson 10.2 is reference material on routing - app.get, path patterns, app.Router, route ordering, and the rules that govern how Express picks a handler for an incoming request.

Back to Node.js Deep Dive