0 / 91
Week 6 · Day 38 of 91

Build a server with node:http

HTTP, Node.js, and APIs

Objective

See the raw request lifecycle before using Express.

HTTP is a request/response protocol like a defined communication bus: method, address, headers, body, and status all have roles.

  • createServer
  • request and response objects
  • listen and close

Why this matters

Yesterday your program read a file and stopped. Today it starts and keeps running, waiting for HTTP requests and answering them — a server, the thing you have been sending requests to since Day 1. You will write it with nothing but Node's built-in node:http module, no framework.

That is deliberate. Tomorrow you switch to Express, and every convenience Express offers will make sense only if you have first done the work yourself. Today you see the raw request lifecycle: a request object arrives, you inspect it, you write a response, and you end it.

createServer: one function, called on every request

node:http gives you createServer. You hand it a function; Node calls that function once for every request that arrives, passing two objects: the request and the response.

import { createServer } from "node:http";

const server = createServer((req, res) => {
  // this runs once per incoming request
});

That callback is the whole server. Everything else this week — routing, validation, JSON — is logic you put inside it or, from tomorrow, logic a framework arranges around it.

A server is an interrupt handler on the network bus

createServer registers a handler; listen enables the interrupt. From then on the process sits idle until a request arrives on the port, at which point your handler runs with the incoming frame in req and an outgoing frame buffer in res. Two consequences follow directly. Your handler must finish quickly, because further requests queue behind slow work. And it must always write and terminate a response — an interrupt handler that never clears the flag leaves the caller waiting forever.

The request object

req is the incoming message, parsed into properties. The three you need today map exactly onto what you read yesterday:

  • req.method — the string "GET", "POST", and so on.
  • req.url — the path and query string only, for example /equipment?status=down. Not the scheme or host; those went into req.headers.host.
  • req.headers — an object of headers, with lowercased names: req.headers["content-type"].

Reading a request body is more work: it arrives as a stream of chunks that you must collect and join. You do not need it today, because GET requests have no body. Tomorrow Express does the collecting for you, and that is one of the main reasons to use it.

The response object

res is how you build the reply, and it must be built in protocol order — status line, then headers, then body — because that is the order the bytes go out.

  • res.writeHead(statusCode, headersObject) writes the status line and headers in one call.
  • res.setHeader(name, value) sets one header; only works before the head is written.
  • res.write(chunk) appends to the body.
  • res.end() finishes the response and sends it. res.end(chunk) writes a last chunk and finishes.

res.end() is not optional. Until you call it, the client is still waiting: the browser tab spins, curl hangs. Forgetting it is the single most common mistake in raw Node servers, and nothing in your terminal tells you it happened.

For JSON you owe the client two things: a body produced by JSON.stringify, and a Content-Type: application/json header saying what it is. It is worth adding Content-Length too — the byte count of the body — so the client knows exactly when the message is complete.

function sendJson(res, statusCode, payload) {
  const body = JSON.stringify(payload);
  res.writeHead(statusCode, {
    "Content-Type": "application/json",
    "Content-Length": Buffer.byteLength(body)
  });
  res.end(body);
}

Buffer.byteLength counts bytes, not characters — an accented letter is one character but two bytes, and HTTP counts bytes.

listen and close

A handler that is never attached to a port does nothing. server.listen(port, callback) starts accepting connections on a host and port and runs the callback once it is ready.

On Day 5 you learned that a port is a numbered door on a machine and that only one program can hold a given port at a time. That rule is about to bite you: start a second copy of your server on port 3000 and Node stops with

Error: listen EADDRINUSE: address already in use :::3000

EADDRINUSE means a previous copy is still running. Stop it — Ctrl+C in the terminal where it runs — or use a different port. (server.close() stops accepting new connections from inside the program; you rarely call it by hand outside of tests.)

Because listen keeps the process alive, the terminal running your server is now busy. Open a second terminal tab to run curl.

Your data lives in a variable, and that is on purpose

This week the equipment records sit in a plain array in memory. When the process stops, every change is gone. That is not a shortcut you will forget to fix — it is the point of doing servers before databases. Storing data durably needs a database, and that is Week 7. Until then, restart the server and you are back to the starting records.

Walkthrough: a server with two routes

In ~/fullstack-journey/, create raw-server/ with a package.json containing { "type": "module" }, then server.mjs:

import { createServer } from "node:http";

const equipment = [
  { id: "eq-001", name: "Feed Pump 3", status: "operational" },
  { id: "eq-002", name: "Cooling Fan A", status: "down" }
];

function sendJson(res, statusCode, payload) {
  const body = JSON.stringify(payload);
  res.writeHead(statusCode, {
    "Content-Type": "application/json",
    "Content-Length": Buffer.byteLength(body)
  });
  res.end(body);
}

const server = createServer((req, res) => {
  console.log(`${req.method} ${req.url}`);

  if (req.method === "GET" && req.url === "/health") {
    sendJson(res, 200, { status: "ok" });
    return;
  }

  if (req.method === "GET" && req.url === "/equipment") {
    sendJson(res, 200, equipment);
    return;
  }

  sendJson(res, 404, { error: "Not found" });
});

server.listen(3000, () => {
  console.log("Listening on http://localhost:3000");
});

Every branch checks both the method and the path, because GET /equipment and POST /equipment are different requests. The last sendJson is the fallback: anything unmatched is a 404, a client error, because the caller asked for something that is not there.

node server.mjs
Listening on http://localhost:3000

The prompt does not come back — the process is alive and waiting. In a second terminal:

curl -i http://localhost:3000/health

-i includes the response headers. You get:

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 15
Date: Mon, 03 Aug 2026 13:47:21 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"status":"ok"}

Everything from Day 36, now produced by code you wrote. Meanwhile the server terminal prints GET /health — your console.log, the server-side view of the same exchange.

curl -s http://localhost:3000/equipment
curl -i http://localhost:3000/nope
[{"id":"eq-001","name":"Feed Pump 3","status":"operational"},{"id":"eq-002","name":"Cooling Fan A","status":"down"}]
HTTP/1.1 404 Not Found
Content-Type: application/json
Content-Length: 21
...

{"error":"Not found"}

The browser is an HTTP client too

Open http://localhost:3000/equipment in your browser. You see the same JSON — the browser sent exactly the request curl did. Now open DevTools → Network and reload: the entry shows method GET, status 200, and Content-Type: application/json, matching curl line for line.

Checkpoint

Say what each of createServer, listen, req.url, res.writeHead, and res.end does, and predict what happens to curl if you delete the res.end(body) line. (It hangs.)

Your turn

Build the deliverable: a raw Node server with two routes and JSON responses.

  1. Create raw-server/ with package.json ({ "type": "module" }) and server.mjs as above.
  2. Start it with node server.mjs and confirm the "Listening" line.
  3. In a second terminal, curl -i http://localhost:3000/health. Confirm status 200 and Content-Type: application/json.
  4. curl -s http://localhost:3000/equipment. Confirm both records come back.
  5. Open both URLs in the browser and check the Network tab entries against the curl output.
  6. Request an unknown path and confirm 404.
  7. curl -i -X POST http://localhost:3000/equipment. You get 404, because your condition requires GET. Write down in one line why 405 Method Not Allowed would be the more honest answer here.
  8. Add a third route, GET /equipment/count, returning { "count": 2 } derived from the array length. Restart the server — Node does not reload on save — and verify with curl.
  9. Deliberately comment out res.end(body) inside sendJson, restart, and run curl. Watch it hang, press Ctrl+C, then restore the line. Feeling that hang once is what makes you remember it.

Pair mode — while you build step 8

"Guide me through a raw Node HTTP server. Explain each response header and why the response must end." Pair mode means the AI proposes and you decide. Before accepting anything it produces, inspect the change, run curl against it, and be able to explain the behaviour line by line. Commit nothing you cannot narrate.

Common pitfalls

  • Forgetting res.end(). The request hangs with no error anywhere. If curl sits there doing nothing, this is almost always why.
  • EADDRINUSE. An older copy of the server still owns port 3000. Stop it with Ctrl+C in its terminal, or pick another port.
  • Editing and not restarting. Node runs the file it loaded at startup. Every change needs a stop and start until you use a watcher (tomorrow).
  • Matching req.url with === when there is a query string. /equipment?status=down is not equal to /equipment, so the branch silently misses and you get a 404. Express solves this tomorrow by separating path from query.

Verify it yourself

Open today's reference, the Node.js Introduction, and follow it to the HTTP server example.

  1. Does Node's own example set Content-Type the same way this lesson did? Note any difference.
  2. Node's docs show res.statusCode = 200 and res.setHeader(...) as an alternative to writeHead. Try that form in your server and confirm the curl output is identical.
  3. Find what the docs say listen does, and check it matches the claim above: that it starts accepting connections on a host and port.

Record the answers in the server file as comments. Tomorrow Express will hide most of this — you should be able to say exactly what it is hiding.

The hour

  1. 0–5 min Recall

    Without notes, state yesterday’s main idea and one unresolved question.

  2. 5–20 min Learn

    Read only the listed concept notes and official reference sections needed today.

  3. 20–48 min Build

    Create a server with GET /health and GET /equipment. Test with browser and curl.

  4. 48–55 min Explain and verify

    Run the result, inspect evidence, and explain the data/control flow in your own words.

  5. 55–60 min Quiz and commit

    Complete the quiz, record one lesson, and commit the verified change when applicable.

What to hand in

Deliverable

A raw Node server with two routes and JSON responses.

Working with AI today

AI as pair programmer

Define one small task, review the plan, inspect the diff, run checks, and explain every changed section.

Guide me through a raw Node HTTP server. Explain each response header and why the response must end.

References

End-of-day quiz

Q1 What does server.listen do?
Q2 Which result best proves today’s work is complete?
Q3 Before accepting an AI-generated code change, what should you do?

Explain-back gate

Pass the quiz above to unlock completion.

Quiz + explain-back checks required.