Lesson 03 / 16
The Web Server Concept
The server side's three distinct roles: serving files with the correct headers, splitting requests by path, and handing a request off to a backend server, each set up and measured as a separate process.
Contents
In the previous two lessons, “the server” was treated as a single program: one process that
listens on a port, reads the request, enforces the rule, and writes the response. The library
application’s real traffic, however, is not homogeneous. The same port receives both a
/style.css request and a /api/books request; the first is a file on disk being served
as-is, the second is the result of a computation.
This lesson splits that distinction into three roles and runs each role as a separate process: static serving, routing, and proxying. When the roles are set up separately, which job belongs to which component becomes visible; when they are gathered into a single program, the same roles still exist, only their boundaries are invisible.
Static Serving: Finding the File and Returning It with the Right Header
Static serving means mapping a request’s path to a file on the file system and sending the content back as-is. On the surface it looks like a one-line job; counted out, it has four responsibilities.
// static-server.mjs — serves files under assets/, blocks escaping outside it import { createServer } from "node:http"; import { readFile } from "node:fs/promises"; import { join, normalize, extname, sep } from "node:path"; const ROOT = join(process.cwd(), "assets"); const TYPES = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".json": "application/json; charset=utf-8" }; createServer(async (req, res) => { res.sendDate = false; res.setHeader("x-served-by", "static"); const path = decodeURIComponent(new URL(req.url, "http://local").pathname); const full = normalize(join(ROOT, path === "/" ? "/index.html" : path)); if (!full.startsWith(ROOT + sep)) { // path escaping outside the root is rejected return res.writeHead(403, { "content-type": "text/plain" }).end("outside root\n"); } try { const content = await readFile(full); res.writeHead(200, { "content-type": TYPES[extname(full)] ?? "application/octet-stream", "content-length": content.length, "cache-control": "public, max-age=31536000, immutable", }).end(content); } catch { res.writeHead(404, { "content-type": "text/plain" }).end("not found\n"); } }).listen(8427, "127.0.0.1", () => console.log("static 127.0.0.1:8427"));
To run the example, there must be an assets directory next to the server file, containing
two files:
mkdir -p assets printf 'body { font-family: system-ui; }\n.book { padding: 8px; }\n' > assets/style.css printf '<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><title>Library</title>\n<link rel="stylesheet" href="/style.css"></head>\n<body><h1>Library Loan Service</h1></body></html>\n' > assets/index.html
#!/usr/bin/env bash # Starts static-server.mjs, tries four paths, stops it. The assets/ directory must sit next to it. node static-server.mjs & server=$! sleep 0.5 for path in / /style.css /missing.css '/%2e%2e%2f%2e%2e%2fetc%2fhosts'; do printf '%-32s -> ' "$path" curl -sS -o /dev/null --path-as-is -w '%{http_code} type=%{content_type} size=%{size_download} B\n' \ "http://127.0.0.1:8427$path" done echo "--- response headers ---" curl -sS -o /dev/null -D - --path-as-is http://127.0.0.1:8427/style.css kill "$server"
static 127.0.0.1:8427 / -> 200 type=text/html; charset=utf-8 size=182 B /style.css -> 200 type=text/css; charset=utf-8 size=57 B /missing.css -> 404 type=text/plain size=10 B /%2e%2e%2f%2e%2e%2fetc%2fhosts -> 403 type=text/plain size=13 B --- response headers --- HTTP/1.1 200 OK x-served-by: static content-type: text/css; charset=utf-8 content-length: 57 cache-control: public, max-age=31536000, immutable Connection: keep-alive Keep-Alive: timeout=5
The four responsibilities appear separately in the output. Path mapping: the / request
corresponds to the directory entry. Content type: the same server produced two different
types; when the type is wrong, the browser cannot interpret the file and the style does not
apply. Missing resource: a missing file is not a crash, it is a 404 status code. Root
boundary: the last request got a 403.
The last line also carries a warning. The new URL() constructor simplifies .. components
in the path on its own, so a plain /../../etc/hosts request is cleaned up before it reaches
the server. The encoded form (%2e%2e%2f), however, passes through without simplification and
turns into a path that escapes the directory after decodeURIComponent. This is why the check
is done on the resolved absolute path, compared against the root directory: a check that
looks at the textual form of the path has to anticipate every encoding of it in advance.
The cache-control header in the response carries the distinguishing property of static
serving: because the content does not vary by request, it can be cached for a long time. The
meaning and trade-offs of this header were established in The Browser and the Web Platform
course; what matters here is that the same header cannot be placed on a dynamic response.
Routing and Proxying: Getting the Request to the Right Component
The static server does not know what to do with a /api/books request, and the loan
application should not have to bother reading a CSS file. The component that sets up the
division of labor between them is the reverse proxy: it accepts the request coming from
the client, forwards it to one of the backend servers according to a rule, and writes the
response it gets back to the client.
// application-server.mjs — loan application; tells the request which connection it came from import { createServer } from "node:http"; const BOOKS = [{ isbn: "978-0262033848", title: "Introduction to Algorithms", shelf: "R-12" }]; createServer((req, res) => { res.sendDate = false; res.setHeader("x-served-by", "application"); res.setHeader("content-type", "application/json; charset=utf-8"); res.writeHead(200).end(JSON.stringify({ path: req.url, books: BOOKS, connectionOwner: `${req.socket.remoteAddress}:${req.socket.remotePort}`, forwarded: req.headers.forwarded ?? null, })); }).listen(8428, "127.0.0.1", () => console.log("application 127.0.0.1:8428"));
// proxy.mjs — reverse proxy in front: forwards to one of two backends by path import { createServer, request } from "node:http"; const TARGET = (path) => (path.startsWith("/api/") ? 8428 : 8427); createServer((outerReq, outerRes) => { outerRes.sendDate = false; const source = outerReq.socket.remoteAddress; const headers = { ...outerReq.headers, forwarded: `for="${source}";proto=http` }; const innerReq = request( { host: "127.0.0.1", port: TARGET(outerReq.url), path: outerReq.url, method: outerReq.method, headers }, (innerRes) => { outerRes.writeHead(innerRes.statusCode, { ...innerRes.headers, "x-proxy": "passed" }); innerRes.pipe(outerRes); }, ); innerReq.on("error", () => outerRes.writeHead(502).end("backend unreachable\n")); outerReq.pipe(innerReq); }).listen(8429, "127.0.0.1", () => console.log("proxy 127.0.0.1:8429"));
#!/usr/bin/env bash # Starts all three servers together: static (8427), application (8428), proxy (8429). node static-server.mjs & s1=$! node application-server.mjs & s2=$! node proxy.mjs & s3=$! sleep 0.6 echo "--- through the proxy: which backend answered ---" for path in /style.css /api/books; do printf '%-16s -> ' "$path" curl -sS -o /dev/null -D - "http://127.0.0.1:8429$path" | grep -iE '^(HTTP|x-served-by|x-proxy)' | tr -d '\r' | paste -sd' ' - done echo "--- connection seen by the application: direct request and request through the proxy ---" printf 'direct : '; curl -sS http://127.0.0.1:8428/api/books | tr ',' '\n' | grep -E 'connectionOwner|forwarded' | paste -sd' ' - printf 'proxied : '; curl -sS http://127.0.0.1:8429/api/books | tr ',' '\n' | grep -E 'connectionOwner|forwarded' | paste -sd' ' - echo "--- duration of the extra hop (same response, two paths) ---" curl -sS -o /dev/null -w 'direct : %{time_total} s\n' http://127.0.0.1:8428/api/books curl -sS -o /dev/null -w 'proxied : %{time_total} s\n' http://127.0.0.1:8429/api/books kill "$s1" "$s2" "$s3"
application 127.0.0.1:8428 proxy 127.0.0.1:8429 static 127.0.0.1:8427 --- through the proxy: which backend answered --- /style.css -> HTTP/1.1 200 OK x-served-by: static x-proxy: passed /api/books -> HTTP/1.1 200 OK x-served-by: application x-proxy: passed --- connection seen by the application: direct request and request through the proxy --- direct : "connectionOwner":"127.0.0.1:59338" "forwarded":null} proxied : "connectionOwner":"127.0.0.1:59337" "forwarded":"for=\"127.0.0.1\";proto=http"} --- duration of the extra hop (same response, two paths) --- direct : 0.000647 s proxied : 0.000935 s
The order of the three servers’ startup lines in the output changes from run to run; the ephemeral port numbers and time fields also differ on every run.
The client made a request to a single address, 8429. The x-served-by header shows that the
two requests came from two separate programs. From the client’s perspective, this distinction
is invisible: one origin, one address, one configuration. The routing rule here is a single
line — if the path starts with /api/, it goes to the application; if not, to the static
server.
What the Proxy Adds and What It Takes Away
The proxy is not free. The two times in the measurement compare, for the same response, a direct connection against a connection that goes through the proxy: the proxied path is longer, because a second TCP connection is established, the request is rewritten, and the response is relayed again. Over the loopback interface this difference is small; in a real deployment, the network between the proxy and the backend also counts.
In exchange, several jobs are gathered into a single point. Encrypted connections can be terminated at the front; the backends speak plain HTTP, and certificate management comes down to a single component. The same path rule can distribute across multiple backend instances: when target selection is made over a list instead of a single port, the proxy turns into a load balancer. Static assets never touch the application process, so the application’s concurrency budget is spent only on computed requests.
Part of the cost, however, is information loss, and it shows up in the output. The application
reads who opened the request through req.socket; once the proxy steps in, the party that
opened this connection is the proxy, not the client. Because the measurement was taken on
the same machine, the address on both lines is 127.0.0.1; what distinguishes them is that
the connection’s owner has changed. Information about who the client is can only be carried by
the Forwarded header the proxy adds, and in the output it is populated only on the proxied
line.
This header is a claim, not a measurement. The client can send the same header too. That
is why the application only takes the Forwarded header into account on connections it has
verified as coming from a known proxy; on an application open to direct access, the header is
disregarded. The same reasoning was established in the second lesson: no value coming from the
client counts as a decision unless it is under the server’s own control.
Roles or Programs?
Three files started three separate processes, but this is a presentation choice, not a
requirement. The same three roles can also live inside a single program: the application could
route paths that do not start with /api/ to its own file-reading branch. What is durable
about the distinction is not the number of programs but the content of the responsibilities.
- The web server role: accepts the connection, parses the HTTP message, serves static content directly, maps paths to targets. What this role does not know is business rules.
- The application server role: carries the request to a handler, runs the business rule, produces the response body. What this role does not know is the layout of the file system.
- The proxying role: connects the two sides, carries source information, distributes
across targets, produces
502when a backend is unreachable.
The practical consequence of separating the roles is that each can scale independently: a system whose static traffic grows scales out the static side, one whose computed requests grow scales out the application side. In a setup where the roles are gathered into a single program, these two needs are tied together, and both are scaled out at once.
Summary
- Static serving carries four responsibilities: mapping the path to a file, the correct
content type, meeting a missing file with
404, and checking the root directory boundary through the resolved absolute path. - Because address resolution simplifies
..components, path checking is not done on the text but by comparing the joined and normalized path against the root directory. - A reverse proxy distributes requests arriving at a single address to backend servers
according to a rule; in the measurement, the
x-served-byheader shows that two requests made to the same address came from two separate programs. - The proxy adds a hop and changes the connection’s owner; the client’s identity can only be
carried by the
Forwardedheader, and this header is taken into account only when it comes from a known proxy. - The web server, the application server, and proxying are each a role; splitting them into separate processes is not required, but this split is what enables them to scale independently.
Next Step
The application server behind the proxy ran as a single process in this lesson and answered requests one after another. What happens to the others when one request takes a long time? How do two copies of the same application share the same port, and how are requests distributed between them? Is the unit of a scaling decision the process, the thread, or something else? The next lesson descends into the runtime beneath the application: it measures how the event loop makes a single request wait, then starts worker processes that share the same port and counts how requests are distributed among them.
To keep your progress and take notes, Log in
My notes
Log in to take notes.