Lesson 10 / 34
Correct Use of HTTP Methods
The promises a method carries: a safe method not changing data, and an idempotent method producing a single effect when repeated; testing each property by counting rows in the database, PATCH's increment and assignment faces, and the identity choice between PUT and POST.
Contents
The previous lesson set the path aside for a resource’s identity and moved on with “the method says what to do.” That sentence holds more than a division of labor. An HTTP method is not just a command sent to the server; it is a promise that the client, caches, and every component in between rely on when they look at the request.
This lesson defines those promises and measures each one. The measurement is uniform: the same request is sent twice in a row, and the row count and field values in the database are counted at every step. Whether a method keeps its promise is settled by numbers, not by argument.
A Method Is Not a Name, It Is a Promise
The How the Internet Works course introduced two properties. A safe method leaves no observable change on the server; it reads, it does not write. An idempotent method makes no difference to the server’s final state whether the same request is sent once or five times in a row. The two are independent: every safe method is idempotent, but not every idempotent method is safe.
| Method | Safe | Idempotent | Carries a body |
|---|---|---|---|
| GET | yes | yes | no |
| HEAD | yes | yes | no |
| OPTIONS | yes | yes | no |
| POST | no | no | yes |
| PUT | no | yes | yes |
| PATCH | no | depends on design | yes |
| DELETE | no | yes | usually no |
These properties are not the server’s internal business; they are commitments made outward. A cache can store a GET response and answer the next request without going to the server — trusting that GET has no side effect. A client can resend a PUT request after a network error — trusting that repeating it is harmless. The request layer in the Application Architecture course encoded this trust as retryability.
If the commitment is not kept, what breaks is not the server, but the parties that trust it.
Safety: A Read Does Not Change Data
The service below carries a deliberate flaw for the measurement: a loan return can be made both the right way and through a read-shaped address.
// method-server.mjs — a service set up to measure method properties import { createServer } from "node:http"; import { DatabaseSync } from "node:sqlite"; const db = new DatabaseSync("library.db"); const readBody = (request) => new Promise((resolve) => { let data = ""; request.on("data", (p) => (data += p)); request.on("end", () => resolve(data ? JSON.parse(data) : {})); }); const respond = (response, status, data) => { response.writeHead(status, { "content-type": "application/json; charset=utf-8" }); response.end(data === null ? "" : JSON.stringify(data)); }; const server = createServer(async (request, response) => { const path = new URL(request.url, "http://127.0.0.1").pathname; const method = request.method; const book = /^\/books\/([^/]+)$/.exec(path); const loan = /^\/loans\/(\d+)$/.exec(path); const returnPath = /^\/loans\/(\d+)\/return$/.exec(path); if (method === "GET" && path === "/loans") return respond(response, 200, db.prepare("SELECT * FROM loan").all()); // WRONG: a read-shaped address changes data. if (method === "GET" && returnPath) { db.prepare("UPDATE loan SET return = '2026-03-12' WHERE id = ? AND return IS NULL") .run(Number(returnPath[1])); return respond(response, 200, { result: "return received" }); } if (method === "POST" && path === "/loans") { const g = await readBody(request); const s = db.prepare("INSERT INTO loan (member, isbn, issuedAt, return) VALUES (?,?,?,NULL)") .run(g.member, g.isbn, g.issuedAt); return respond(response, 201, { id: Number(s.lastInsertRowid) }); } if (method === "PUT" && book) { const g = await readBody(request); // Full replacement: the sent representation determines the whole record. const exists = db.prepare("SELECT 1 FROM book WHERE isbn = ?").get(book[1]); db.prepare(`INSERT INTO book (isbn, title, author, year, branch, copies) VALUES (?,?,?,?,?,?) ON CONFLICT(isbn) DO UPDATE SET title=excluded.title, author=excluded.author, year=excluded.year, branch=excluded.branch, copies=excluded.copies`) .run(book[1], g.title, g.author, g.year, g.branch, g.copies); return respond(response, exists ? 200 : 201, db.prepare("SELECT * FROM book WHERE isbn = ?").get(book[1])); } if (method === "PATCH" && book) { const g = await readBody(request); if ("addCopies" in g) // increment: depends on the previous value db.prepare("UPDATE book SET copies = copies + ? WHERE isbn = ?").run(g.addCopies, book[1]); if ("copies" in g) // assignment: independent of the previous value db.prepare("UPDATE book SET copies = ? WHERE isbn = ?").run(g.copies, book[1]); return respond(response, 200, db.prepare("SELECT isbn, copies FROM book WHERE isbn = ?").get(book[1])); } if (method === "DELETE" && loan) { db.prepare("DELETE FROM loan WHERE id = ?").run(Number(loan[1])); return respond(response, 204, null); } respond(response, 404, { error: "path_not_found" }); }); server.listen(8476, "127.0.0.1", () => console.log("method server 127.0.0.1:8476"));
Now suppose a client that follows the links on a page in order arrives at this service. Such a client only walks GET; it does not know what is behind a link.
# What happens when a link crawler follows a read-shaped address? rm -f library.db && sqlite3 library.db < schema.sql sqlite3 library.db "ALTER TABLE book ADD COLUMN copies INTEGER NOT NULL DEFAULT 1;" node method-server.mjs & server=$! sleep 0.4 open() { sqlite3 library.db "SELECT COUNT(*) FROM loan WHERE return IS NULL;"; } printf 'open loans at start : %s\n' "$(open)" # A client that follows every link on a page: it only walks GET. for path in /loans /loans/1/return /loans/3/return; do printf 'GET %-22s -> %s\n' "$path" "$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:8476$path")" done printf 'open loans after GET: %s\n' "$(open)" kill $server
method server 127.0.0.1:8476 open loans at start : 2 GET /loans -> 200 GET /loans/1/return -> 200 GET /loans/3/return -> 200 open loans after GET: 0
Two open loan records closed without any user performing a return. The client broke no rule: for a link-following program, GET is a question it is free to ask. The server is the one that broke the rule.
The scale of this flaw goes beyond a design mistake. A page-preview tool, a link-checking script, a browser that prefetches an address bar entry — all operate on the same assumption. In an example schema with two tables, two rows are lost; in a real catalog, the loss is as large as the number of links followed.
In a correct design, an address like /loans/{id}/return is never opened; a return is made
with a PATCH /loans/{id} request, as shown in the previous lesson. If the address is
read-shaped, the method must be a read too, and the reverse holds as well.
Idempotency: Sending the Same Request Twice
Idempotency measurement is direct. Every method is called twice in a row; if the result of two calls differs from that of one, the method is not idempotent.
# Every method is called twice in a row; the effect on the database is counted after each call. rm -f library.db && sqlite3 library.db < schema.sql sqlite3 library.db "ALTER TABLE book ADD COLUMN copies INTEGER NOT NULL DEFAULT 1;" node method-server.mjs & server=$! sleep 0.4 T=http://127.0.0.1:8476 say() { printf '%-26s loans=%s copies=%s\n' "$1" \ "$(sqlite3 library.db 'SELECT COUNT(*) FROM loan;')" \ "$(sqlite3 library.db "SELECT copies FROM book WHERE isbn='978-0131103627';")"; } say "start" curl -sS -o /dev/null $T/loans; curl -sS -o /dev/null $T/loans say "GET x2" for i in 1 2; do curl -sS -o /dev/null -X POST -H 'content-type: application/json' \ -d '{"member":"U-1002","isbn":"978-0131103627","issuedAt":"2026-03-10"}' $T/loans; done say "POST x2" for i in 1 2; do curl -sS -o /dev/null -X PUT -H 'content-type: application/json' \ -d '{"title":"The C Programming Language","author":"Ritchie","year":1988,"branch":"S-01","copies":4}' \ $T/books/978-0131103627; done say "PUT x2" for i in 1 2; do curl -sS -o /dev/null -X PATCH -H 'content-type: application/json' \ -d '{"addCopies":2}' $T/books/978-0131103627; done say "PATCH increment x2" for i in 1 2; do curl -sS -o /dev/null -X PATCH -H 'content-type: application/json' \ -d '{"copies":6}' $T/books/978-0131103627; done say "PATCH assignment x2" printf '%-26s %s %s\n' "DELETE x2 (status code)" \ "$(curl -sS -o /dev/null -w '%{http_code}' -X DELETE $T/loans/2)" \ "$(curl -sS -o /dev/null -w '%{http_code}' -X DELETE $T/loans/2)" say "DELETE x2" kill $server
method server 127.0.0.1:8476 start loans=3 copies=1 GET x2 loans=3 copies=1 POST x2 loans=5 copies=1 PUT x2 loans=5 copies=4 PATCH increment x2 loans=5 copies=8 PATCH assignment x2 loans=5 copies=6 DELETE x2 (status code) 204 204 DELETE x2 loans=4 copies=6
Read the lines one at a time.
GET x2 changed no number: the method is safe, so it is idempotent.
POST x2 raised the loan count from three to five: two requests, two records. This is POST’s definition — a new member is added to the collection and the server determines its identity. A POST resent because the network dropped produces a second loan record. This is not a design flaw, it is POST’s nature; its fix is the ninth lesson’s subject.
PUT x2 set the copy count to 4 and left it there. Because the sent body determines the whole record, the second request produces the same final state as the first. How many times it is sent does not change the result.
PATCH increment x2 raised the copy count from 4 to 8. The body carries an addCopies
field, so the new value depends on the old one. This is not a flaw in PATCH; it is proof
that PATCH’s idempotency depends on the body. PATCH assignment x2 used the same method to
produce the value 6, and the second request did not change it.
The design rule this yields is clear: a partial-update body built on assignment rather than increment makes the method idempotent. If an increment is genuinely needed — a counter, a balance, stock — idempotency cannot be achieved through the body; the key mechanism from the ninth lesson is needed.
DELETE x2 returned 204 to both requests, and the loan count dropped from five to four, not dropping further on the second delete. This is idempotency’s definition: the final state is the same. Returning 404 to the second request is also a defensible choice and does not break idempotency, because idempotency is a property of server state, not of the response code. Which one to choose is the next lesson’s subject.
The Choice Between PUT and POST
Both write; what draws the line is who determines the identity.
POST is used when the server determines the identity. The client sends a body to the
/loans collection, the server generates the new record’s identity and reports it in the
response. The client cannot know the target address before sending the request.
PUT is used when the client knows the identity. A PUT to /books/978-0131103627 creates
the resource at that address if it does not exist, or replaces it with the sent
representation if it does. The server above reflects this distinction in the status code
too: 201 if the record did not exist, 200 if it did.
PUT’s second property is often overlooked: fields left out are emptied. PUT is a full
replacement, not a partial update. If the author field is absent from the body, the
record’s author counts as cleared. If this behavior is not wanted, the right method is
PATCH. A service that runs PUT like a partial update does not break the client’s idempotency
assumption, but it makes the contract ambiguous: the same body produces two different
results across two services.
Method Mismatch
If an address exists but does not support the requested method — say, a DELETE arrives at
the /members/U-1001/status resource — the response should not be “not found.” The resource
is right there; the method just cannot be applied. HTTP defines a separate status code for
this and expects the response to report which methods are accepted. The server above does
not make this distinction; it returns 404 to anything that does not match. The next lesson
measures this gap and closes it.
Summary
- A method is a commitment the server makes outward: a safe method leaves no observable change, an idempotent one does not change the final state when repeated.
- A read-shaped address changing data turns every link-following program into something destructive; in the measurement, three GET requests closed two open loan records.
- POST is not idempotent and does not try to be; because the server generates the identity, every request produces a new record.
- PUT is a full replacement and is idempotent; fields left out are emptied, so it is not suited to partial updates.
- PATCH’s idempotency depends on the body: an assignment-shaped body is idempotent, an increment-shaped one is not — in the measurement, the copy count rose from 4 to 8.
- DELETE is idempotent; a second delete request returning a different status code does not break this property, because idempotency is a property of server state.
Next Step
This lesson largely assumed the response status codes: 201 on create, 204 on delete, 404 for everything unmatched. Yet each of these is a choice, and when the choice is made wrong, the client does not notice the error. Does a resource’s absence return the same code as lacking permission to access it? How is a request rejected by a business rule told apart from one with a malformed body? The next lesson asks the same set of scenarios of two different servers, sets the codes they produce side by side, and shows what a wrong mapping produces on the client.
To keep your progress and take notes, Log in
My notes
Log in to take notes.