#A minimal Express app
Node's built-in http module gives you one callback for every request. Inside it you get a raw request (a readable stream plus a method, a URL string and headers) and a raw response (a writable stream). Everything else is your job: matching methods and paths, splitting off the query string, reading and parsing the body, choosing status codes and headers, and sending a 404 when nothing matches.
Express is a function that you hand to that HTTP server. It has the same (req, res) shape as the callback you'd write yourself, but inside it keeps an ordered list of layers — middleware and routes — and walks that list for each request. On top of that it adds routing by method and path with named parameters, helpers on req and res (req.params, req.query, req.get(), res.status(), res.json()), and a central place for errors.
A good mental model is a conveyor belt. Every request is put on the belt at one end. Each station (middleware or route) looks at it and either finishes it (sends the response), passes it on (next()), or throws it onto a separate "rejects" belt that only error handlers stand next to (next(err)).
Here's the plain-Node version first, so you can see what Express replaces:
const http = require('node:http');
const server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/') {
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Hello, World!');
return;
}
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Not found');
});
server.listen(3000, () => console.log('Listening on 3000'));
// $ curl http://localhost:3000/ -> Hello, World!
// $ curl http://localhost:3000/x -> Not found
// $ curl 'http://localhost:3000/?a=1' -> Not found (!)http.createServer(callback)creates a server object and registerscallbackas the listener for its'request'event. Nothing is listening on a port yet.server.listen(3000, …)asks the OS to bind port 3000. When that succeeds the server emits'listening'and the callback logsListening on 3000.- For each request Node parses the HTTP headers and calls your callback with
req(anhttp.IncomingMessage) andres(anhttp.ServerResponse). The same function handles every method and every path. - You compare
req.methodandreq.urlby hand.req.urlis the raw path including the query string, so/?a=1isn't equal to'/'and falls through to the 404 — the kind of detail Express handles for you. res.writeHeadsets the status and headers;res.endwrites the body and finishes the response. Because noContent-Lengthwas set, Node sent the body withTransfer-Encoding: chunked.
The Express version of the same thing:
const express = require('express');
const app = express();
app.get('/', (req, res) => {
res.send('Hello, World!');
});
const server = app.listen(3000, (err) => {
if (err) throw err;
console.log('Server is running on port 3000');
});curl -i responseHTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/html; charset=utf-8
Content-Length: 13
ETag: W/"d-CgqfKmdylCVXq1NV12r0Qvj2XgE"
Date: Wed, 07 Oct 2026 18:50:16 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Hello, World!express()returnsapp, which is itself a function with the signature(req, res, next)(typeof app === 'function',app.length === 3). It also carries methods likeget,useandlisten.app.get('/', handler)only registers a layer: "forGETrequests whose path is/, call this handler". The handler doesn't run at startup — it runs later, once per matching request.app.listen(3000, …)creates anhttp.Serverwithappas its request listener and starts listening (the next topic looks at this in detail).- When
GET /arrives, Express strips the query string, walks its layers, finds the match and calls the handler.res.send('Hello, World!')sees a string, so it setsContent-Type: text/html; charset=utf-8, computesContent-Length: 13(13 bytes), and generates a weakETag(dis 13 in hex, followed by a hash of the body).X-Powered-By: Expressis added by default. - A request for
/nopematches nothing. When Express runs out of layers, its final handler sends a404with an HTML bodyCannot GET /nopeand aContent-Security-Policy: default-src 'none'header. You never wrote that code.