Consistency as a Contract Clause
A client POSTs a resource, then GETs it — and gets a 404. Nothing is broken unless the contract said otherwise. Read-after-write, monotonic reads and staleness bounds are promises consumers build UI and logic on, so they must be written down.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
The 404 that isn't a bug
The sequence is almost a reflex: POST /orders returns 201 with "id": "ord_91", the client immediately calls GET /orders/ord_91 — and receives 404. If reads are served from a replica that has not yet applied the write, from a search index that ingests asynchronously, or from a cache holding the pre-write world, the 404 is the system working as built. Whether it is *working as promised* depends entirely on whether anything was promised.
This is where infrastructure reality leaks into the contract. The moment your read path involves replicas (Replication and Read Scaling), asynchronous indexing, or caching layers (Caching as a Contract Clause), "a successful write is visible to subsequent reads" stopped being automatically true — and consumers cannot see your architecture. They see a 201 followed by a 404 and file a bug. Silence about consistency does not spare consumers the complexity; it converts the complexity into flaky integrations, retry loops discovered by trial and error, and sleep(2) calls in partner code that break when your replication lag changes.
The clauses worth naming are few and concrete. Read-your-writes: a client that completed a write sees it in its own subsequent reads. Monotonic reads: a client never sees data older than data it has already seen (no flickering back in time between load-balanced replicas). Bounded staleness: reads may lag writes by at most N seconds, stated per surface. Each is a real promise with a real infrastructure cost — and each is checkable by a consumer, which is what makes it a contract clause rather than an aspiration.
POST /orders HTTP/1.1
{ "items": [ … ] }
# immediately after the 201:
GET /orders/ord_91 HTTP/1.1HTTP/1.1 201 Created
{ "id": "ord_91", "status": "created" }
# then:
HTTP/1.1 404 Not Found
# Docs, if honest:
# "GET /orders/{id} is read-your-writes for the
# creating client. GET /orders (list) and
# /search may lag writes by up to 10s."Different surfaces, different honest promises
Consistency is not one dial for the whole API. The primary read (GET /orders/{id}) is usually cheap to make read-your-writes — serve it from the primary, or pin the writing session to fresh reads. Derived surfaces are a different economy: a search index (Search Is a Different Contract Than Filtering), an analytics rollup, a list with denormalized joins — forcing those synchronous would put index building on the write path and make every POST pay for it in latency and coupling. The honest contract makes the *split* explicit: strong where consumers act on their own writes, eventual-with-a-bound where they browse.
The bound matters more than the label. "Eventually consistent" is unfalsifiable and therefore useless to a consumer — eventually might be 50ms or an hour, and code cannot branch on "eventually". "Search reflects changes within 30 seconds under normal operation" is testable, monitorable, and designable-against: the consumer knows to read the primary endpoint for confirmation flows and tolerate the lag in browse flows. If you cannot state a bound, state the mechanism ("index updates are triggered per write and typically complete in seconds; there is no upper bound during reindexing") — honesty about the shape beats a fake number.
Cross-resource ordering is its own clause. A consumer that sees invoice.created may assume the referenced order is readable; if events and resources propagate independently, that assumption fails intermittently (Webhook Ordering: Assume None treats the event-stream case). Either promise the order ("an event is emitted only after the resource is readable") or document the fetch-on-miss pattern consumers need.
| Read surface | Typical honest promise | What the consumer builds on it |
|---|---|---|
| GET /orders/{id} (primary read) | Read-your-writes for the creator | Redirect-after-create, create-then-configure scripts |
| GET /orders (filtered list) | Bounded staleness, e.g. ≤ 10s | Refresh UX; no assertion that a new item appears instantly |
| GET /search?q=… (index) | Bounded staleness, e.g. ≤ 30s; no bound during reindex | Browse flows; never confirmation flows |
| Webhook / event stream | Emitted after the resource is readable — or explicitly not | Whether handlers can GET on event or must retry (Webhooks: The Inverted Contract) |
| Analytics endpoints | Snapshot semantics, refreshed hourly | Dashboards labeled with data-as-of timestamps |
Writing the clause instead of leaking the architecture
The failure mode on the docs side is describing infrastructure instead of promises: "reads may hit replicas" tells a consumer nothing actionable and freezes your architecture into the contract. The clause should name the guarantee and stay silent about the mechanism, so you can later swap replicas for caches for a new datastore without renegotiating (What an API Contract Actually Is).
Two contract tools make the guarantees concrete on the wire. Returning the full resource in the write response removes the most common reason for the immediate re-read — the client already has the state, no read-after-write required. And a freshness signal on lagging surfaces (data_as_of timestamp, or the write response returning a token the consumer can send to demand at-least-this-fresh reads) turns staleness from a trap into a value the consumer can display or branch on.
1Docs: "For scalability, our API uses read2replicas and a distributed search cluster.3Data is eventually consistent."4 5# Consumer questions with no answer:6# - can I GET what I just POSTed? (unknown)7# - how stale is the list? (unknown)8# - is the lag bounded? (unknown)9# Partner code now contains sleep(2) —10# calibrated to today's replication lag.1Docs, per surface:2 POST responses return the complete resource —3 no follow-up GET needed to render it.4 5 GET /orders/{id}: read-your-writes for the6 creating credential.7 8 GET /orders, /search: may lag writes ≤ 30s;9 responses include "data_as_of".10 11 Events: emitted only after GET /orders/{id}12 would succeed for the referenced resource.The good version answers every question a consumer actually has, is testable clause by clause, and never mentions replicas — so the infrastructure can change under a stable promise. The bad version constrains the provider and still leaves the consumer guessing.
Key points
- POST-then-404 is not a bug unless the contract promised otherwise — and if the contract is silent, consumers will treat it as one.
- Name the clauses: read-your-writes, monotonic reads, bounded staleness — checkable promises, not the unfalsifiable "eventually consistent".
- Consistency is per surface: primary reads can promise read-your-writes cheaply; search and analytics honestly promise a bound instead.
- Return the full resource in write responses — it eliminates most immediate re-reads outright.
- Promise guarantees, not architecture: "replicas" in the docs freezes your infrastructure; "≤ 30s staleness" leaves it free.
- Undocumented consistency becomes sleep(2) in partner code, calibrated to today's lag and broken by tomorrow's.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Team → infrastructure: adds read replicas and an async search index for load; the docs don't change.
- 2Consumer → API: POSTs an order, redirects the user to the order page; the page GETs the order and 404s intermittently.
- 3Consumer → workaround: adds a retry-with-delay tuned by experiment to current replication lag; the bug report is closed as "cannot reproduce".
- 4Team → infrastructure: a failover doubles replication lag for an afternoon; every calibrated workaround in every consumer breaks at once.
- 5Support → team: a wave of "data loss" reports lands — nothing was lost, but no contract clause exists to point to, so every ticket is a fresh investigation.
- Create-then-read flows fail intermittently and non-reproducibly — the class of bug that consumes the most support time per incident.
- Consumers see time move backwards between load-balanced reads (no monotonic-reads promise), producing UI flicker and "it reverted!" reports.
- Every consumer independently discovers and hard-codes your current lag; any infrastructure change breaks them all simultaneously.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Decide and document the consistency promise per read surface — read-your-writes on primary reads, explicit bounds on derived ones — before consumers calibrate to accidents.
- • Return the complete resource from writes so the immediate re-read is unnecessary in the first place.
- • Expose freshness on lagging surfaces: `data_as_of` fields, or a session/consistency token that lets a client demand reads at least as fresh as its own write.
- • Order events after visibility: emit `created` only once the primary read would succeed, or document fetch-retry as the required consumer pattern.
- • Measure real read-after-write lag per surface (synthetic probes: write, then poll each read path) and alert when it exceeds the documented bound — the bound is now an SLO.
- • Track 404-after-201 sequences per credential in access logs; they count consumers hitting the gap whether or not they report it.
- • Watch for tight poll loops immediately following writes — the signature of consumers compensating for an unpromised guarantee.
- • Strengthening is safe (eventual → bounded → read-your-writes); weakening a promise consumers built flows on is a breaking change requiring migration, even though no field changed shape ([[backward-compatibility]]).
- • A consistency-token mechanism can be added additively, letting consumers opt into stronger reads without changing defaults for everyone else.
- • Keep the promise mechanism-free so replicas, caches and datastores can be swapped under a stable clause.
- • Promising read-your-writes constrains routing: creator reads must reach fresh state, via primary reads or session pinning — a real cost at scale, paid on every read.
- • Stated bounds become SLOs you must monitor and defend; a bound you miss during incidents needs an "under normal operation" qualifier, which weakens what consumers can build.
- • Per-surface promises complicate the docs compared to one global (false) statement — the complexity was always there; now it is visible and yours.