01/13Client and server

LEARN TO PROGRAM · CHAPTER 12 · ADVANCED

Networks and APIs

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.

Every code block runs in your browser. Edit it, break it, press Run.

01 · CLIENT AND SERVER

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:

client-and-server.js

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: a request is text

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:

raw-request.js

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.

HTTP methods (RFC 9110, §9)
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:

fake-fetch.js

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

Status codes: the answer in three digits

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.

the codes you'll meet most
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.

status.js

Only /unplugged, where no answer arrived at all, rejects. Every real request needs an if (!res.ok) branch.

418"I'm a teapot" began as a 1998 April Fools' RFC. It was copied into so many real servers that today's HTTP standard reserves 418 as unused, so no one can give it a real meaning. RFC 9110 §15.5.19

04 · HEADERS

Headers: everything about the message

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.

etag.js

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:

terminal
$ 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

JSON: the shape of the data

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:

json.js

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:

client.py · Python, with the requests library
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"])
9,007,199,254,740,991is 253 − 1, the largest whole number a JavaScript number is guaranteed to hold exactly. JSON's own standard warns about numbers beyond it. MDN: Number.MAX_SAFE_INTEGER

06 · REST

REST: nouns and verbs

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:

rest-api.js

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):

server.js · Node.js
// 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

Designing a good API

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:

  • Name things plainly and consistently. Plural nouns for collections, one case style everywhere (created_at or createdAt, never both), the same word for the same idea in every endpoint.
  • Version from day one. Put /v1/ in the path (or a version header). Adding a field is safe; removing or renaming one breaks clients, so that waits for /v2/.
  • Errors are part of the API. Use the right status code and a body that says what went wrong, in a standard shape.
  • Never return an unbounded list. Paginate.
a consistent resource layout
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
an error in the standard Problem Details shape (RFC 9457)
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:

pagination.js

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

Idempotency: safe to try again

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.

idempotency.js

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:

backoff.js

09 · TOKENS

Tokens: proving who you are

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:

token.js

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

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:

token-bucket.js

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, properly

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.

origin.js

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:

  • Allowed: the right origin, the right header, the page reads the response.
  • Not allowed, simple request: a plain GET from evil.example reaches the server and the server answers. The browser blocks the answer at the shield. CORS stopped the reading, not the request.
  • Not allowed, preflighted: a 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.
cors.js

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

WebSockets: the server speaks first

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:

handshake.js

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:

socket.js

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

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

a GraphQL query and its answer
# 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" } } } }
mini-graphql.js

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
// 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; }
protobuf-bytes.js

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

Now you do it

Type the code yourself rather than pasting it: producing an answer builds memory that reading does not. Use the hints one at a time.

1

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.

exercise-1.js
2

Parse a raw response

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.

exercise-2.js
3

Follow the cursor

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.

exercise-3.js
4

Make a payment idempotent

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.

exercise-4.js
5

A fixed-window rate limiter

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

exercise-5.js
6

Answer CORS correctly

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.

exercise-6.js

RECALL

Check yourself, from memory

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

Resources

  • DOCS · FREEAn overview of HTTPMDN Web DocsStart here. Then the rest of MDN's HTTP guides: caching, CORS, cookies, status codes, one page each.
  • DOCS · FREEUsing the Fetch APIMDN Web DocsEverything fakeFetch imitated above, for real: requests, responses, headers, bodies, errors, aborting.
  • BOOK · FREEHigh Performance Browser NetworkingIlya GrigorikFree online. TCP, TLS, HTTP/1.1, HTTP/2 and WebSockets, explained by what they cost in milliseconds.
  • BOOK · FREEEverything curlDaniel StenbergThe free book by curl's author. curl -v is the single best tool for seeing what really goes over the wire.
  • DOCS · FREEZalando RESTful API GuidelinesZalandoA large company's real rulebook for naming, versioning, pagination, errors and compatibility. Opinionated, with reasons.
  • DOCS · FREEGoogle API Improvement ProposalsGoogleShort design rules, one per page: resource names, standard methods, pagination (AIP-158), errors.
  • DOCS · FREELearn GraphQLGraphQL FoundationThe official tour: queries, schemas, resolvers, and when GraphQL is the wrong tool.
  • DOCS · FREEgRPC: IntroductiongRPC authorsServices defined in .proto files, generated clients, streaming. Then try a quick start in your language.
  • BOOKDesigning Data-Intensive ApplicationsMartin KleppmannChapter 4 (encoding: JSON, Protocol Buffers, REST and RPC) and the chapters on faults and retries are the grown-up version of this page.

ON THIS SITE

See it running

SOURCES

Where the facts come from

  1. RFC 9110: HTTP Semantics (2022) Methods, safe and idempotent methods (§9.2), status codes (§15), headers, conditional requests (ETag, If-None-Match, 304); 418 is listed as unused (§15.5.19).
  2. RFC 9112: HTTP/1.1 The text message format: start line, header fields, CRLF, empty line, body.
  3. W3C: The original HTTP as defined in 1991 HTTP/0.9: one line, GET only.
  4. RFC 9113: HTTP/2 Binary frames and many streams over one connection, used by gRPC.
  5. WHATWG Fetch Standard fetch(), Request, Response, Headers, and the CORS protocol (preflight, Access-Control-* headers, credentials).
  6. MDN: Cross-Origin Resource Sharing (CORS) Simple requests vs preflighted ones, and why the browser enforces it.
  7. MDN: Same-origin policy An origin is scheme, host and port.
  8. MDN: fetch() rejects only on network errors A 404 or 500 still resolves; check response.ok.
  9. RFC 8259: The JSON Data Interchange Format Including §6 on numbers and interoperability beyond 2^53.
  10. MDN: Number.MAX_SAFE_INTEGER 2^53 − 1 = 9,007,199,254,740,991.
  11. MDN: JSON.stringify undefined is dropped, NaN becomes null, Dates use toJSON().
  12. Roy Fielding (2000): Architectural Styles and the Design of Network-based Software Architectures, ch. 5 Where REST was defined.
  13. RFC 9457: Problem Details for HTTP APIs (2023) application/problem+json.
  14. Stripe API: Idempotent requests The Idempotency-Key header in production use.
  15. IETF draft: The Idempotency-Key HTTP header field
  16. AWS Architecture Blog: Exponential backoff and jitter Full jitter, measured.
  17. RFC 6750: Bearer token usage (OAuth 2.0) Authorization: Bearer <token>.
  18. RFC 7519: JSON Web Token header.payload.signature, base64url, the exp and sub claims.
  19. RFC 6585: Additional HTTP status codes (2012) 429 Too Many Requests, with Retry-After.
  20. RFC 6455: The WebSocket Protocol (2011) The Upgrade handshake, the fixed GUID, and the worked example key dGhlIHNhbXBsZSBub25jZQ== → s3pPLMBiTxaQ9kYGzzhZRbK+xOo=.
  21. GraphQL specification
  22. GraphQL Foundation: history Built at Facebook in 2012, open-sourced in 2015.
  23. Protocol Buffers: Encoding Varints, tags and wire types; field 1 = 150 encodes as 08 96 01.
  24. gRPC: About Google's internal Stubby, rebuilt and open-sourced as gRPC in March 2015.
  25. RFC 2324: Hyper Text Coffee Pot Control Protocol (1 April 1998) The joke that gave us 418.
  26. gRPC: Core concepts Unary and streaming RPCs over HTTP/2.