Skip to content
academia.sh

Lesson 05 / 16

The Request Lifecycle

The path a request takes from connection accept to the last byte written is split into seven stages; timestamping each stage measures the order and durations, and shows that a connection and a request are separate things, and when the status line becomes irreversible.

Contents

The previous lesson measured the outside of the process: how many copies run, which copy a request lands on, what the copies do not share. Inside the process, however, there is a path a single request takes, and up to now that path has been treated as a single box. This lesson opens the box.

From the outside, a request handler looks like a single step: “take the request, give the response.” In reality, there are stages in between, each occurring in its own time and each constraining the next. The most direct way to see the stages is to put a timestamp on each one and print the order. Two endpoints of the library loan service — lending a book and listing the catalog — will be used for this measurement.

Seven Stages

A request’s path on the server side goes through these steps:

  1. Connection accept. The operating system places a TCP connection on the listening socket, and the application accepts it. At this point, no HTTP data has been read yet.
  2. Parsing the header lines. The request line and headers are read and parsed. The method, path, and headers are now known; the body is not.
  3. Reading the body. The body arrives in pieces; the request is complete once the last piece arrives.
  4. Routing. The method and path are mapped to a handler. If there is no match, the request’s response is 404.
  5. The handler running. The business rule is enforced, data is read or written, the response body is produced.
  6. Writing the response header. The status code and headers are written to the socket.
  7. Completing the response. The body’s last byte is written, and the connection either closes or stays open for the next request.

This order is not a rule but an observation; the server below records each stage the moment it occurs.

Measuring the Stages

// lifecycle.mjs — records the stages a request passes through with timestamps and prints them
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";

const CATALOG = new Map([["978-0262033848", { title: "Introduction to Algorithms", copies: 2 }]]);
const t0 = () => process.hrtime.bigint();
const ms = (a, b) => Number(b - a) / 1e6;                    // nanosecond difference -> millisecond

let connectionId = 0;

const server = createServer();

server.on("connection", (socket) => {                        // 1. TCP connection accepted
  socket.acceptedAt = t0();
  socket.id = ++connectionId;
  socket.requestCount = 0;
});

server.on("request", (req, res) => {
  const socket = req.socket;
  socket.requestCount++;
  const stages = [["connection accept", socket.acceptedAt]];
  const record = (name) => stages.push([name, t0()]);

  record("header lines parsed");                             // 2. request event = headers ready

  const chunks = [];
  req.on("data", (chunk) => chunks.push(chunk));
  req.on("end", async () => {
    record("body read");                                      // 3. got the body's last byte

    const path = new URL(req.url, "http://local").pathname;
    const handler = path === "/loan" && req.method === "POST" ? "lendBook"
      : path === "/books" ? "listCatalog" : null;
    record(`routing -> ${handler ?? "none"}`);                 // 4. path mapped to a handler

    let status = 404, body = { error: "route_not_found" };
    if (handler === "lendBook") {
      const payload = JSON.parse(Buffer.concat(chunks).toString() || "{}");
      const book = CATALOG.get(payload.isbn);
      await readFile(new URL(import.meta.url));                // work: a real input/output operation
      status = book && book.copies > 0 ? 201 : 409;
      body = status === 201 ? { isbn: payload.isbn, member: payload.member, remaining: --book.copies }
        : { error: "shelf_empty" };
    } else if (handler === "listCatalog") {
      status = 200;
      body = { books: [...CATALOG].map(([isbn, k]) => ({ isbn, ...k })) };
    }
    record("handler finished");                                // 5. response body ready

    const text = JSON.stringify(body);
    res.writeHead(status, { "content-type": "application/json; charset=utf-8",
      "content-length": Buffer.byteLength(text) });
    record("header written");                                  // 6. status line now final

    res.end(text);
    res.on("finish", () => {                                   // 7. last byte handed to the kernel
      record("response completed");
      const start = stages[0][1];
      console.log(`\n#${socket.id}.${socket.requestCount}  ${req.method} ${req.url} -> ${status}`);
      for (const [name, at] of stages) {
        console.log(`  ${String(ms(start, at).toFixed(3)).padStart(9)} ms  ${name}`);
      }
    });
  });
});

server.listen(8432, "127.0.0.1", () => console.log("lifecycle 127.0.0.1:8432"));

The measurement makes three requests: one loan and one catalog listing on the same connection, then one unknown path on a separate connection. The --next option defines a second request inside the same curl invocation, and the connection is reused.

#!/usr/bin/env bash
# Starts lifecycle.mjs; makes two requests on one connection and one request on a separate connection.
node lifecycle.mjs & server=$!
sleep 0.6

echo "### two requests on one connection (curl reuses the connection)"
curl -sS -o /dev/null \
  -X POST -H 'content-type: application/json' \
  -d '{"isbn":"978-0262033848","member":"U-4711"}' http://127.0.0.1:8432/loan \
  --next -sS -o /dev/null http://127.0.0.1:8432/books
sleep 0.3

echo "### unknown path on a separate connection"
curl -sS -o /dev/null http://127.0.0.1:8432/shelves
sleep 0.3

kill "$server"
lifecycle 127.0.0.1:8432
### two requests on one connection (curl reuses the connection)

#1.1  POST /loan -> 201
      0.000 ms  connection accept
      1.378 ms  header lines parsed
      2.192 ms  body read
      2.240 ms  routing -> lendBook
      3.534 ms  handler finished
      5.434 ms  header written
      6.882 ms  response completed

#1.2  GET /books -> 200
      0.000 ms  connection accept
      7.580 ms  header lines parsed
      7.702 ms  body read
      7.728 ms  routing -> listCatalog
      7.779 ms  handler finished
      7.823 ms  header written
      8.088 ms  response completed
### unknown path on a separate connection

#2.1  GET /shelves -> 404
      0.000 ms  connection accept
      0.180 ms  header lines parsed
      0.243 ms  body read
      0.278 ms  routing -> none
      0.280 ms  handler finished
      0.334 ms  header written
      0.541 ms  response completed

Durations change on every run; the first request’s measurements also carry the warm-up cost of module loading and the first file read. What stays fixed is the order of the stages and the order-of-magnitude relationship between them.

The third measurement gives a baseline: for an unmatched path, the total is half a millisecond, and the whole of that duration is protocol work — connection accept, parsing, response writing. The application’s own work is zero. In the first measurement, the 1.3 milliseconds between handler finished and routing is the real work: the file read and the business rule. When a request is said to be slow, the first question to ask is which of these two parts the time was spent in.

The Connection and the Request Are Separate Things

The second measurement’s first line reads 0.000 ms connection accept, but its second line shows 7.580 ms. There is no new connection accept in between; the second request came in on the connection the first request finished on. The #1.1 and #1.2 labels say this directly: same connection, second request. The third request got a new connection number, with the label #2.1.

This distinction carries practical consequences. Work done per connection happens once: the TCP handshake, secure connection setup, and the delay that comes with them are amortized over the following requests for as long as the connection stays open. By contrast, state attached to the connection is dangerous: more than one member’s request can pass over a single socket, because a proxy in between can reuse the connection. A credential belongs to the request, not to the connection.

The distinction also determines resource management. The number of open connections is independent of the number of requests processed; a client that sends no requests at all still holds a connection. This is why the server places separate limits on the number of connections and on idle wait time.

The Body Is a Stream, Not a Value

In the code, the body is collected piece by piece through data events and completed by the end event. This shows up in the measurement output as the interval between header lines parsed and body read. For a small JSON body, this interval is under a millisecond; for a large upload, it can take seconds.

Two decisions follow from this. First, routing can be done before the body. In this example, the routing decision was recorded after the body was read, because the handler logic was gathered in one place for readability. Since the method and path are already known at the second stage, routing can be moved there; once it is, an unmatched request is rejected without its body ever being read.

Second, the body must be bounded. For as long as the server collects the body into memory, the number of bytes sent determines the server’s memory consumption. Without a limit, a single client can leave the process out of memory. How the limit is set, and which status code is returned when it is exceeded, is covered in the File Upload and Storage lesson of this course.

The Moment the Decision Becomes Irreversible

The sixth stage is a threshold. Once the status line and headers are written to the socket, the client has received them; there is no taking them back. Confirming this only takes a server that tries to add a header and change the status code after it has already started writing the response.

// after-header.mjs — shows that the decision cannot be taken back once the status line is written
import { createServer } from "node:http";

createServer((req, res) => {
  res.sendDate = false;
  res.writeHead(201, { "content-type": "application/json; charset=utf-8" });
  res.write('{"status":"loan given"');            // body started streaming

  for (const [name, attempt] of [
    ["setHeader", () => res.setHeader("x-warning", "too late")],
    ["writeHead", () => res.writeHead(500)],
  ]) {
    try {
      attempt();
      console.log(`${name}: passed`);
    } catch (err) {
      console.log(`${name}: ${err.code}`);
    }
  }

  res.end(',"note":"status code stayed 201"}');
}).listen(8433, "127.0.0.1", () => console.log("after header 127.0.0.1:8433"));
#!/usr/bin/env bash
# Starts after-header.mjs; fetches the response's headers and body, shows the server log.
node after-header.mjs & server=$!
sleep 0.6
curl -sS -D - http://127.0.0.1:8433/loan
echo
kill "$server"
after header 127.0.0.1:8433
setHeader: ERR_HTTP_HEADERS_SENT
writeHead: ERR_HTTP_HEADERS_SENT
HTTP/1.1 201 Created
content-type: application/json; charset=utf-8
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

{"status":"loan given","note":"status code stayed 201"}

Because the server’s log lines and curl’s output are written to the same terminal, the first two lines come from the server, and the rest from the client.

Both attempts were rejected with the same error code, and the response stayed at 201. The output also shows Transfer-Encoding: chunked instead of content-length: because the body was written piece by piece, the server could not announce the length in advance.

The rule this result has become is: every decision about the response must be made before the first byte is written. Code that starts writing partway through the handler and then runs into an error cannot report that error to the client — what remains is a half-finished body and the wrong status code. This is why the response body is produced in memory first and written in a single pass, or, in a streamed send, the possibility of an error is exhausted before the stream begins.

Summary

  • A request’s path passes through seven stages: connection accept, parsing the headers, reading the body, routing, the handler running, writing the header, and completing the response.
  • In the measurement, an unmatched path completed in half a millisecond; that duration is protocol work. The application’s own work shows up in the interval between routing and the handler finishing.
  • The connection and the request have separate lifetimes: the second request on the same connection does not include a new accept stage, which is why identity and state attach to the request, not the connection.
  • The body arrives in pieces; routing can be done before the body, and when the body’s size is not bounded, the client determines the server’s memory consumption.
  • Adding a header or changing the status code after the headers have been written is rejected with ERR_HTTP_HEADERS_SENT; decisions about the response are made before the first byte.

Next Step

All the measured stages were inside a single handler, and each stage was written into the code by hand. In a real application, the same jobs — resolving identity, parsing the body, writing the request to the log, catching the error — repeat in every handler. The structure that gathers these repetitions in one place is a chain that passes the request through in sequence. The next lesson builds that chain: it measures how the result changes when the order of the links changes, in which order identity checking and logging need to be arranged, and where in the chain the error catcher stands.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close