Ch. 20 · Networking

HTTP Methods, Safety and Idempotence

Safe and idempotent HTTP methods explained by sending POST, PUT, PATCH and DELETE twice to a real server, then making POST retries safe.

~6 min readbeginnerupdated Oct 11, 2026

HTTP methods look like a naming convention, but they’re a promise to everything between your client and your server. Browsers, proxies, client libraries, caches and crawlers all decide what they may do with a request (retry it, cache it, prefetch it) based on its method, without knowing anything about your application. Pick the wrong method and one of them will eventually do something you didn’t expect.

That’s why interviewers ask about safety and idempotence: they’re testing whether you know what other software assumes about your API. This note sends each method twice to a small Node order service and shows what happens.

Two properties: safe and idempotent

RFC 9110 defines both.

A safe method is one where the client isn’t asking for any change on the server. GET, HEAD and OPTIONS are safe. The server can still write a log line or bump a view counter; what matters is that the client didn’t request a change and can’t be held responsible for one.

An idempotent method is one where sending the same request several times has the same intended effect on the server as sending it once. All the safe methods are idempotent, and so are PUT and DELETE. POST and PATCH aren’t idempotent by definition, although a particular PATCH can be.

Method Safe Idempotent Typical use
GET, HEAD, OPTIONS yes yes read, check, discover
PUT no yes create or replace at a known URL
DELETE no yes remove
POST no no create in a collection, run an action
PATCH no not guaranteed partial update

These properties matter because of retries. When a connection drops before a response arrives, the client doesn’t know whether the server acted. For an idempotent request that doesn’t matter: send it again. HTTP clients and browsers are allowed to retry idempotent requests automatically after a connection failure, and they won’t do that for POST.

Sending each method twice

The test server keeps orders in memory. POST adds to the collection, PUT replaces an order, PATCH adds to an order’s quantity and DELETE removes it. Here’s the POST and PATCH handling:

if (req.method === 'POST' && req.url === '/orders') {
  const order = { id: nextId++, item: body.item, qty: body.qty, status: 'placed' };
  orders.set(order.id, order);
  return send(res, 201, order);
}
// ...
if (req.method === 'PATCH') { // a relative change: add to the quantity
  const order = orders.get(id);
  order.qty += body.addQty;
  return send(res, 200, order);
}
JavaScript

POST, twice, with the same body:

curl -s -w '%{http_code}\n' -X POST http://127.0.0.1:8088/orders \
  -H 'content-type: application/json' -d '{"item":"keyboard","qty":1}'
Terminal
{"id":1,"item":"keyboard","qty":1,"status":"placed"}
201
{"id":2,"item":"keyboard","qty":1,"status":"placed"}
201
Text

Two orders. If the first response had been lost and the client retried, the customer would have two keyboards on the way.

PUT, twice, sending the full order:

curl -s -w '%{http_code}\n' -X PUT http://127.0.0.1:8088/orders/1 \
  -H 'content-type: application/json' -d '{"item":"keyboard","qty":2,"status":"placed"}'
Terminal
{"id":1,"item":"keyboard","qty":2,"status":"placed"}
200
{"id":1,"item":"keyboard","qty":2,"status":"placed"}
200
Text

Same state after one call or ten. That’s idempotence.

PATCH, twice, with {"addQty":1}:

{"id":1,"item":"keyboard","qty":3,"status":"placed"}
200
{"id":1,"item":"keyboard","qty":4,"status":"placed"}
200
Text

A relative change compounds. Had the patch said “set quantity to 3” instead of “add one”, it would have been idempotent. PATCH’s behaviour depends on the patch document, which is why the spec doesn’t promise either way. JSON Merge Patch ({"qty": 3}) is naturally idempotent; operation lists that add or increment aren’t.

DELETE, twice:

204
{"error":"not found"}
404
Text

This is the question interviewers like to ask: if the second DELETE returns 404, is DELETE really idempotent? Yes. Idempotence is about the state of the server, not the response. After one DELETE or two, order 2 is gone. The response codes can differ.

One more surprise turned up: curl -I (a HEAD request) got 405 Method Not Allowed, because this hand-written server only matches GET. Frameworks like Express answer HEAD with the GET handler automatically, and the spec expects HEAD to work wherever GET does. Raw servers need you to handle it.

PUT, POST and PATCH in real APIs

The common misconception is that PUT means “update” and POST means “create”. The difference is really about who picks the URL and what the body means.

PUT sends a complete representation to a URL the client already knows, and the server stores it there. If the client leaves a field out, a correct PUT removes it. People use PUT for partial updates all the time, and then wonder why fields vanish or why two clients overwrite each other’s changes. PUT can create, too: PUT /orders/1 on an empty store returns 201.

POST asks the resource at the URL to process the body however it defines. Creating an item in a collection, where the server picks the ID, is the classic case. It’s also the right choice for actions that don’t map to a resource state, like POST /orders/1/refund. A creating POST should return 201 with a Location header pointing at the new resource.

PATCH applies a set of changes. Use it for partial updates, and pick a format (merge patch or JSON Patch) so clients know exactly what they’re sending.

Making POST safe to retry

You can’t change what POST means, but you can make a specific POST endpoint behave idempotently. The client generates a unique key for each logical operation and sends it in a header. The server remembers the key with the result and replays the result if the same key arrives again:

const key = req.headers['idempotency-key'];
if (key && byKey.has(key)) return send(res, 200, orders.get(byKey.get(key))); // replay the first result
// ... create the order, then:
if (key) byKey.set(key, order.id);
JavaScript

Two identical POSTs with Idempotency-Key: 6f1c2a9e:

{"id":3,"item":"mouse","qty":1,"status":"placed"}
201
{"id":3,"item":"mouse","qty":1,"status":"placed"}
200
Text

One order, and both callers got its details. Payment APIs work this way. A production version needs more than a map: store the key in the same database transaction as the order (or behind a unique constraint) so two concurrent requests with the same key can’t both create one, scope keys to the client, expire them after a day or so, and reject a reused key that arrives with a different body.

GET must not change anything

The reverse mistake is a GET that does something, such as an email with a link to GET /orders/1/cancel. Chat apps fetch links to build previews. Email security scanners follow every link in a message. Browsers prefetch. Crawlers follow anything they find. Any of them can cancel the order without a human clicking. Actions that change state belong in POST (with CSRF protection in browsers), and an email link should open a page with a confirm button.

Interview practice

A mobile app’s “Place order” button sends POST /orders. On flaky networks the request sometimes times out, the app retries automatically, and some customers end up with duplicate orders. How would you fix it?

Answer

The duplicates happen because POST isn’t idempotent and a timeout doesn’t tell the client whether the first request was processed. Often it was, and only the response was lost. Turning retries off would trade duplicates for orders customers think failed, so I’d make the operation safe to repeat instead.

The app should generate a UUID when the user taps “Place order”, before the first attempt, and send it as an Idempotency-Key on that attempt and every retry. On the server, I’d store the key alongside the created order with a unique constraint, in the same transaction. If a request arrives with a key that already exists, the server returns the original order instead of creating a new one. If two requests with the same key race, the constraint lets only one insert succeed and the other reads the winner’s result. I’d keep keys for at least as long as the app might retry, and return an error if a key is reused with a different payload. An alternative is letting the client choose the order ID and using PUT /orders/{uuid}, which is idempotent by definition.

Keep reading

More in Networking

read ✓Networking · mid

HTTP Status Codes and Client Decisions

Read HTTP status codes as instructions: retry, fix the request, re-authenticate or check first, with a client decision function and its output.

~7 min readread →
read ✓Networking · mid

Networking: HTTP Redirects

Test 301, 302, 303, 307 and 308 against a local Node server with curl and fetch, and see which codes turn a POST into a GET and why.

~6 min readread →
read ✓Networking · mid

HTTP Connection Reuse and Latency

Measure what keep-alive saves, size a client connection pool with Little's law, and retry safely when a pooled socket was closed under you.

~6 min readread →
esc