Lesson 10 / 20
HTTP Server
The stream counterpart of request and response objects, dispatch by path and method, a size limit on reading the body, and the choice between declaring a length and chunked transfer.
Contents
The How the Internet Works course parsed an HTTP request line by line: the request line, headers, an empty line, the body. There, the party reading that text was an example program, and the protocol’s parsing was never shown.
This lesson hands that job to the built-in module and turns the measurement collector into a service. The key observation is this: the request object is a readable stream, the response object a writable one. Everything established in the streams lesson applies directly here.
Moving Summary Accumulation Into a Separate Module
Before writing the service code, record validation and summary accumulation have to move into a separate module. Data coming from the network and data coming from a file have to pass through the same path, and that path has to be testable independently of the server.
// summarizer.mjs export function recordFromLine(line) { const record = JSON.parse(line); if (typeof record.node !== 'string' || typeof record.metric !== 'string') { throw new TypeError('node and metric must be strings'); } if (!Number.isFinite(record.value)) { throw new TypeError('value must be a finite number'); } return record; } export class Summarizer { #buckets = new Map(); add({ node, metric, value }) { const key = `${node}/${metric}`; const bucket = this.#buckets.get(key) ?? { count: 0, total: 0, max: -Infinity }; bucket.count += 1; bucket.total += value; bucket.max = Math.max(bucket.max, value); this.#buckets.set(key, bucket); } summary() { return [...this.#buckets].map(([key, { count, total, max }]) => ({ key, count, average: Number((total / count).toFixed(2)), max, })); } }
The validation function does not just parse, it also checks field types. Data coming
from the network is not trusted: when the value field arrives as a string, the
addition operation silently turns into concatenation and the summary becomes
meaningless.
The Server and the Handler
createServer in the node:http module takes a handler called for every request.
The handler has two arguments, and both are streams.
// service.mjs import { createServer } from 'node:http'; import { createReadStream } from 'node:fs'; import { createInterface } from 'node:readline'; import { pipeline } from 'node:stream/promises'; import { Summarizer, recordFromLine } from './summarizer.mjs'; const summarizer = new Summarizer(); // On startup, stream-read the file and fill the buckets async function loadStartupData(file) { const lines = createInterface({ input: createReadStream(file), crlfDelay: Infinity, }); let counter = 0; for await (const line of lines) { if (line.trim() === '') continue; summarizer.add(recordFromLine(line)); counter += 1; } return counter; } async function readBody(request, maxBytes = 64 * 1024) { const chunks = []; let size = 0; for await (const chunk of request) { // request is a readable stream size += chunk.length; if (size > maxBytes) { request.destroy(); throw Object.assign(new Error('body too large'), { status: 413 }); } chunks.push(chunk); } return Buffer.concat(chunks).toString('utf8'); } function writeJson(response, status, value) { const body = JSON.stringify(value) + '\n'; response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(body), }); response.end(body); } const server = createServer(async (request, response) => { const address = new URL(request.url, `http://${request.headers.host}`); try { if (request.method === 'GET' && address.pathname === '/summary') { writeJson(response, 200, summarizer.summary()); return; } if (request.method === 'GET' && address.pathname === '/raw') { response.writeHead(200, { 'content-type': 'application/x-ndjson; charset=utf-8' }); await pipeline(createReadStream('measurements.ndjson'), response); // body written as a stream return; } if (request.method === 'POST' && address.pathname === '/measurement') { const body = await readBody(request); let counter = 0; for (const line of body.split('\n')) { if (line.trim() === '') continue; summarizer.add(recordFromLine(line)); counter += 1; } writeJson(response, 201, { received: counter }); return; } writeJson(response, 404, { error: 'not found' }); } catch (error) { writeJson(response, error.status ?? 400, { error: error.message }); } }); const loaded = await loadStartupData('measurements.ndjson'); server.listen(8791, '127.0.0.1', () => { const { address, port } = server.address(); console.log(`${loaded} records loaded; listening: http://${address}:${port}`); });
node service.mjs
12 records loaded; listening: http://127.0.0.1:8791
The number 8791 in the example is a choice; if there is a port already in use on your machine, write a different number. The next lesson will read this value from configuration.
The 127.0.0.1 given as the second argument to listen makes the server listen
only on the loopback interface; this address was introduced in the How the Internet
Works course. When no argument is given, the server becomes reachable from every
interface — an unwanted openness during development.
Parsing the Request
The request object offers the protocol’s parsed form as fields. request.method is
the request line’s first field, request.url the second, and request.headers is
an object made of lowercased header names.
request.url carries only the path and query part; the hostname is in the host
header. This split was explained in the How the Internet Works course as the basis
of name-based virtual hosting. Combining the two parts to build a URL object is
safer than splitting the path and query by hand: escape decoding and normalization
rules live inside the object.
The body is not found among the fields — it is a stream. The reason is that the body
can be of arbitrary size. The readBody function above collects chunks with
for await while also applying a limit. The limit is mandatory: an endpoint that
reads an unbounded body can exhaust the process’s memory with a single request.
Writing the Response
The response object is a writable stream. writeHead sets the status code and
headers; end writes the body and closes the response.
curl -s -i http://127.0.0.1:8791/summary
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
content-length: 390
Date: Fri, 14 Aug 2026 23:21:55 GMT
Connection: keep-alive
Keep-Alive: timeout=5
[{"key":"edge-01/temperature","count":3,"average":21.87,"max":22.3},{"key":"edge-01/humidity","count":2,"average":47.6,"max":48},{"key":"edge-02/temperature","count":3,"average":20.23,"max":20.7},{"key":"edge-02/humidity","count":1,"average":52.5,"max":52.5},{"key":"edge-03/temperature","count":2,"average":24.35,"max":24.6},{"key":"edge-03/humidity","count":1,"average":41.3,"max":41.3}]
The value of the Date header changes with the moment of the request; the
Connection and Keep-Alive headers also depend on client negotiation. What is
fixed is the two headers the handler writes, and the body.
The content-length header was computed by hand here. It has to be the byte count,
not the character count; this is exactly why the distinction in the buffers lesson
mattered.
For responses whose length you do not know in advance, this header is not written. When the body is written through a stream, the runtime switches to chunked transfer:
curl -s -i http://127.0.0.1:8791/raw | head -8
HTTP/1.1 200 OK
content-type: application/x-ndjson; charset=utf-8
Date: Fri, 14 Aug 2026 23:22:02 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked
{"time":"2024-02-07T09:12:44Z","node":"edge-01","metric":"temperature","value":21.4}
The Transfer-Encoding: chunked header announces that each piece of the body is
preceded by its own length; the receiver knows the end this way. This is the
mechanism described in the How the Internet Works course as “if the length is not
known in advance, the body is sent in pieces.”
The choice is made this way: if the response is small and ready in memory,
declaring a length gives the client progress information; if the response is large
or flows while being produced, chunked transfer keeps memory from being occupied for
the whole response. Using pipeline also guarantees that backpressure is applied: if
the client is slow, the file read slows down too.
Sending Data and Errors
Sending a measurement is done with a request carrying a body:
curl -s -X POST --data-binary '{"node":"edge-01","metric":"temperature","value":25.9}' -w 'status: %{http_code}\n' http://127.0.0.1:8791/measurement
{"received":1}
status: 201
The 201 code means “a new resource was created” per the classification from the How the Internet Works course; choosing it separately from 200 announces the effect of the request.
A broken body falls into a different class:
curl -s -X POST --data-binary 'broken' -w 'status: %{http_code}\n' http://127.0.0.1:8791/measurement
{"error":"Unexpected token 'b', \"broken\" is not valid JSON"}
status: 400
curl -s -w 'status: %{http_code}\n' http://127.0.0.1:8791/missing
{"error":"not found"}
status: 404
The 400 class reports the sender’s error, the 500 class the server’s error. Getting this distinction right is not just courtesy, it is an operational necessity: the 500 rate is a signal of failure, the 400 rate a signal of client behavior. If the two get mixed, the signal loses its meaning.
The error catcher in the handler above has a shortcoming: it reports every caught error as 400, but a programmer error occurring during summarization should be 500. This distinction will be fixed in the Error Handling Strategy lesson.
One warning: because the handler is asynchronous, an error occurring inside it that
is not caught is not seen by createServer. This is why the try/catch block has
to wrap the entire body — without it, no response is ever written and the client
times out.
Summary
- The request object is a readable stream, the response object a writable one; every rule from the streams lesson applies here directly.
- The request line and headers are offered as parsed fields;
request.urlcarries only the path and query, the hostname is in thehostheader. - Because the body is a stream, its size has to be limited; unbounded reading can exhaust memory with a single request.
- A length-declaring response writes the byte count; when the length is unknown, the runtime switches to chunked transfer, and writing through a stream preserves backpressure.
- The status code class reports the source of the error; mixing client and server errors makes operational signals meaningless.
Next Step
The service runs in a single process and does all its work on a single thread. When an expensive computation is needed on the measurement data — resummarizing a long period, say — this single thread makes every request wait. The next lesson covers the first way to move work outside the process: starting a child process, talking to it through streams, and safely calling external commands.
To keep your progress and take notes, Log in
My notes
Log in to take notes.