Skip to content
Chapter 11Lesson 1

Methods and the safe-to-retry contract

Your first lesson on HTTP semantics, where the methods GET, POST, PUT, PATCH, and DELETE become a reasoning tool for deciding which requests are safe to retry.

An HTTP method is more than a label on the request. Each method declares an intent, and the CDN, the browser’s prefetcher, and every retry policy in the stack reads that intent to decide what it is allowed to do with the request. Pick the wrong method and the request still works: the bytes travel and the server answers. What breaks is the contract every downstream layer was built to honor, and that kind of mismatch is what turns into a duplicated charge in production.

This lesson covers four things. The first is safety and idempotency, the two properties that decide whether a network blip is recoverable. The second is the five-method palette (GET, POST, PUT, PATCH, DELETE) arranged in a 2x2 grid by those properties, so you can reason about methods instead of memorising them. The third is PUT versus PATCH for updates, a distinction that trips up most people learning it. The fourth is the Idempotency-Key header, which fixes the one cell of the grid where nothing built into the method itself can save you. By the end you’ll pick a method by reasoning rather than by lookup, and you’ll know exactly why a retried POST can charge a card twice.

The previous chapter walked the wire end to end. Its fourth stage was the HTTP request, whose first line has the shape METHOD path HTTP/version: three tokens that ride on top of every QUIC stream. This lesson is about the first of those tokens, the method.

Why does the method matter beyond “POST has a body, GET doesn’t”? Because three layers between the client and your server read it and act on it before your code ever runs.

  • The browser’s link-rel-prefetch and prerender machinery follows GETs speculatively when it thinks the user is about to navigate. It never speculates on POST.
  • CDNs cache GET responses by default and bypass POST entirely. The default cache key is (method, URL, Vary headers), so a POST falls out of the cache before the question of TTL is even asked.
  • HTTP clients (the browser, fetch, server-side SDKs) automatically retry GET-shaped requests on a network error and refuse to retry POSTs. In some libraries that refusal is load-bearing: they won’t retry a POST even if you ask them to.

Those three behaviors are what a mismatched method costs you. A GET that writes will be retried on a network blip, prefetched on hover, and cached at the edge, which means three duplicated writes from one user action. A POST that reads bypasses every cache layer and re-runs the same server work on every navigation. The wrong verb doesn’t just read oddly; it breaks every layer that trusted the contract.

Safety and idempotency, the two anchor properties

Section titled “Safety and idempotency, the two anchor properties”

Two definitions sit underneath everything else in this lesson. Once they are clear, the 2x2 grid in the next section falls into place, and so does most of what follows.

A method is safe if calling it has no observable side effect on server state. The word observable is what matters most here. The server is allowed to log the call, warm a cache, or increment a metric counter; none of those count as observable side effects, because none of them change what a client would see on the next request. The test is what the client perceives, not what the server’s internals do. A read endpoint is safe. A “send welcome email” endpoint is not, no matter how innocent its name sounds.

A method is idempotent if making the same request N times leaves the server in the same final state as making it once. The key word is state, not response. A DELETE /invoices/42 is idempotent: call it once and the invoice is gone; call it five times and the invoice is still gone. The first call may return 200 OK and the next four may return 404 Not Found, so the responses differ but the final state is the same. A POST /invoices that creates a new invoice on each call is not idempotent: five calls leave you with five invoices.

This is the most common misread of idempotency in production code. People hear “idempotent” and remember “returns the same response,” so when DELETE returns 404 on the replay they decide the method isn’t idempotent after all. They’re checking the wrong thing. Idempotency is a property of the server state, not of the response.

Safety

No observable change to server state.

Test Any observable server-state change? No → safe.

Idempotency

Same final state after N calls as after one.

Test Same final state after N calls as after one? Yes → idempotent.

The two anchor properties, each with a one-line definition and the test that tells you whether a method has it.

Why these two properties and not others? Because each one unlocks a different downstream behavior. Safety is what caches and prefetchers act on: if a method is safe, those layers can speculate freely. Idempotency is what retry policies act on: if a method is idempotent, the network layer can retry on a transient error without asking permission. Together they map the four cells of the grid the next section builds.

Two properties with two states each give four cells. Place each of the five methods in the cell its properties pick out, and the relationships between them become easy to see.

Idempotent
Not idempotent
Safe
GET

Load an invoice.

no occupants
Unsafe
PUT PATCH DELETE

Replace, set status to ‘paid’, delete the invoice.

POST

Charge a card.

The five-method palette placed by property. The grey cell has no occupants, because there's no use case for a read that produces different state each time. The red cell is the one that needs Idempotency-Key to become retry-safe.

Take the four corners in order. The top-left is GET, the only safe method in the everyday toolkit, which is why it’s also the only method that caches, prefetchers, and retry layers can act on freely. HEAD is safe and idempotent too and lives in the same cell; it’s GET without the response body, used to read metadata like content length or last-modified date without paying for the payload. You’ll see it again later in the course when uploading to object storage. OPTIONS is also safe; it’s what the browser sends as the CORS preflight, covered in the next chapter.

The top-right is empty, and that emptiness is informative. No standard method is safe but not idempotent, and there isn’t a use case for one either. A “read” that produces different state on each call isn’t really a read. If you find one in your design, you’ve named it wrong.

The bottom-left holds PUT, PATCH, and DELETE. They’re unsafe, because they change server state, but they’re idempotent: retry them on a network blip and the final state lands in the same place as if the original had succeeded. That’s the property the retry layer can act on. PATCH lives in this cell only when its diff is absolute (set this field to this value); a relative diff (increment this field by one) breaks idempotency, which we’ll come back to shortly.

The bottom-right holds POST alone. POST is unsafe and not idempotent: retry it and you get duplicate work. Nothing about the method or the underlying transport makes a POST retry safe. The whole second half of this lesson is about closing that hole.

Place each method in the cell it belongs to. One cell stays empty — that's intentional. Drag each item into the bucket it belongs to, then press Check.

Safe + Idempotent
Safe + Not idempotent
Unsafe + Idempotent
Unsafe + Not idempotent
GET
PUT
PATCH (absolute diff)
DELETE
POST

PUT and PATCH share a cell on the grid and a job in the design: both update an existing resource. They send very different things on the wire, though, and confusing the two is a steady source of small update bugs.

PUT replaces. You send the full resource body and the server overwrites whatever it had with what you sent. The mental model is assignment, =. Sending the same body twice yields the same final state by construction, which is why PUT is idempotent without needing any qualifiers.

PATCH applies a diff. You send a description of what to change and the server applies it. The mental model is Object.assign(current, patch) for the merge case, or “run this list of operations” for the json-patch case. PATCH is idempotent only when the diff is absolute: status = 'paid' re-runs to the same place no matter how many times you send it. A relative diff like balance = balance + 100 is not idempotent, because every call adds another 100. So when a reference says “PATCH is idempotent,” read it as shorthand for “PATCH with an absolute diff is idempotent.”

PATCH has a second problem PUT doesn’t: the wire format isn’t determined by the method. PUT’s body is the resource, which is well-defined. PATCH’s body is a description of changes, and there are two standardised ways to describe changes, with different content types signalling which one the server should expect. Here are both, applied to the same logical operation: marking invoice 42 as paid and removing its draft note.

PATCH /invoices/42 HTTP/3
Content-Type: application/merge-patch+json
{ "status": "paid", "draftNote": null }

The default 2026 choice. JSON Merge Patch (RFC 7396) lets you send a partial JSON object: keys overwrite, and an explicit null deletes a key. Its one limitation is that arrays can’t be merged element by element, so to change one item in an array you have to send the whole new array. Reach for it when your patches are field-level on object-shaped resources, which describes most SaaS update endpoints.

Merge-patch is the default, the one to reach for unless you have a reason not to. Json-patch is the escape hatch for when the diff has to encode operations merge-patch can’t express. Most SaaS update endpoints, including the invoicing endpoints you’ll build later in the course, land on merge-patch.

A wallet endpoint receives PATCH /wallets/42 with the body { "delta": 5 }, and the server adds 5 dollars to the current balance on every call. Is this endpoint idempotent?

Yes — PATCH inherits idempotency from the HTTP spec.
Yes — the body is a partial JSON object, which is the merge-patch shape.
No — the body describes a relative change, so each call shifts the final state by another 5.
It depends — only if the server sets Content-Type: application/merge-patch+json on the response.

Picture the sequence of events. A user clicks “Pay” on an invoice, and the client sends POST /payments/charge with { amount: 5000, currency: 'usd', source: 'tok_...' }. The server receives the request, charges the card through Stripe, writes the payment row to the database, and builds a 200 OK response. Then the response packet gets dropped on a flaky WiFi connection. The server completed every side effect, but the client never saw the answer.

What happens next is a retry, either from the client’s network layer or from the user hitting “Pay” again because the spinner never went away. The server gets a second POST /payments/charge with the same body. From the server’s perspective this is a brand-new request with no context, so it charges the card a second time. The customer is now down 100 dollars instead of 50, and you have a refund ticket to process before the day is over.

GET-style retry doesn’t fix this. GET retries are safe because the method is safe: the server’s reaction to a duplicate GET is to send the same answer again. POST is unsafe by construction, and nothing about the method or the transport makes its retry safe. The fix has to live at the application layer.

The pattern works like this. The client generates a stable identifier per logical operation, one ID per “the user wants to pay this invoice” rather than one per HTTP attempt, and sends it as an Idempotency-Key header. The server stores (key, response) before sending the response back. On a retry with the same key, the server recognises the replay, returns the cached response, and runs no business logic at all. Same logical operation in, same response out, exactly one side effect.

The figure below walks through it one step at a time. Each step shows the same client-server pair as a two-column mini diagram on its own slide, and the slider advances through the three slides.

Client
Server
POST /charge Idempotency-Key: ab12c3d4...
Process charge.
Store (ab12, resp).
200 OK
dropped
First attempt: the client sends POST with an Idempotency-Key. The server processes the charge, persists (key, response), and sends 200, but the response packet is dropped, so the client never sees it. The server has done the work and remembered it.
Client
Server
POST /charge Idempotency-Key: ab12c3d4...
Same key. No new UUID.
The client retries with the same key. The contract works because retries of the same logical operation share one key, generated per operation rather than per attempt.
Client
Server
Look up ab12.
Found cached response.
200 OK
No charge re-run.
Card charged exactly once.
The server looks up the key, finds the cached response, and returns it without re-running the charge. The card is charged exactly once.

Three things are worth nailing down before moving on. The first is where the key comes from. The client generates a UUID per operation, one per “user clicked Pay on invoice 42,” not one per HTTP request. The call site is crypto.randomUUID(), available on the global crypto object in every modern runtime. (You’ll meet UUIDv7 in the database unit as the primary-key default; the idempotency key is a UUIDv4 instead, because what you want here is randomness and opacity, not time-ordering.) The key has to live with the operation, not with the request, so that a retry uses the same key the original sent. In a React app that often means storing the key on a mutation handle and reusing it across attempts.

The second is where the key lives on the server. The server needs a (key, response, expires_at) store with a unique constraint on the key column. When a request arrives, the server looks the key up; if it finds it, it returns the cached response; if not, it processes the request, stores the result, and then sends the response. The unique constraint catches the race between two simultaneous retries trying to be the first to store the result. The full implementation, covering schema, TTL, unique-constraint race handling, and the transactional shape, lands later in the course when you build the Stripe webhook ingestion path. Here the contract is what matters: the server stores the key before sending the response, and looks it up on every incoming request.

The third is where the standard is. Idempotency-Key is on track to become a Standards-Track RFC: the IETF httpapi working group draft draft-ietf-httpapi-idempotency-key-header-07 (October 2025) is the current shape, and Stripe, PayPal, and most payment processors already deploy it. The header name and value format aren’t going to change between draft and RFC, so you can build against the contract now without worrying about renaming later.

Here’s the client side end to end. This is the call shape you’ll be writing later, once fetch is covered properly in this unit and the billing unit wires up Stripe.

const idempotencyKey = crypto.randomUUID();
const response = await fetch('/api/payments/charge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({ amount: 5000, currency: 'usd' }),
});

The key is generated once per logical operation. Store it on the operation handle, such as a React Query mutation key, a form ref, or a state value, so that every retry of the same operation reuses it. Generating a fresh key inside the fetch retry loop is the most common way to defeat the whole pattern.

const idempotencyKey = crypto.randomUUID();
const response = await fetch('/api/payments/charge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({ amount: 5000, currency: 'usd' }),
});

The header rides on the request. The name is fixed by the draft, and the value is your UUID. Both must travel together on every retry; drop the header on a retry and the server treats it as a fresh operation.

const idempotencyKey = crypto.randomUUID();
const response = await fetch('/api/payments/charge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({ amount: 5000, currency: 'usd' }),
});

crypto.randomUUID() returns a UUIDv4: random, opaque, and unique enough to be safe against collisions. The call lives on the global crypto object, part of the Web Crypto API covered later in this unit.

1 / 1

The whole property model, covering safety, idempotency, the 2x2, and the Idempotency-Key, meets code at one surface: the Next.js route handler. In the App Router (covered properly later in the course), a route.ts file under app/api/ exports an async function named after each HTTP method it handles. The framework reads the request line, finds the function matching the verb, and calls it. The function name is the contract.

src/app/api/invoices/[id]/route.ts
import type { NextRequest } from 'next/server';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
// load and return the invoice
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
// apply a merge-patch and return the updated invoice
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
// delete the invoice
}

One file, one route, three handlers. The framework dispatches GET /invoices/42 to the GET function, PATCH /invoices/42 to PATCH, and so on. A POST arriving at this route, when no POST is exported, gets an automatic 405 Method Not Allowed. The verb names the contract and the handler implements it, and the exported function name is where the two line up. Full coverage of route handlers, including the validation pipeline and the authedRoute wrapper that runs the auth and permission checks, comes later in the course.

One special case is worth a note. The HTTP spec doesn’t forbid a GET request from carrying a body; older drafts allowed it and the current spec is silent rather than prohibitive. But intermediaries such as proxies, CDNs, and some HTTP libraries silently strip GET bodies, and the request semantics ignore them anyway. In practice, a GET body won’t survive the journey. If your read needs structure, such as filters, sort columns, or pagination, encode it in the query string, which the next chapter covers as the URL surface, including URLSearchParams.

Here are four claims to apply the property model against. Each one checks whether you’re reasoning about state and contract rather than pattern-matching method names.

Mark each claim about HTTP methods and retries True or False. Mark each statement True or False.

Calling DELETE /invoices/42 twice in a row makes DELETE non-idempotent, because the second call does less work and returns a different status code.

Idempotency is about state, not response or work. After both calls, invoice 42 is gone — same final state. The fact that the second call may return 404 instead of 200 doesn’t break the property; the response can differ across calls, the state cannot.

A PATCH endpoint that accepts { "balance": 100 } and overwrites the balance field is idempotent.

Absolute set. Repeating the call lands at the same final state every time. The relative-diff variant { "delta": 100 }, on the other hand, accumulates — five calls add 500, which breaks idempotency.

Generating a fresh Idempotency-Key for each network retry of the same charge guarantees the card is only charged once.

Exactly opposite. The whole point is that retries share the same key, so the server recognises the replay. A new key per attempt makes every retry look like a brand-new operation, and the server happily runs the charge again.

A POST endpoint that only logs the request and never mutates business state still cannot be retried safely without an idempotency mechanism.

POST is unsafe by contract — the cache, retry, and prefetch layers treat it as side-effecting whether or not the implementation actually is. Method choice declares intent; the implementation does not override the contract every downstream layer signed.

This lesson covered the method line of the request. The rest of the message envelope is covered elsewhere.

  • Status codes, meaning the 1xx/2xx/3xx/4xx/5xx split and the Problem Details (RFC 9457) error-body shape, are the next lesson in this chapter.
  • The header surface beyond Idempotency-Key, covering authentication, caching, content negotiation, and security, is the lesson after that.
  • The full route handler implementation, error helpers, and the server-side (key, response) store come in the App Router unit and the Stripe webhook chapter later in the course.
  • Server Actions, the form-submission surface that supersedes most public POST endpoints in this stack, land in the forms unit.
  • OPTIONS and CORS preflight are the next chapter, and HEAD for object-size reads is the object-storage unit.