Write a route handler
Finish handle(req) so that GET /books/2 answers 200 with Emma, GET /books/9 answers 404, and any other method answers 405 Method Not Allowed.
LEARN TO PROGRAM · CHAPTER 12 · ADVANCED
Almost every program you use talks to another one. Your weather app asks a server for a forecast; the server asks a database; a payment page asks a bank. The rules for those conversations are protocols, and the menu of questions a program agrees to answer is its API (application programming interface).
This chapter follows requests and responses across the wire, from the first bytes of HTTP to designing an API other people will enjoy using. The code blocks can't reach the internet (they run in a sealed sandbox), so each one brings a pretend server that lives in the same tab. Scroll, and the 3D follows.
01 · CLIENT AND SERVER
A client is the program that asks; a server is the program that waits to be asked and answers. Your browser is a client. The machine at the other end of a URL runs a server: a program in a loop, listening on a network port, turning each request into a response. The same program can be both: a web server is a client of its database.
Underneath, the request travels as packets: small chunks that routers pass along hop by hop. In the 3D the client first opens a connection with TCP's three-way handshake (SYN, SYN-ACK, ACK), then agrees on encryption keys with TLS, and only then sends the actual question, GET /books/42, and gets 200 OK back. How packets find their way is the subject of The Internet; how TLS keeps them private is HTTPS. This chapter lives on top of both.
Strip away the network and a server is just a function from a request to a response. That's the first pretend server:
Ask for /books/43, or add a second book to books. Press Step through to watch the request go in and the response come out.
The real version adds a network between the two calls, and with it everything that can go wrong with a network: it is slow, it drops messages, and the other side can be down. Most of what makes network code hard comes from that gap. To follow one request through DNS, routers and data centres, try the Request Journey.
02 · HTTP REQUESTS
HTTP is the language of the web, and in its classic form (HTTP/1.1) it is plain text you could type by hand. A request is a start line (method, target, version), then headers (one Name: value per line), then an empty line, then an optional body. The board in the 3D shows exactly this request:
Lines end in \r\n (carriage return, line feed), as the spec requires. Put an emoji in the title and watch Content-Length: it counts bytes, not characters.
The method says what you want done. The important property is whether it's safe (it changes nothing) and idempotent (doing it twice is the same as doing it once). Clients, caches and proxies rely on those promises: a browser will retry a GET on its own, but it won't silently resend a POST.
method asks the server to… safe idempotent
GET send this resource yes yes
HEAD GET, but headers only yes yes
OPTIONS say what is allowed here yes yes
POST process this (often: create) no no
PUT store this, replacing it no yes
PATCH change part of it no not by default
DELETE remove it no yes
In JavaScript you rarely write that text yourself. You call fetch(), which builds a Request, sends it, and gives you a promise of a Response. Here is fetch's exact shape with a pretend server behind it:
The server reads the body with await req.json(), and the client does the same with the response: bodies arrive as a stream, so reading them is asynchronous. Await and promises are the subject of the async chapter.
03 · STATUS CODES
Every response starts with a three-digit status code, and the first digit tells you most of what you need: 2xx it worked, 3xx look elsewhere, 4xx you (the client) did something wrong, 5xx the server failed. In the 3D the five classes stand in columns, and each answer lights the code it carries.
1xx informational 101 Switching Protocols
2xx success 200 OK · 201 Created · 204 No Content
3xx redirection 301 Moved Permanently · 304 Not Modified
4xx client error 400 Bad Request · 401 Unauthorized · 403 Forbidden
404 Not Found · 409 Conflict · 422 Unprocessable Content
429 Too Many Requests
5xx server error 500 Internal Server Error · 502 Bad Gateway
503 Service Unavailable · 504 Gateway Timeout
The distinction between 4xx and 5xx matters in practice: a 4xx means "don't send that again unchanged", while a 503 or a 429 often means "try again later". And there is a trap here that catches almost everybody once: fetch() does not throw on a 404 or a 500.
Only /unplugged, where no answer arrived at all, rejects. Every real request needs an if (!res.ok) branch.
04 · HEADERS
Headers are the message's metadata: what format the body is in (Content-Type), what format the client wants back (Accept), who is asking (Authorization), how long the answer may be cached (Cache-Control), and dozens more. Their names are case-insensitive: ETag, etag and ETAG are the same header.
One pair of headers shows how much they can do. The server labels each version of a resource with an ETag. Next time, the client sends that label back in If-None-Match; if nothing changed, the server answers 304 Not Modified with no body at all. Same answer, a fraction of the bytes. That's the second exchange in the 3D.
Change book.year before the second request and the 304 becomes a 200.
The best tool for seeing headers is curl -i (or -v for everything) in a terminal, or the Network tab in your browser's developer tools. HTTP/2 sends header names in lower case, which is why curl prints them that way:
$ curl -i https://api.example.com/books/42
HTTP/2 200
content-type: application/json
etag: "v1"
cache-control: no-cache
content-length: 36
{"id":42,"title":"Dune","year":1965}
05 · JSON
A body is just bytes, so both sides need to agree on a format. For APIs that is overwhelmingly JSON (JavaScript Object Notation): objects in { }, arrays in [ ], strings in double quotes, numbers, true, false and null. Nothing else. No dates, no comments, no undefined, no functions.
In the 3D, the text that travels is flat; JSON.parse turns it back into a tree of values on arrival. JSON.stringify goes the other way, and it quietly changes things that JSON can't express:
Look for three surprises: the Date became a string, notes vanished, and NaN became null. The last line printed a number that isn't the one in the text.
That last one bites real systems. JSON numbers can be any size, but a JavaScript number is a 64-bit float that holds whole numbers exactly only up to 253 − 1. Big ids get rounded silently, so careful APIs send large ids as strings. Every other language has its own JSON library; in Python it looks like this:
import requests
res = requests.get("https://api.example.com/books/42", timeout=5)
res.raise_for_status() # turn a 4xx or 5xx into an exception
book = res.json() # JSON text → a Python dict
print(book["title"])
06 · REST
REST is a style for designing APIs around resources: the things your API is about, each with its own URL. The URL is a noun (/books, /books/2, /books/2/reviews) and the HTTP method is the verb. You don't make up /getBook and /deleteBook; you send GET or DELETE to /books/2.
In the 3D the server's resources form a tree. GET /books reads the collection, POST /books creates /books/3 (and answers 201 with a Location header saying where), PATCH changes it, DELETE /books/1 removes one and answers 204 No Content. Here is that whole API, in memory:
Step through the POST and watch nextId. Then try handle("PUT", "/books/2", { title: "Persuasion" }): PUT replaces the whole thing, PATCH changes part.
The real version is almost the same code. In Node.js, with nothing but the standard library (this one can't run here: a sandbox that could open network ports wouldn't be a sandbox):
// The same idea as a real server, in Node.js (it can't run in this sandbox:
// a sandbox with a network port would not be a sandbox).
import { createServer } from "node:http";
const books = new Map([[42, { id: 42, title: "Dune" }]]);
createServer((req, res) => {
const m = req.url.match(/^\/books\/(\d+)$/);
const book = req.method === "GET" && m && books.get(Number(m[1]));
res.writeHead(book ? 200 : 404, { "Content-Type": "application/json" });
res.end(JSON.stringify(book || { error: "not found" }));
}).listen(8080); // then: curl -i http://localhost:8080/books/42
07 · API DESIGN
An API is a promise. Once other people's code depends on it, every name and every shape is very hard to change. A few habits make it a promise worth keeping:
created_at or createdAt, never both), the same word for the same idea in every endpoint./v1/ in the path (or a version header). Adding a field is safe; removing or renaming one breaks clients, so that waits for /v2/.GET /v1/books?author=herbert&limit=20 list, filtered, one page
POST /v1/books create; 201 + Location
GET /v1/books/42 one book
PATCH /v1/books/42 change some fields
DELETE /v1/books/42 remove; 204
GET /v1/books/42/reviews a sub-collection
POST /v1/books/42:archive an action that isn't CRUD
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/invalid-field",
"title": "A field is invalid",
"status": 422,
"detail": "year must be a whole number, got \"soon\"",
"instance": "/v1/books"
}
Pagination is where design decisions show. The obvious way is an offset: "skip 20, give me 20". But on a live list, items move while you read, and offsets count positions. A cursor says "the ones after this item", and the 3D shows one walking down the list, four at a time. Run both side by side:
The offset reader sees post 7 twice, because a new post pushed everything down one place. The cursor reader doesn't. Big offsets are also slow: a database still has to walk past every skipped row.
08 · IDEMPOTENCY
Here is the hardest problem in network programming, in one sentence: when a request times out, you don't know whether it happened. Maybe the request was lost. Maybe the server did the work and the reply was lost. From the client, the two look identical.
So clients retry, and for a GET that's harmless. For "charge this card 30 euros", it isn't. The fix is an idempotency key: the client makes up a unique key for the operation and sends it with every attempt. The server stores the reply under that key, and when the same key arrives again it replays the stored reply instead of doing the work twice. In the 3D, the first reply is lost on the way back, the client retries, and the ledger answers from memory.
Step through it and watch charges. Without keys it ends as [30, 30]: the customer paid twice.
Retrying well also means waiting well. If a server is overloaded and every client retries instantly, they knock it over again. Exponential backoff doubles the wait after each failure; jitter randomises it so the retries spread out:
09 · TOKENS
HTTP is stateless: each request stands alone, and the server doesn't remember the last one. So every request has to carry proof of who sent it. The common pattern: you sign in once (username and password, over HTTPS), and the server gives you a token. You send it on every later request in a header: Authorization: Bearer <token>.
In the 3D a gate checks each request. The first one has no token and bounces with 401; then the client signs in, receives a token (three coloured parts), and its next request passes. A token can be a random string the server looks up in its database, or a self-contained signed token like a JWT: a header, a payload of claims and a signature, each base64url-encoded and joined with dots. This one is built and checked with a real HMAC-SHA-256 signature:
Paste the token into a base64 decoder and you can read the payload: it is encoded, not secret. But change one letter and the signature no longer matches, so the forged admin token gets a 401.
10 · RATE LIMITS
A public API has to protect itself from clients that send too much, by accident (a loop with a bug) or on purpose. A rate limit caps how many requests one client may make in a period. Over the limit, the server answers 429 Too Many Requests, ideally with a Retry-After header saying how many seconds to wait.
The 3D shows the most common algorithm, the token bucket. The bucket holds up to five tokens and drips in one more each second. Every request takes a token; when the bucket is empty, requests bounce with 429. It allows short bursts but caps the average rate:
Step through it with the times in mind: five pass at once, then 1.5 seconds of refill buys exactly one more.
A good client treats a 429 as an instruction, not an error: read Retry-After, back off, try again. Exercise 5 below builds a simpler limiter, the fixed window, and shows its weakness.
11 · CORS
CORS confuses almost everyone at first, because it is not a server feature that protects your API. It is a browser rule that protects the user. Start with the problem. Your browser holds cookies for your bank. If any web page's JavaScript could call fetch("https://bank.example/balance") and read the answer, every site you visit could read your bank account. So browsers enforce the same-origin policy: a page's scripts may only read responses from their own origin.
Scheme, host and port: change any one and it's a different origin. Even http versus https.
But legitimate apps need cross-origin calls all the time: app.example calling api.example. CORS (Cross-Origin Resource Sharing) lets a server opt in. The browser sends an Origin header; if the server's response carries Access-Control-Allow-Origin naming that origin, the browser lets the page read it. If not, the page gets a network error. In the 3D the shield at the browser's end does the checking, and three things happen:
evil.example reaches the server and the server answers. The browser blocks the answer at the shield. CORS stopped the reading, not the request.DELETE (or anything beyond a plain GET, HEAD or form-style POST) is not sent straight away. The browser first sends an OPTIONS preflight asking permission. The server says nothing useful, so the real DELETE is never sent.Read serverLog at the end: the server saw the evil GET, but only the OPTIONS for the DELETE. Add "https://evil.example" to allowed and run it again.
12 · WEBSOCKETS
HTTP is ask-then-answer: the server can only reply. For a chat app or live prices, the client would have to keep asking "anything new?" (polling), which is slow and wasteful. A WebSocket turns one connection into a two-way pipe that stays open: either side can send a message at any moment.
It begins as an ordinary HTTP request with an Upgrade: websocket header. The server agrees with 101 Switching Protocols, and from then on the same TCP connection carries WebSocket frames both ways. In the 3D the pipe opens after the 101, and prices start arriving without being asked for. The server even proves it understood the request with a little hash, which you can compute yourself and compare with the example in the standard:
The printed value matches the worked example in RFC 6455, section 1.3: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=.
The browser API is small: new WebSocket(url), then send() and a message event. This pretend socket has the same shape:
Notice the order of the output: the program's last line runs first, and messages arrive later, as events.
Lighter alternatives exist: Server-Sent Events for one-way streams from server to client over plain HTTP, and long polling where nothing else works. Each open socket also costs the server memory, so a million connected users is an engineering project of its own (see the cluster).
13 · GRAPHQL AND GRPC
REST is not the only shape an API can take. Two others are common enough that you should know what they are for.
GraphQL (Facebook, 2012; open-sourced in 2015) gives a single endpoint and lets the client say exactly which fields it wants, nested as deep as it likes. The answer has the same shape as the question. That saves round trips and over-fetching for apps with many different screens, at the price of caching and rate limiting being harder (every query is a POST to one URL).
# The client says exactly which fields it wants…
query {
book(id: 42) {
title
author { name }
}
}
# …and the answer has exactly that shape.
{ "data": { "book": { "title": "Dune", "author": { "name": "Frank Herbert" } } } }
The author resolver runs only when a query asks for it. Try ask("{ book { pages } }").
gRPC (Google, open-sourced 2015) is for programs talking to programs, usually inside one company. You describe the service in a .proto file, generate client and server code in each language, and calls look like ordinary function calls. Messages are Protocol Buffers: compact binary, not text. They travel over HTTP/2, which runs many streams in one connection, and in the 3D extra lanes open and binary frames interleave on them.
// books.proto: the contract both sides compile into code
syntax = "proto3";
service Books {
rpc GetBook (GetBookRequest) returns (Book);
rpc WatchPrices (GetBookRequest) returns (stream Price);
}
message GetBookRequest { int32 id = 1; }
message Book { int32 id = 1; string title = 2; int32 year = 3; }
message Price { int32 cents = 1; }
Try a small number like 5, or a large one like 300,000, and count the bytes. Small numbers take one byte; that is the point of a varint.
PRACTICE
Type the code yourself rather than pasting it: producing an answer builds memory that reading does not. Use the hints one at a time.
Finish handle(req) so that GET /books/2 answers 200 with Emma, GET /books/9 answers 404, and any other method answers 405 Method Not Allowed.
A response arrives as text. Write parseResponse(text) returning { status, headers, body }: the status as a number, header names in lower case (they are case-insensitive, so normalise them), values trimmed, and the body as everything after the empty line.
The server hands out books five at a time with a next cursor. Write the client loop that collects all 23 into all, calling listBooks until next is null. It should take exactly 5 calls.
pay(key, amount) charges every time it's called, so a retry charges twice. Change it so a repeated key returns the reply from the first call and doesn't charge again. A new key must still charge.
Write allow(now): at most 3 requests in each 1000 ms window (0–999, 1000–1999, …). The given times must produce [true, true, true, false, false, true, true, true, false].
The starter answers every origin with *, letting any website's scripts read this API's responses. Write corsHeaders(origin) so only the two listed origins get Access-Control-Allow-Origin (their own origin, echoed back) plus Vary: Origin; anyone else gets no CORS headers.
RECALL
Answer each one before you open it. Then come back tomorrow, and again next week, and answer them cold: retrieval spaced over days is the most reliable study method we know of. Why that works.
A fetch() to a URL comes back 404. Does the promise reject?
What do safe and idempotent mean for an HTTP method?
401 or 403: which means "who are you?" and which means "I know who you are, and no"?
Why does cursor pagination beat offset pagination on a live feed?
Who enforces CORS, and what does it protect?
Why can't you trust the payload of a JWT that a client sends you without checking it?
When would you reach for a WebSocket instead of plain HTTP requests?
GO FURTHER
ON THIS SITE
SOURCES