Learn / Backend fundamentals
LESSON 25 OF 84

Backend fundamentals Foundation

HTTP in depth

See the exact bytes of an HTTP request and response, then learn the parts every backend developer uses daily: headers, cookies, content types, methods and status codes.

  • 40 min to read and try
  • You need: The Task API from the node-task-api lesson
  • You build: A small server that checks content types, reads JSON safely and sets a secure session cookie
Test yourself

What a request really looks like

In the node:http lesson you wrote a server, and node:http handed you a neat req object. Under that object there is only text, sent over a network connection. That text follows a set of rules called HTTP (HyperText Transfer Protocol). A protocol is an agreement about who says what, and in which format.

To see the text itself, skip node:http and use node:net, which gives you the raw connection. This server prints every byte it receives, then answers by writing an HTTP response by hand. fetch plays the client:

raw-request.jsNode.js only
import net from "node:net";

const server = net.createServer((socket) => {
  socket.once("data", (bytes) => {
    console.log("--- the exact bytes fetch sent ---");
    console.log(bytes.toString().replaceAll("\r\n", "\\r\\n\n"));
    socket.end("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 5\r\n\r\nhello");
  });
});

server.listen(0, async () => {
  const { port } = server.address();
  const res = await fetch(`http://localhost:${port}/tasks?done=false`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ title: "Buy milk" }),
  });
  console.log("--- what fetch understood ---");
  console.log(res.status, res.statusText, res.headers.get("content-type"));
  console.log(await res.text());
  server.close();
});
Output of node raw-request.js
--- the exact bytes fetch sent ---
POST /tasks?done=false HTTP/1.1\r\n
host: localhost:41855\r\n
connection: keep-alive\r\n
Content-Type: application/json\r\n
accept: */*\r\n
accept-language: *\r\n
sec-fetch-mode: cors\r\n
user-agent: node\r\n
accept-encoding: gzip, deflate\r\n
content-length: 20\r\n
\r\n
{"title":"Buy milk"}
--- what fetch understood ---
200 OK text/plain
hello

listen(0) asks the operating system for any free port, so the example never clashes with another program. We print \r\n where the real text has a line break, because HTTP ends every line with two invisible characters: carriage return (\r) and line feed (\n).

A request has four parts:

  1. The request line: the method (POST), the path with its query string (/tasks?done=false) and the version (HTTP/1.1).
  2. Headers: one name: value pair per line. They describe the request. host says which site you want, content-type says what the body is, content-length says how many bytes it has.
  3. An empty line. It means "the headers are finished".
  4. The body: exactly content-length bytes. Here, 20 bytes of JSON.

fetch added several headers you did not ask for, like user-agent: node (which program is asking) and accept-encoding (which compressions it can undo).

What a response really looks like

Now the other direction. This time node:http is the server, and a raw node:net connection sends a request typed by hand and prints the answer byte for byte:

raw-response.jsNode.js only
import http from "node:http";
import net from "node:net";

const server = http.createServer((req, res) => {
  res.sendDate = false;
  const body = JSON.stringify([{ id: 1, title: "Buy milk", done: false }]);
  res.writeHead(200, {
    "Content-Type": "application/json",
    "Content-Length": Buffer.byteLength(body),
  });
  res.end(body);
});

server.listen(0, () => {
  const socket = net.connect(server.address().port, "localhost");
  socket.write("GET /tasks HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n");
  let raw = "";
  socket.on("data", (bytes) => (raw += bytes));
  socket.on("end", () => {
    console.log(raw.replaceAll("\r\n", "\\r\\n\n"));
    server.close();
  });
});
Output of node raw-response.js
HTTP/1.1 200 OK\r\n
Content-Type: application/json\r\n
Content-Length: 42\r\n
Connection: close\r\n
\r\n
[{"id":1,"title":"Buy milk","done":false}]

res.sendDate = false only stops Node from adding a Date header, so the output is the same every time you run it. A response has the same shape as a request:

  1. The status line: the version, a status code (200) and a short reason phrase (OK) for humans.
  2. Headers.
  3. An empty line.
  4. The body.

Content-Length counts bytes, not characters. Buffer.byteLength gets it right even when the text has letters like é that take two bytes. If you leave the header out, Node sends the body in pieces instead (Transfer-Encoding: chunked), which is also valid.

Watching HTTP with curl

curl is a command-line HTTP client. It comes with macOS, Windows 10 and later, and most Linux systems. Its -v (verbose) flag prints the whole conversation, which makes it the first tool to reach for when an API does something strange.

Save this small server, a trimmed-down version of your Task API, and start it with node server.js. It keeps running until you press Ctrl+C:

server.jsNode.js only
import http from "node:http";

const tasks = [{ id: 1, title: "Buy milk", done: false }];

const server = http.createServer(async (req, res) => {
  if (req.method === "GET" && req.url === "/tasks") {
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify(tasks));
    return;
  }
  if (req.method === "POST" && req.url === "/tasks") {
    let body = "";
    for await (const chunk of req) body += chunk;
    let title;
    try {
      title = JSON.parse(body).title;
    } catch {
      title = undefined;
    }
    if (typeof title !== "string" || title.trim() === "") {
      res.writeHead(400, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ error: "Send JSON with a non-empty title" }));
      return;
    }
    const task = { id: tasks.length + 1, title: title.trim(), done: false };
    tasks.push(task);
    res.writeHead(201, { "Content-Type": "application/json", Location: `/tasks/${task.id}` });
    res.end(JSON.stringify(task));
    return;
  }
  res.writeHead(404, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ error: "Not found" }));
});

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

In a second terminal:

Terminal on your computer
curl -v http://localhost:3000/tasks
* Host localhost:3000 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:3000...
* Established connection to localhost (::1 port 3000) from ::1 port 36778
* using HTTP/1.x
> GET /tasks HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/8.21.0
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200 OK
< Content-Type: application/json
< Date: Wed, 23 Sep 2026 13:13:21 GMT
< Connection: keep-alive
< Keep-Alive: timeout=5
< Transfer-Encoding: chunked
<
* Connection #0 to host localhost:3000 left intact
[{"id":1,"title":"Buy milk","done":false}]

Lines starting with * are curl's own notes: it looked up localhost and opened a connection. Lines starting with > are the request it sent, and lines starting with < are the response it got. The last line is the body. Node added Date, Connection and Keep-Alive by itself; keep-alive means the connection stays open for the next request instead of being rebuilt each time.

To send a body, give curl a method with -X, a header with -H and the data with -d. -i prints the response headers without curl's notes:

Terminal on your computer
curl -i -X POST http://localhost:3000/tasks -H "Content-Type: application/json" -d '{"title":"Call Ada"}'
HTTP/1.1 201 Created
Content-Type: application/json
Location: /tasks/2
Date: Wed, 23 Sep 2026 13:13:21 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

{"id":2,"title":"Call Ada","done":false}

NOTE

On Windows PowerShell, curl may be a different command. Type curl.exe instead, and put the JSON in double quotes with the inner quotes escaped: -d "{\"title\":\"Call Ada\"}".

Headers and content types

Header names are not case sensitive: Content-Type and content-type are the same header. node:http gives you req.headers with every name in lower case, so always read them in lower case.

The most important header for an API is Content-Type. It names the format of the body with a media type: application/json for JSON, text/plain for plain text, text/html for a web page, application/x-www-form-urlencoded for a classic HTML form. It may carry extra parameters after a semicolon, like application/json; charset=utf-8.

A body is outside input, so a server must not trust it. This server accepts a new task only if the client says the body is JSON, the JSON is valid, and the title is a non-empty string. Each failure gets its own status code:

json-body.jsNode.js only
import http from "node:http";

const tasks = [];

function send(res, status, data) {
  res.writeHead(status, { "Content-Type": "application/json" });
  res.end(JSON.stringify(data));
}

const server = http.createServer(async (req, res) => {
  const type = (req.headers["content-type"] ?? "").split(";")[0].trim();
  if (type !== "application/json") {
    return send(res, 415, { error: "Send the body as application/json" });
  }
  let text = "";
  for await (const chunk of req) text += chunk;
  let body;
  try {
    body = JSON.parse(text);
  } catch {
    return send(res, 400, { error: "The body is not valid JSON" });
  }
  if (typeof body?.title !== "string" || body.title.trim() === "") {
    return send(res, 400, { error: "title must be a non-empty string" });
  }
  const task = { id: tasks.length + 1, title: body.title.trim(), done: false };
  tasks.push(task);
  send(res, 201, task);
});

server.listen(0, async () => {
  const url = `http://localhost:${server.address().port}/tasks`;
  const tries = [
    ["text/plain", "Buy milk"],
    ["application/json", "{title: 'Buy milk'}"],
    ["application/json; charset=utf-8", '{"title": 42}'],
    ["application/json", '{"title": "  Buy milk  "}'],
  ];
  for (const [type, body] of tries) {
    const res = await fetch(url, { method: "POST", headers: { "Content-Type": type }, body });
    console.log(res.status, await res.json());
  }
  server.close();
});
Output of node json-body.js
415 { error: 'Send the body as application/json' }
400 { error: 'The body is not valid JSON' }
400 { error: 'title must be a non-empty string' }
201 { id: 1, title: 'Buy milk', done: false }

Read the four answers:

  • 415 Unsupported Media Type: the client sent plain text to an endpoint that only speaks JSON.
  • 400 Bad Request: {title: 'Buy milk'} is valid JavaScript but not valid JSON. JSON needs double quotes around names and strings. Without the try/catch, JSON.parse would throw and crash the request.
  • 400 Bad Request again: the JSON is fine, but a title must be text. The ; charset=utf-8 part was accepted because the server compares only the part before the semicolon.
  • 201 Created: everything is valid, and the title was trimmed.

The body is read with for await because it arrives in pieces over the connection, as you saw with streams in the streams lesson. A real server also limits how many bytes it reads, so nobody can send a gigabyte of "JSON".

Cookies

HTTP does not remember anything between two requests. Each one stands alone. A cookie is how a server asks the browser to remember a small piece of text and send it back on every later request. It is how "you are still logged in" works.

The server sets a cookie with a Set-Cookie response header. The browser sends it back in a Cookie request header. The attributes after the value are rules for the browser:

AttributeWhat it does
HttpOnlyJavaScript on the page cannot read the cookie. If an attacker manages to run a script on your site, it still cannot steal the session.
SecureThe browser sends the cookie only over HTTPS, never over plain HTTP where anyone on the network could read it.
SameSite=LaxThe browser does not send the cookie with most requests started by other sites. This blocks many cross-site request forgery (CSRF) attacks. Strict is even tighter; None turns the protection off and needs Secure.
Max-Age=3600The cookie expires after 3600 seconds. Without it, the cookie disappears when the browser closes.
Path=/Send the cookie for every path on the site.

Writing and reading these headers is plain string work, so it runs in the browser too:

cookie.js
function serializeCookie(name, value, maxAgeSeconds) {
  return [
    `${name}=${encodeURIComponent(value)}`,
    "Path=/",
    `Max-Age=${maxAgeSeconds}`,
    "HttpOnly",
    "Secure",
    "SameSite=Lax",
  ].join("; ");
}

function parseCookies(header = "") {
  const cookies = {};
  for (const part of header.split(";")) {
    const i = part.indexOf("=");
    if (i === -1) continue;
    cookies[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
  }
  return cookies;
}

console.log(serializeCookie("theme", "dark mode", 3600));
console.log(parseCookies("theme=dark%20mode; lang=en"));
console.log(parseCookies(undefined));
Output of node cookie.js and of the browser terminal
theme=dark%20mode; Path=/; Max-Age=3600; HttpOnly; Secure; SameSite=Lax
{ theme: 'dark mode', lang: 'en' }
{}

encodeURIComponent turns characters that are not allowed in a cookie, like the space, into %20. parseCookies splits on the first = only, because a value may contain one.

Now a login flow. The server stores sessions in a Map, gives each one a random id nobody can guess, and sends only that id in the cookie. The user's data never leaves the server. A real login checks a password first; this one skips that step to keep the focus on cookies. The browser would send the cookie back by itself; here fetch plays the browser, so the example copies it by hand:

session.jsNode.js only
import http from "node:http";
import { randomUUID } from "node:crypto";

const sessions = new Map();

const server = http.createServer((req, res) => {
  if (req.method === "POST" && req.url === "/login") {
    const id = randomUUID();
    sessions.set(id, { user: "ada" });
    res.setHeader("Set-Cookie", `sid=${id}; Path=/; Max-Age=3600; HttpOnly; Secure; SameSite=Lax`);
    res.writeHead(204).end();
    return;
  }
  const sid = (req.headers.cookie ?? "").match(/(?:^|;\s*)sid=([^;]+)/)?.[1];
  const session = sessions.get(sid);
  res.writeHead(session ? 200 : 401, { "Content-Type": "application/json" });
  res.end(JSON.stringify(session ?? { error: "Log in first" }));
});

server.listen(0, async () => {
  const base = `http://localhost:${server.address().port}`;
  const login = await fetch(`${base}/login`, { method: "POST" });
  const setCookie = login.headers.get("set-cookie");
  console.log(login.status, setCookie);

  const cookie = setCookie.split(";")[0];
  const me = await fetch(`${base}/me`, { headers: { Cookie: cookie } });
  console.log(me.status, await me.json());

  const stranger = await fetch(`${base}/me`);
  console.log(stranger.status, await stranger.json());
  server.close();
});
Output of node session.js
204 sid=560ddd14-acff-481e-ab70-e68c4f621a66; Path=/; Max-Age=3600; HttpOnly; Secure; SameSite=Lax
200 { user: 'ada' }
401 { error: 'Log in first' }

The id is different on every run. 204 No Content means "it worked and there is no body". The request without the cookie got 401 Unauthorized. The Secure attribute did not stop this example, because only browsers enforce the attributes; fetch in Node ignores them.

A cookie is not a place for data

Never put a user id, a role or anything you trust into a cookie as plain text: the user can edit their own cookies. Send a long random id and look it up on the server, as above. The ZudoJS authentication lesson comes back to sessions and tokens with ready-made, tested code.

Methods: safe and idempotent

The method says what the client wants to do. Two properties matter when you design an API:

  • A safe method only reads. It never changes anything on the server, so browsers, caches and search engines may call it freely.
  • An idempotent method has the same effect whether you send it once or ten times. If a network error hides the answer, the client can simply send it again.
MethodUsed forSafeIdempotent
GETRead a resourceYesYes
HEADLike GET, but headers only, no bodyYesYes
OPTIONSAsk which methods are allowedYesYes
POSTCreate something, or run an actionNoNo
PUTReplace a resource completelyNoYes
PATCHChange part of a resourceNoNot guaranteed
DELETERemove a resourceNoYes

See the difference with an array standing in for the database. Sending the same POST twice creates two tasks. Sending the same PUT twice leaves one task in the same state:

idempotent.js
const tasks = [];

function post(body) {
  tasks.push({ id: tasks.length + 1, ...body });
}

function put(id, body) {
  const i = tasks.findIndex((t) => t.id === id);
  tasks[i] = { id, ...body };
}

post({ title: "Buy milk", done: false });
post({ title: "Buy milk", done: false });
console.log("after two POSTs:", tasks.length, "tasks");

put(1, { title: "Buy oat milk", done: true });
put(1, { title: "Buy oat milk", done: true });
console.log("after two PUTs:", tasks);
Output of node idempotent.js and of the browser terminal
after two POSTs: 2 tasks
after two PUTs: [
  { id: 1, title: 'Buy oat milk', done: true },
  { id: 2, title: 'Buy milk', done: false }
]

That is why a browser warns you before it resends a form (a POST), but reloads a normal page (a GET) without asking. Never change data in a GET handler: a link preview or a search engine could trigger it.

Status codes

The first digit of a status code gives its class, so a client knows roughly what happened even for a code it has never seen:

  • 1xx informational: "keep going". You rarely handle these yourself.
  • 2xx success.
  • 3xx redirection: the thing is somewhere else; look in the Location header.
  • 4xx client error: the request was wrong. Sending it again unchanged will fail again.
  • 5xx server error: the request may be fine, the server failed. Trying again later can work.

Node knows the reason phrase for every standard code in http.STATUS_CODES. These are the ones you will use most:

status.jsNode.js only
import { STATUS_CODES } from "node:http";

const classes = { 1: "informational", 2: "success", 3: "redirection", 4: "client error", 5: "server error" };
const common = [200, 201, 204, 301, 304, 400, 401, 403, 404, 405, 409, 415, 422, 429, 500, 503];

for (const code of common) {
  const kind = classes[Math.floor(code / 100)];
  console.log(code, STATUS_CODES[code].padEnd(24), kind);
}
Output of node status.js
200 OK                       success
201 Created                  success
204 No Content               success
301 Moved Permanently        redirection
304 Not Modified             redirection
400 Bad Request              client error
401 Unauthorized             client error
403 Forbidden                client error
404 Not Found                client error
405 Method Not Allowed       client error
409 Conflict                 client error
415 Unsupported Media Type   client error
422 Unprocessable Entity     client error
429 Too Many Requests        client error
500 Internal Server Error    server error
503 Service Unavailable      server error

A few that beginners often mix up:

  • 401 Unauthorized means "I do not know who you are": log in. 403 Forbidden means "I know who you are, and you may not do this".
  • 400 Bad Request is for a request the server cannot read. 422 Unprocessable Content (Node still prints its older name, Unprocessable Entity) is sometimes used when the server can read the request but the values break a rule. Pick one style and use it everywhere.
  • 404 Not Found: no such resource. 405 Method Not Allowed: the resource exists, but not with this method. Send an Allow header listing the methods that work.
  • 409 Conflict: the request clashes with the current state, like a duplicate title.
  • 429 Too Many Requests: slow down. It is what a rate limiter sends.
  • 500 Internal Server Error: your bug. Log the details on the server, but never send the stack trace to the client.

Practice

TRY IT YOURSELF

Allow only GET and POST

Write a server for /tasks that answers GET with 200 and a JSON list, and any other method except POST with 405, an Allow: GET, POST header and a JSON error. Test it with fetch for GET and DELETE, then close the server.

Show a solution
allow.jsNode.js only
import http from "node:http";

const server = http.createServer((req, res) => {
  if (req.method === "GET") {
    res.writeHead(200, { "Content-Type": "application/json" });
    return res.end(JSON.stringify([]));
  }
  if (req.method !== "POST") {
    res.writeHead(405, { "Content-Type": "application/json", Allow: "GET, POST" });
    return res.end(JSON.stringify({ error: `${req.method} is not allowed here` }));
  }
  res.writeHead(201).end();
});

server.listen(0, async () => {
  const url = `http://localhost:${server.address().port}/tasks`;
  for (const method of ["GET", "DELETE"]) {
    const res = await fetch(url, { method });
    console.log(method, res.status, res.headers.get("allow"), await res.json());
  }
  server.close();
});
Output of node allow.js
GET 200 null []
DELETE 405 GET, POST { error: 'DELETE is not allowed here' }

TRY IT YOURSELF

A session cookie builder

Write sessionCookie(id) that returns a Set-Cookie value for a cookie named sid, valid for 7 days, with all the safe attributes from this lesson. Then write clearSessionCookie() that logs the user out. Hint: a cookie with Max-Age=0 is deleted at once.

Show a solution
session-cookie.js
const DAY = 24 * 60 * 60;

function sessionCookie(id) {
  return `sid=${encodeURIComponent(id)}; Path=/; Max-Age=${7 * DAY}; HttpOnly; Secure; SameSite=Lax`;
}

function clearSessionCookie() {
  return "sid=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax";
}

console.log(sessionCookie("3f9c2a"));
console.log(clearSessionCookie());
Output of node session-cookie.js and of the browser terminal
sid=3f9c2a; Path=/; Max-Age=604800; HttpOnly; Secure; SameSite=Lax
sid=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax

The cookie that clears must have the same name and Path as the one that set it, or the browser treats it as a different cookie.

TRY IT YOURSELF

Which status code?

For each situation, pick a status code: (a) a task was created; (b) DELETE /tasks/9 worked and there is nothing to return; (c) the client is logged in but tries to delete another user's task; (d) the client sent {"title": ""}; (e) the database is down.

Show a solution

(a) 201 Created, with a Location header. (b) 204 No Content. (c) 403 Forbidden, or 404 Not Found if you do not want to reveal that the task exists. (d) 400 Bad Request (or 422, if that is your API's style). (e) 503 Service Unavailable, or 500.

Recap

  • An HTTP request is text: a request line (method, path, version), headers, an empty line and an optional body. A response has a status line, headers, an empty line and a body.
  • curl -v shows the whole conversation. Use it whenever an API surprises you.
  • Content-Type names the body's format. Check it, parse JSON inside try/catch, and validate every field.
  • Cookies carry a random session id, never trusted data. Set HttpOnly, Secure, SameSite=Lax and a Max-Age.
  • GET, HEAD and OPTIONS are safe. PUT and DELETE are idempotent. POST is neither.
  • 2xx success, 3xx look elsewhere, 4xx the client's mistake, 5xx the server's.

Test yourself

Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.