---
title: 'The Web Server Concept'
source: 'https://academia.sh/en/courses/server-fundamentals/web-server-concept'
course: 'Server-Side Fundamentals'
language: en
updated: '2026-08-19T05:19:37+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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:

```bash
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
```

```bash
#!/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.

```js
// 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"));
```

```js
// 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"));
```

```bash
#!/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 `502` when 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-by` header 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 `Forwarded` header, 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.
