---
title: 'HTTP Server'
source: 'https://academia.sh/en/courses/nodejs/http-server'
course: 'The Node.js Runtime'
language: en
updated: '2026-08-17T18:09:51+00:00'
license: 'CC BY-SA 4.0'
---

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

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.

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

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

```sh
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.

```sh
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:

```sh
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:

```sh
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:

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

```sh
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.url` carries
  only the path and query, the hostname is in the `host` header.
- 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.
