Section D · Trust, limits & speed

Caching, Concurrency & Idempotency

Conditional requests buy three things at once. This chapter is HTTP caching with ETags and Cache-Control, optimistic concurrency with If-Match, and idempotency keys that make a retried write safe to send twice.

One idea: validators

Three problems in this chapter look unrelated. A client wants to skip a download it already has. Two clients edit the same link and one erases the other. A client times out on a create and cannot tell if it worked. All three come down to the same question: what state does the client think the server is in, and does the server agree?

HTTP answers with validators and preconditions. A validator is a short token that names one version of a resource. The best one is the ETag. A precondition is a request header that says "do this only if the validator still matches". The server checks the match before it acts. The same ETag serves two jobs, depending on which header carries it.

HeaderSent onMeansOn failurePurpose
If-None-Match: "v7"GETSend the body only if it is not v7304 Not ModifiedCaching
If-Match: "v7"PATCH, PUT, DELETEApply this write only if it is still v7412 Precondition FailedConcurrency
Idempotency-Key: <uuid>POSTRun this write at most onceStored response replayedRetries

The first two read opposite ways. If-None-Match asks "tell me only if it changed". If-Match asks "act only if it did not change". Mix them up and a write silently skips its check.

The scope here is the control plane of Snip, the API that creates and edits links at https://api.snip.ly/v1. The data plane, the redirect that millions of people hit, has its own caching with CDNs and Redis. That is a different problem with different tools, covered in Designing for Scale: Caching. Here the cache sits between an SDK or dashboard and the API itself.

Caching the control plane

An HTTP cache stores a response and reuses it under rules the response states. The server states them in Cache-Control. Four directives carry most of the weight.

  • max-age=N: the response is fresh for N seconds. A cache serves it without asking the server.
  • private or public: private lets only the end client cache it (a browser or an SDK). public also lets shared caches such as a CDN or corporate proxy keep it.
  • no-cache: store it, but ask the server before every reuse. The name misleads.
  • no-store: do not keep it at all.

Add must-revalidate to forbid serving a stale copy when the server cannot be reached. Without it, a cache may serve stale data in some cases, which is the wrong default for anything a user might act on.

The rule that causes real incidents: an authenticated response must never be public. A shared cache would key it by URL and hand one customer's link data to the next caller. Snip's responses depend on the bearer token, so they carry private, and Vary: Authorization as a second guard.

What to cache in Snip:

  • GET /v1/links/{code}: yes. A link changes rarely and the shape is small. Cache-Control: private, max-age=30, must-revalidate plus an ETag. Thirty seconds is short enough that a dashboard shows its own edits quickly.
  • Click totals: cache briefly or not at all. total_clicks is eventually consistent, so a 5 to 10 second max-age costs nothing the data was not already costing.
  • Responses that contain secrets, such as a newly issued API key: no-store.
  • Any write response: none of it is reusable. Leave Cache-Control off, or send no-store.

A short max-age has a cost you should name in the docs. For those 30 seconds a client can read its own stale write. If a client PATCHes a link and then GETs it, it may see the old destination. Two fixes: the client updates its local copy from the PATCH response (which returns the new representation), or sends Cache-Control: no-cache on the read that follows a write. Prefer the first.

Freshness is a promise you cannot recall. Once a response goes out with max-age=3600, you cannot reach into a thousand caches and revoke it. Start short. Lengthen it only when you have measured that the revalidation traffic hurts.

The conditional GET

When max-age runs out, the cache does not have to throw the copy away. It holds a validator, so it can ask: "I have version "a41c". Is it current?"

GET /v1/links/q7Zk2 HTTP/1.1
Host: api.snip.ly
Authorization: Bearer sk_live_…
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: private, max-age=30, must-revalidate
ETag: "a41c"
Vary: Authorization

{"code":"q7Zk2","url":"https://example.com/spring-sale","short_url":"https://snip.ly/q7Zk2",
 "created_at":"2026-09-30T14:02:11Z","tags":["promo"],"total_clicks":1840}

Later, after the 30 seconds, the client revalidates:

GET /v1/links/q7Zk2 HTTP/1.1
Host: api.snip.ly
Authorization: Bearer sk_live_…
If-None-Match: "a41c"
HTTP/1.1 304 Not Modified
Cache-Control: private, max-age=30, must-revalidate
ETag: "a41c"

No body. The client keeps its copy and the freshness clock restarts. If the link had changed, the server would send a full 200 with a new ETag.

Be honest about what this saves. A 304 saves bandwidth. It does not by itself save the database read, because the server must still learn whether the resource changed. To save the lookup too, keep the current ETag where it is cheap to read. The simplest way is a version column on the row, bumped on every write, and the ETag is a hash of it. Then the check is an index lookup and the server skips serialising the body. If your ETag is a hash of the full response, you compute the whole response to produce it, and you save only the bytes on the wire. For a 400-byte link that is nearly nothing. Conditional GET pays off on big payloads such as a link list with 1,000 rows.

Last-Modified, the weaker option

Last-Modified with If-Modified-Since does the same job with a timestamp. It has one-second resolution, so two edits inside the same second look identical. It also ties you to a clock. Use it when you already have an updated_at column and cannot build an ETag. Otherwise use the ETag. If both are present, a server must check If-None-Match and ignore If-Modified-Since.

Strong and weak

An ETag is strong ("a41c") when two responses with the same tag are byte-for-byte equal. It is weak (W/"a41c") when they are equivalent in meaning but may differ in bytes, for example after a proxy recompresses the body. Caching can use weak tags. If-Match must use the strong comparison, which a weak tag never passes. If you want write protection, issue strong tags.

The lost update

Ana and Ben both open link q7Zk2 in the Snip dashboard. It points at spring-sale and carries the tag promo. Ana changes the destination to the summer page. Ben, whose copy is older, adds a tag. Both clients send a PATCH.

The trouble depends on how the client builds the request. A well-behaved PATCH sends only the changed field, and then the two edits merge cleanly. But dashboards and SDKs often send the whole object back, because that is the simplest code to write. Ben's request then carries the old url. The server applies it, and Ana's change vanishes. Neither request failed. No log line says anything went wrong. The last write wins, and nobody is told they lost.

The problem is the gap between read and write. Locks do not fix it in HTTP, because the gap is a human thinking for ten minutes, and a lock held across a user's coffee break is a bug. The usual fix is optimistic concurrency: let everyone read, and make each write prove it saw the latest version.

If-Match and 412

The ETag from the earlier GET is the proof. The client sends it back on the write:

PATCH /v1/links/q7Zk2 HTTP/1.1
Host: api.snip.ly
Authorization: Bearer sk_live_…
Content-Type: application/json
If-Match: "a41c"

{"url":"https://example.com/summer-sale"}

Ana goes first. The server sees the stored version still matches, applies the change, and returns the new representation with a new tag:

HTTP/1.1 200 OK
ETag: "b7e2"

{"code":"q7Zk2","url":"https://example.com/summer-sale", …}

Ben's request carries "a41c", which no longer matches. The check and the write must be one atomic step, for example UPDATE links SET … , version = version + 1 WHERE code = $1 AND version = $2. If it touches zero rows, the precondition failed. A read-then-write in application code reopens the race you are closing.

HTTP/1.1 412 Precondition Failed
Content-Type: application/problem+json
ETag: "b7e2"

{
  "type": "https://api.snip.ly/errors/precondition-failed",
  "title": "The link changed since you read it",
  "status": 412,
  "detail": "If-Match \"a41c\" does not match the current version.",
  "code": "q7Zk2"
}

The client's job is to re-read, show the user the conflict or merge it, and retry with the new tag. Returning the current ETag on the 412 saves one round trip for clients that merge automatically. The error body follows the format from Errors & Validation.

428: make the check mandatory

Optional protection protects nobody, because the lazy client skips it. If a lost update on your resource is a real harm, reject writes that omit the header with 428 Precondition Required. Snip does this on PATCH /v1/links/{code}. The 428 body should say how to fix it: "GET the link and send its ETag in If-Match."

Do not confuse 412 with 409. A 412 says a precondition the client named has failed. A 409 Conflict says the request clashes with current state in a way the client did not guard against, for example creating a custom code that already exists. Errors & Validation covers when to use each.

ETag or a version field in the body?

You can do the same with {"version": 7} in the JSON and a check on write. It works, and some teams prefer it because the number is visible. It has three costs. Every client must learn your field name, where If-Match is standard and many HTTP libraries and gateways already understand it. A version in the body mixes server state with client input, so a client can send the wrong value by accident. And it does not help for DELETE, which has no body. Use the header. Keep a version column in the database and derive the tag from it.

Same validator, two directions. The ETag you served for caching in the last section is the one you now require for concurrency. You build the mechanism once.

Idempotency keys

Conditional requests protect an existing resource. Creation has no version to match yet, so it needs a different guard.

A client sends POST /v1/links and the connection drops before the response arrives. The link may exist. It may not. The server could have crashed before the insert, or after the insert and before the reply. The client cannot tell which. Retrying risks two links for one intent. Not retrying risks none. With payments, that is a double charge. With Snip, it is a duplicate short link, which pollutes analytics and burns a custom code.

The cause is the method. As Resource Modeling explains, GET, PUT and DELETE are idempotent by definition: doing them twice leaves the same state as doing them once. POST is not, and neither is a PATCH that increments something. The fix is to give the client a way to say "this is the same attempt" so the server can recognise it.

POST /v1/links HTTP/1.1
Host: api.snip.ly
Authorization: Bearer sk_live_…
Content-Type: application/json
Idempotency-Key: 8f14e45f-ceea-467a-9575-0f2c7a5d3b1e

{"url":"https://example.com/spring-sale","tags":["promo"]}
HTTP/1.1 201 Created
Location: /v1/links/q7Zk2

{"code":"q7Zk2","url":"https://example.com/spring-sale","short_url":"https://snip.ly/q7Zk2", …}

The connection drops. The client retries with the same key and same body:

HTTP/1.1 201 Created
Location: /v1/links/q7Zk2
Idempotency-Replayed: true

{"code":"q7Zk2","url":"https://example.com/spring-sale","short_url":"https://snip.ly/q7Zk2", …}

Same status, same body, same code. Nothing new was created. The Idempotency-Replayed header is a convenience for debugging and not required.

What the server does

  1. Read the key. Scope it to the caller's account, so two customers cannot collide.
  2. Look it up. If there is no record, insert one as in progress with a hash (the fingerprint) of the method, path and body. Do this with a unique constraint so two simultaneous requests cannot both win.
  3. Run the handler. Store the status and body against the key.
  4. On a later request with the same key, compare fingerprints, then answer as below.
Same key, and…Server responds
Finished earlier, same fingerprintThe stored response, replayed
Finished earlier, different body422 Unprocessable Content. The client reused a key for a different request, which is a bug
Still running409 Conflict, with Retry-After. Do not start a second copy
Older than the windowTreated as a new key

Keep records for a fixed window. 24 hours is common and Snip uses it. The window must outlast any realistic retry loop. Document it, because a retry after 25 hours creates a duplicate and the client must know that.

Three design choices matter more than they look.

  • The client generates the key. A UUID v4 per intent, not per attempt. The retry loop must reuse the key. An SDK that makes a fresh key on each try gives you nothing.
  • Store the outcome, including errors, once the handler ran. If a first attempt returned 500 after a partial write, replaying the 500 is honest. Do not store failures that happened before any work, such as a 401 or a validation error, because the client should be free to fix the request and reuse the key.
  • Make key write and business write one transaction. If the link insert commits and the key record does not, the next retry creates a duplicate. Put both in the same database transaction, or build the record into the insert itself.

Which methods need it

POST always, when a duplicate would hurt. Non-idempotent PATCH operations such as "add 1 to a counter" too. PUT and DELETE do not: a repeated DELETE /v1/links/q7Zk2 leaves the link gone either way. The first returns 204; the second may return 404, which is fine, since the state is the same. Accept the header on them anyway if it makes SDKs simpler, but do not make clients depend on it.

Accept the key as optional on POST and say so loudly in the reference. Requiring it breaks every existing client the day you ship it. If the cost of duplicates is high, as with payments, require it for new API versions.

Safe retries, end to end

A retry is safe only when three separate pieces work together, and each lives in a different chapter.

  1. The request can be repeated. That is this chapter: idempotent methods, or Idempotency-Key on POST, and If-Match so a retried PATCH after a lost response fails with 412 and not a second silent change.
  2. The error says whether to retry. A 503 or a dropped connection: retry. A 422 or a 404: never. Errors & Validation shows how the problem body marks this.
  3. The retry does not become an attack. Clients wait with exponential backoff and random jitter, and honour Retry-After on a 429. Without jitter, every client that failed at 12:00:00 retries together at 12:00:01. Rate Limits & Quotas covers the schedule.

Remove any one and the others fail. Idempotency without backoff turns a brief outage into a stampede. Backoff without idempotency spreads duplicates over a longer time.

The burden also flows the other way. When Snip sends a webhook to a customer, delivery is at-least-once: if the customer's server answers slowly or errors, Snip sends again. Snip is now the retrying client, and the customer is the server that must tolerate repeats. They need their own idempotency, typically a stored event ID checked before acting. Async: Webhooks & Streaming picks this up.

Idempotency is not deduplication by content. Two different users who create a link to the same URL should get two links. The key identifies one attempt, not one URL. If you want one link per URL, that is a uniqueness rule in the data model, and it returns 409.

✓ Check yourself

Try each before you open the answer.

  1. A client sends GET /v1/links/q7Zk2 with If-None-Match: "a41c" and gets a 304. What did the server avoid, and what might it still have done?

    Show answer

    It avoided sending the body. It may still have read the row and built the whole response to compute the ETag. To skip the read, keep a version number where it is cheap to fetch and derive the tag from it. See The conditional GET.

  2. Ana and Ben both edit one link. Their SDK sends the whole object on each PATCH. Walk through what happens without If-Match, and then with it.

    Show answer

    Without it, the second write overwrites the first with old values and nobody sees an error. With it, the second request carries a stale tag, the conditional update touches zero rows, and the server returns 412. Ben re-reads, merges and retries. See The lost update and If-Match and 412.

  3. Why must the version check and the write be a single statement?

    Show answer

    If application code reads the version, compares, then writes, another request can slip between the read and the write. Both pass the check and one update is lost again. A single UPDATE … WHERE version = $n makes the database do the compare and write atomically. See If-Match and 412.

  4. A client reuses one Idempotency-Key for two POST requests with different URLs. What should the server return, and why not just replay the first response?

    Show answer

    422. The stored fingerprint no longer matches the body. Replaying the first response would tell the client its second link was created when it was not, which hides a client bug. See Idempotency keys.

  5. Why does an idempotency key not protect you if the SDK generates a new key on every retry?

    Show answer

    The server sees each attempt as a new intent and creates a link each time. The key must name the intent, so the client makes it once, before the first try, and reuses it on every retry. See Idempotency keys.

  6. A retry loop has idempotency keys and backoff with jitter but treats every non-2xx as retriable. What goes wrong?

    Show answer

    It keeps retrying errors that can never succeed, such as 422 or 404, wasting quota and delaying the real failure report. The loop must read the error and retry only transient ones. See Safe retries, end to end.

Takeaway

  • One validator, the ETag, serves two jobs: If-None-Match for caching and If-Match for concurrency. They guard opposite outcomes.
  • Authenticated responses are private, never public. Start with a short max-age; you cannot recall a long one.
  • A 304 saves bytes, not the lookup, unless the version is cheap to read.
  • Silent last-write-wins is a bug. Require If-Match on writes (428 when absent), answer stale ones with 412, and do the check inside the write statement.
  • Make POST retry-safe with a client-made Idempotency-Key: replay the stored response, 422 on a changed body, 409 while in flight.
  • Safe retries need all three parts: idempotency, error classification and jittered backoff.

Next, work that outlives one request. Async: Webhooks & Streaming →

Further reading