See It Flow

Core Concepts

REST

REST is a way of designing APIs that allows clients, servers, caches, proxies, and gateways to cooperate using standard HTTP semantics.

Mangesh MandlikLast updated:

What Is REST?

An architectural style, and the problem it was built to solve

REST (Representational State Transfer) is an architectural style for distributed hypermedia systems. It is a set of constraints chosen so that clients, servers, caches, and gateways can cooperate at scale without knowing one another's internals. It is not a synonym for JSON, CRUD, or "HTTP endpoints": an API can use tidy resource-oriented paths and still implement only part of the model. A REST API, in practice, is an HTTP API that honours those constraints: resources identified by URLs, a uniform set of methods with defined semantics, stateless requests, and responses that say whether and how long they may be cached.

The problem is intermediaries. In the mid-1990s a request went from one browser to one server. A few years later it crossed load balancers, reverse proxies, CDNs, and gateways, each of which had to decide something about the message: may I cache this, may I retry it, who is it from? Fielding's 2000 dissertation described the style that made those decisions possible, in the same period he was working on the HTTP/1.1 and URI specifications. REST scales because those intermediaries understand the messages, not merely because the transport is HTTP.

The mental model is that a client never touches the server's data directly. It interacts with resources through representations. A resource is a conceptual thing the API names: an order, a customer, today's exchange rate. A representation is a transferable description of its current or intended state, commonly JSON, but possibly HTML, CSV, or a PDF. The URI identifies the resource; the HTTP method and headers say which interaction is requested. Everything below follows from taking that separation seriously.

REST Architecture

Clients, intermediaries, and servers cooperating through constraints

REST architecture is a set of constraints on how clients, intermediaries, and servers interact: client–server separation, stateless requests, cacheable responses, a uniform interface, and a layered system, with optional code on demand. Because every request carries what the server needs, any server instance can answer it, and load balancers, caches, and gateways can act on the message without knowing the application. The visual story shows this at work, and the constraints section covers what each one buys.

Visual Story

Stateless scaling, CDN caching, session bugs, and POST side effects — watch the full flow

REST API — Complete Visual Deep-Dive

REST API — Complete Visual Deep-Dive
Stateless Scaling
0 / 0
🔒Up nextSession State Problem

⤢ Expand for synchronized narration timeline

Resources, Representations, and Content Negotiation

What a URI names, and what actually travels over the wire

GET /v1/orders/ord_8f32 addresses one order. Whether it lives in PostgreSQL, was moved to another cluster last quarter, or is assembled from three services is invisible to the client. The URI should stay stable when storage moves, because clients, bookmarks, caches, and webhooks all hold on to it. Opaque identifiers such as ord_8f32 age much better than raw auto-increment keys tied to one database. It also follows that URIs name things (/orders, /orders/42), and the method carries the action. POST /createOrder, POST /getOrder, and POST /cancelOrder is RPC wearing an HTTP costume: every feature adds an endpoint, and because everything is a POST, no intermediary can tell a read from a write, so nothing is cacheable.

The same independence applies to the body. A representation is a projection of internal state, and that projection is part of your public contract. If a storage column such as internal_partition_id leaks into the response, some client will eventually read it, branch on it, or log it, and the team can no longer move or rename it without a breaking change.

Public representation versus storage row
-- storage row (internal)
id=8f32  internal_partition_id=17  status=CONFIRMED  total_cents=4999  created_ts=1718200000

GET /v1/orders/ord_8f32 HTTP/1.1
Accept: application/json

HTTP/1.1 200 OK
Content-Type: application/json

{"id":"ord_8f32","status":"CONFIRMED","total":{"amount":"49.99","currency":"USD"},
 "created_at":"2024-06-12T14:26:40Z"}

Field names, units, and formats are chosen for the contract. internal_partition_id, raw cents, and epoch timestamps stay behind the mapper.

One resource, several representations, one stable URILeft to right: a storage row that includes an internal_partition_id field, a representation mapper that exposes only the public contract, then two representations of the same resource (application/json and text/csv) that the client selects with the Accept header. The URI /v1/orders/ord_8f32 stays the same while storage and representation change.Storage rowinternal_partition_id: 17Representation mapperpublic fields onlyapplication/jsonAccept: application/jsontext/csvAccept: text/csvClientGET ord_8f32
One resource, several representations, one stable URI. Left to right: a storage row that includes an internal_partition_id field, a representation mapper that exposes only the public contract, then two representations of the same resource (application/json and text/csv) that the client selects with the Accept header. The URI /v1/orders/ord_8f32 stays the same while storage and representation change.

Content negotiation

One resource can have more than one representation. In proactive content negotiation the client states its preferences in request headers such as Accept (media type) or Accept-Language, and the server picks the best match it supports. If it can serve none of them it may answer 406 Not Acceptable, though many APIs simply fall back to a default type. Requests that carry a body declare it with Content-Type; a server that does not support that media type can reject it with 415 Unsupported Media Type. The response says which representation was chosen through its own Content-Type.

Negotiation has a caching consequence that is easy to miss. If the same URI can return different bytes depending on Accept, the response must carry Vary: Accept, otherwise a shared cache may store the CSV and replay it to a client that asked for JSON. The caching section returns to Vary. For how representation shape, error envelopes, and versioning fit into one contract, see the API Design lesson.

The Constraints and What Each One Buys

Client–server, stateless, cacheable, uniform, layered, and optional code on demand

Each constraint trades a freedom for a property. Client–server separation lets the UI and the server evolve independently as long as they preserve the contract. Statelessness buys horizontal scaling and simple failure handling. Cacheability lets responses declare whether and for how long they may be reused, which buys load reduction. A uniform interface buys interoperability: components that have never met can still understand each other. A layered system buys the ability to insert proxies, gateways, and caches without either end noticing. Code on demand is the one optional constraint, where a server ships executable logic such as JavaScript to extend the client. Most JSON APIs do not use it, and omitting it does not make an API less REST.

Two of these cause most of the real incidents when they are violated: statelessness and cacheability. They get the detailed treatment below.

Statelessness does not mean the server stores nothing

An order service obviously stores orders. The constraint concerns client–server interaction state: each request must carry enough information to be understood and processed without depending on an earlier request having established an undocumented, implicit context on one particular server instance. Durable resources, caches, and authorization records are all fine. A bearer token or cookie can carry identity while the server validates what it may do against durable state.

The scaling consequence is practical. Imagine a checkout wizard whose half-completed steps live only in the memory of node A. The first request lands on A and works; the load balancer sends the second to node B, which has never heard of the wizard, and the user sees an error. Pinning users to nodes (sticky sessions) hides the bug but makes deploys, autoscaling, and node failure lose in-flight work. The robust alternatives are to make the workflow a resource, or to keep shared durable state keyed by an explicit identifier the client sends:

Workflow state as an addressable resource
POST /v1/checkouts HTTP/1.1
HTTP/1.1 201 Created
Location: /v1/checkouts/chk_91

PATCH /v1/checkouts/chk_91 HTTP/1.1      # served by any node
{"shipping_method":"express"}

The identifier chk_91 travels with each request. Any instance can load the checkout from shared storage, so nothing depends on which node answered earlier.

Statelessness is not free. State moves to the client (more bytes per request) or to shared storage (a lookup per request). Authentication shows the tension most clearly. A signed JWT lets any node verify identity with no lookup, which is statelessness at its purest, but it cannot be revoked before it expires without reintroducing server state. The usual compromise is a short access-token lifetime (minutes) with a refresh token that the server can revoke, or a small denylist consulted for high-risk operations.

Cacheability

Responses to GET can be stored by browsers, proxies, and CDNs when they say so. For a popular product page this means most reads after warm-up never reach the origin, let alone the database. The price is the discipline of declaring policy per response, covered in full in the caching section. The cardinal rule: never embed user-specific data in a response that a shared cache may store.

Uniform interface, layered system

The uniform interface does the most work and is the one most often skimmed. It breaks down into identification of resources, manipulation through representations, self-descriptive messages, and hypermedia as the engine of application state (HATEOAS). "Self-descriptive" is why a Content-Type, a status code, or Cache-Control can be understood by a proxy that has never seen your API: the meaning is in the standard, not in your documentation. That is also what makes the layered system constraint work. A CDN can cache, a gateway can rate-limit, and a load balancer can terminate TLS because the messages say enough for those layers to act on them.

HTTP API Versus Fully REST: Where Hypermedia Fits

A descriptive scale, not a definition

Hypermedia means responses carry the links and affordances that describe what can be done next, so the client follows links instead of hard-coding URI templates and workflow rules:

A hypermedia-style response (illustrative shape)
{"id":"ord_8f32","status":"CONFIRMED",
 "links":{"self":"/v1/orders/ord_8f32",
          "cancel":{"href":"/v1/orders/ord_8f32/cancellation","method":"POST"},
          "invoice":"/v1/orders/ord_8f32/invoice"}}

If the order shipped, the server would drop the cancel link; the client reads availability of an action from the response.

Many production APIs implement statelessness, resource identification, and HTTP method semantics, but their clients still embed URI patterns and decide valid transitions from documentation, so the hypermedia part of the uniform interface is missing. That is a legitimate engineering choice: link-driven clients cost more to build and tooling support is uneven. It is simply more accurate to call such an API an HTTP API or REST-style API than to claim full REST conformance.

Richardson maturity levels: how far an API leans into the uniform interfaceFour boxes left to right. Level 0: one endpoint, actions in the payload. Level 1: separate resource URIs, but usually POST for everything. Level 2: HTTP methods and status codes carry the meaning. Level 3: responses include links to valid next actions (hypermedia). Arrows show increasing use of the uniform interface. Most production HTTP APIs sit at level 2.Level 0POST /api {action:…}Level 1resource URIs, POST onlyLevel 2methods + status codesLevel 3hypermedia links
Richardson maturity levels: how far an API leans into the uniform interface. Four boxes left to right. Level 0: one endpoint, actions in the payload. Level 1: separate resource URIs, but usually POST for everything. Level 2: HTTP methods and status codes carry the meaning. Level 3: responses include links to valid next actions (hypermedia). Arrows show increasing use of the uniform interface. Most production HTTP APIs sit at level 2.

The Richardson maturity model describes the gradient. Level 0 tunnels everything through one endpoint (SOAP-style POST /api with an action in the payload). Level 1 introduces separate resource URIs but still uses POST for nearly everything. Level 2 lets HTTP methods and status codes carry the meaning, and is where most APIs that call themselves REST actually sit. Level 3 adds hypermedia. Use it as a lens for talking about an existing API, not as a checklist an API must complete to "count".

HTTP Method Semantics: Safe, Idempotent, and Concurrency-Safe

Three different properties that drive retry, caching, and conflict decisions

RFC 9110 defines a safe method as one whose defined semantics are read-only from the client's perspective: GET and HEAD (also OPTIONS and TRACE) qualify. Safe does not forbid every server-side effect. Access logs, metrics, and rate-limit counters still change. It means the client did not ask for, and is not responsible for, a change to the resource's state. That is what permits crawlers, prefetchers, and caches to issue GETs freely, and why a state-changing GET /orders/42/cancel is dangerous: a link prefetcher can cancel orders.

An idempotent method has the same intended effect on server state whether the identical request is sent once or many times. It is a weaker guarantee than safe: the method may change state, but repeating it leaves the resource where a single call left it.

MethodTypical intentSafe?Idempotent by semantics?
GETRetrieve a representationYesYes
POSTCreate a subordinate resource or process an operationNoNot generally
PUTCreate or replace the target resourceNoYes
PATCHApply a partial modificationNoDepends on the patch
DELETERemove the target resourceNoYes

Three nuances matter in practice:

  • Different responses are allowed. A first DELETE /v1/orders/ord_8f32 can return 204 No Content and a repeat can return 404 Not Found. Idempotency is about the resulting state (the order is gone), not identical status codes.
  • The body defines PATCH idempotency. PUT /v1/subscriptions/s1 with {"status":"PAUSED"} means "replace with exactly this", so sending it twice leaves the same state. A PATCH that says {"credits":5} states a target and is idempotent too; a PATCH meaning "increment credits by one" is not, because every retry compounds the change.
  • Idempotent is not concurrency-safe. Two different PUT requests from two clients are each idempotent, yet the later one still overwrites the earlier one. Idempotency protects you from retries of the same request; it says nothing about conflicting different writes. Conditional writes handle that.

POST is neither safe nor idempotent by default, so a retried POST /orders after a gateway timeout can create a second order. That does not mean POST cannot be retried safely; it means retry-safety has to be built deliberately, typically with an idempotency key the server uses to detect and replay a duplicate. The mechanics, including the crash windows, are in the Idempotency lesson, and how clients should space retries is in Retry & Backoff.

HTTP Caching: Freshness, Validation, and 304 Not Modified

Cache-Control, ETag, If-None-Match, Vary, private, and no-store

Caching is part of the protocol, not an add-on. A successful GET response may be stored and reused if its cache policy allows. A cache normally keys an entry on the effective request URI plus any request headers named in Vary. Reuse happens in two modes: freshness (the stored response is still within its lifetime, so it is served with no origin contact) and validation (it is stale, so the cache asks the origin whether it is still valid).

A cacheable response
GET /v1/catalog/items/sku_42 HTTP/1.1

HTTP/1.1 200 OK
Cache-Control: public, max-age=60
ETag: "catalog-sku42-v7"
Content-Type: application/json
Vary: Accept

{"sku":"sku_42","name":"Wireless Mouse","available":true}

max-age=60 says the response is fresh for 60 seconds; public allows shared caches such as a CDN to store it. The ETag is an opaque validator identifying a version of the representation. It is not necessarily a content hash; a version counter works equally well. Once the copy is stale, the cache does not discard it. It sends a conditional request:

Revalidation and 304
GET /v1/catalog/items/sku_42 HTTP/1.1
If-None-Match: "catalog-sku42-v7"

HTTP/1.1 304 Not Modified
ETag: "catalog-sku42-v7"
Cache-Control: public, max-age=60

A 304 has no body. The cache keeps its stored body, updates the stored headers, and starts a new freshness period.

Fresh hit, stale revalidation, and 304 through a shared cacheThree actors: browser, CDN or shared cache, and origin. At t=0 the cache has no copy, so it forwards the GET to the origin, which returns 200 with ETag catalog-sku42-v7 and max-age=60; the cache stores it and returns 200 with Age 0. At t=30 the copy is fresh, so the cache answers without contacting the origin. At t=75 the copy is stale, so the cache sends a conditional GET with If-None-Match to the origin, the origin replies 304 Not Modified with no body, and the cache serves its stored body to the browser as 200.BrowserclientCDN / shared cachestores by URI + VaryOrigincatalog service1. t=0 GET /v1/catalog/items/sku_422. miss: forward GET3. 200 ETag "…v7", max-age=604. 200 (stored copy, Age: 0)5. t=30 GET same URI6. 200 fresh hit, Age: 30, no origin call7. t=75 GET same URI8. stale: GET + If-None-Match "…v7"9. 304 Not Modified, no body10. 200 stored body, freshness reset
Fresh hit, stale revalidation, and 304 through a shared cache. Three actors: browser, CDN or shared cache, and origin. At t=0 the cache has no copy, so it forwards the GET to the origin, which returns 200 with ETag catalog-sku42-v7 and max-age=60; the cache stores it and returns 200 with Age 0. At t=30 the copy is fresh, so the cache answers without contacting the origin. At t=75 the copy is stale, so the cache sends a conditional GET with If-None-Match to the origin, the origin replies 304 Not Modified with no body, and the cache serves its stored body to the browser as 200.

Read the figure top to bottom. At t=0 the cache is empty and the origin does the work. At t=30 the copy is fresh, so the origin is not contacted at all, which is where the large load reduction comes from. At t=75 the copy has been stale for 15 seconds, so the cache revalidates; the origin still has to look the item up, but it returns a few hundred bytes rather than the full body. Revalidation therefore saves bandwidth and serialization, not necessarily the database read. If the origin's version had changed, it would answer 200 with the new body and a new ETag. Directives such as stale-while-revalidate can let a cache serve the stale copy while it refreshes in the background.

Vary, private, and no-store

Vary tells caches which request headers influenced the response; Vary: Accept keeps a CSV and a JSON copy of the same URI separate. Shared caches must not casually store personalized responses, and explicit policy matters more than the assumption that every GET is shareable. Cache-Control: private allows a single user's browser cache but forbids shared caches; no-store forbids storing the response anywhere; and the oddly named no-cache permits storing but requires revalidation before every reuse. For an order history page, private or no-store is usually correct depending on whether any caching is acceptable. By default a shared cache will not reuse a response to a request carrying an Authorization header unless the response explicitly opts in, another reason not to put per-user data behind public. Listing endpoints interact with caching too: a cached page of results is only as fresh as its policy, a point developed in the Pagination lesson.

Conditional Writes with If-Match: Preventing Lost Updates

Two clients, one stale read, and a 412 that saves the data

Suppose two operators open the same order, both seeing version 7. Operator A changes the delivery address and saves. Operator B, still holding the version-7 copy, changes the status and saves a full replacement that contains the old address. Without a concurrency condition the second write silently overwrites the first. Neither request was malformed, neither was a retry, and both were idempotent; the data was lost anyway. This is the lost update problem.

HTTP's answer is a conditional request. The client sends the validator it last saw in an If-Match header; the server applies the write only if the current version still matches.

Conditional PUT
PUT /v1/orders/ord_8f32 HTTP/1.1
If-Match: "order-v7"
Content-Type: application/json

{"status":"CONFIRMED","delivery_address":"12 Quay St"}

HTTP/1.1 412 Precondition Failed          # current version is no longer v7
Lost-update timeline guarded by If-MatchActors: operator A, operator B, and the order service. Both operators read order ord_8f32 and receive ETag order-v7. A sends PUT with If-Match order-v7 to change the delivery address; the service applies it and returns 200 with ETag order-v8. B then sends PUT with If-Match order-v7 to change the status using the stale copy; the service sees the current version is v8 and returns 412 Precondition Failed without writing. B re-reads version 8, reapplies the status change and succeeds with version 9. Without the If-Match header the second PUT would have silently erased A's address change.Operator Achanges addressOperator Bchanges statusOrder servicecurrent version: 71. GET /v1/orders/ord_8f322. 200 ETag "order-v7"3. GET /v1/orders/ord_8f324. 200 ETag "order-v7"5. PUT address, If-Match "order-v7"6. 200 ETag "order-v8"7. PUT status, If-Match "order-v7"8. 412 Precondition Failed (now v8)9. GET, merge, PUT If-Match "order-v8"10. 200 ETag "order-v9"
Lost-update timeline guarded by If-Match. Actors: operator A, operator B, and the order service. Both operators read order ord_8f32 and receive ETag order-v7. A sends PUT with If-Match order-v7 to change the delivery address; the service applies it and returns 200 with ETag order-v8. B then sends PUT with If-Match order-v7 to change the status using the stale copy; the service sees the current version is v8 and returns 412 Precondition Failed without writing. B re-reads version 8, reapplies the status change and succeeds with version 9. Without the If-Match header the second PUT would have silently erased A's address change.

In the timeline, A's write moves the resource to version 8. B's If-Match: "order-v7" no longer matches, so the service returns 412 Precondition Failed and writes nothing. B must re-read (now v8), re-apply the status change onto the fresh state, and retry with the new validator, or show the user a conflict. Remove the header and B's write succeeds, erasing A's address.

Three implementation details decide whether this works. First, the check and the write must be atomic on the server, typically a compare-and-set such as UPDATE orders SET ..., version = version + 1 WHERE id = ? AND version = 7 that affects zero rows on conflict; comparing in application code and then writing leaves a race window. Second, If-Match uses strong comparison, so use strong ETags rather than weak (W/) ones. Third, a server can require the header by answering 428 Precondition Required when it is missing. Conditional writes are most natural for state replacement with PUT (and work with PATCH and DELETE). They complement rather than replace business validation and database transaction isolation: they protect one client's read-modify-write cycle across the network, not invariants that span several resources.

Failure Modes in Production

What goes wrong when a constraint is bent without noticing

The broken CDN cache

A product listing is cached at the CDN for five minutes. Someone adds {"is_in_wishlist": true} to each item for personalization. The CDN stores the first visitor's response and serves it to everyone, so users see another person's wishlist. The cause is a personalized body behind a shared-cache policy. Fix it by moving personal data to a separate private or no-store endpoint, or at minimum varying the cache on whatever identifies the user. Never rely on the cache to "know" a response is personal.

The PATCH retry bug

A client sends PATCH {"quantity":"+1"}. The gateway times out after the origin has already applied the change; the client retries and the quantity is now doubled. Delta operations are not idempotent, so a blind retry applies them twice. Design the patch as a target state ({"quantity":5}), guard it with If-Match when concurrent edits matter, or add an idempotency key.

The JWT revocation problem

An account is compromised and you want the user logged out now, but the JWT is valid for another 24 hours and every node accepts it without asking anyone. Statelessness and immediate revocability are in direct tension. Short-lived access tokens with revocable refresh tokens bound the exposure window; a denylist checked on sensitive operations closes it further at the cost of a lookup.

The stale catalog

A catalog endpoint caches product availability for five minutes. Inventory changes quickly, so customers see "available" after the last unit has sold and then fail at checkout. The defect is not that HTTP caching is unsafe; it is that the freshness policy does not match the business decision. Availability on a product page is informational; availability at purchase time is a commitment.

How the freshness policy sets the length of the wrong-answer windowTime in seconds (illustrative). The last unit of sku_42 sells at t=60. A cached copy fetched at t=0 under max-age=300 keeps saying available until t=300, a 240-second wrong window. A copy fetched at t=59 under max-age=15 expires at t=74, so the wrong window is at most 14 seconds. Checkout re-reads inventory from the source of truth in either case.060120180240300seconds (illustrative)Stock: units availableCopy fetched t=0, max-age=300Wrong window, max-age=300240 sCopy fetched t=59, max-age=15Wrong window, max-age=15≤ 14 s
How the freshness policy sets the length of the wrong-answer window. Time in seconds (illustrative). The last unit of sku_42 sells at t=60. A cached copy fetched at t=0 under max-age=300 keeps saying available until t=300, a 240-second wrong window. A copy fetched at t=59 under max-age=15 expires at t=74, so the wrong window is at most 14 seconds. Checkout re-reads inventory from the source of truth in either case.

With illustrative numbers, if the last unit sells at t=60 s, a copy fetched at t=0 under max-age=300 keeps claiming availability for another 240 seconds. A copy fetched at t=59 under max-age=15 is wrong for at most 14 seconds. The usual remedy has three parts: cache stable data such as descriptions and images for a long time; give volatile data like availability a short lifetime or explicit invalidation; and always revalidate inventory against the source of truth during checkout. The cache then improves browsing without becoming the authority for an irreversible purchase.

What REST Does Not Guarantee

Constraints are tools, not outcomes

REST does not give you low latency, strong consistency, security, or exactly-once execution. HTTP offers clear semantics for methods, status codes, caching, and preconditions, but the backend still owns transaction boundaries, authorization, retry safety, and observability. A cache can legitimately serve a response that is a minute old; two replicas can disagree; a timeout can leave a write's outcome unknown. A JSON body does not make an API RESTful, and equally, choosing gRPC does not make a service stateful. Protecting the origin from abusive or runaway clients is a separate concern, covered in Rate Limiting.

When to Use REST, and When Not To

Choosing by workload, not by habit

Choose REST when the API is external, partner-facing, or browser-accessible; when reads dominate and benefit from CDN and browser caching; and when being debuggable with curl and understandable to any engineer who knows HTTP is worth more than wire efficiency. For resource-shaped domains (orders, customers, documents), it is the sensible default. It is chosen not because it is popular but because HTTP cacheability and statelessness are valuable for that workload.

Choosing REST or something else, by workloadLeft: a new API workload. Middle: four workload shapes. Right: the style that usually fits each. Public, partner or browser-facing resource APIs with cacheable reads fit REST. Chatty internal service-to-service calls with typed contracts fit gRPC. Screens that need varied slices of related data fit GraphQL. Server-initiated updates and streams fit Server-Sent Events or WebSockets. These are tendencies, not rules.New workloadwho calls, how often?Resource CRUD, cacheable readspublic / partner / browserChatty internal callstyped contract, high volumeVaried data per screenover-fetching, many round tripsServer push or streamsupdates not initiated by clientRESTHTTP caching, tooling, curl-ablegRPCprotobuf + HTTP/2GraphQLclient-chosen selectionSSE / WebSocketslong-lived connection
Choosing REST or something else, by workload. Left: a new API workload. Middle: four workload shapes. Right: the style that usually fits each. Public, partner or browser-facing resource APIs with cacheable reads fit REST. Chatty internal service-to-service calls with typed contracts fit gRPC. Screens that need varied slices of related data fit GraphQL. Server-initiated updates and streams fit Server-Sent Events or WebSockets. These are tendencies, not rules.

The friction points are where another style fits better:

  • Streaming and server push. REST is request–response; the server cannot initiate. Use Server-Sent Events for one-way updates or WebSockets for bidirectional traffic.
  • Chatty internal service-to-service calls. JSON text over HTTP costs parsing and bytes at very high call volumes. A binary, schema-first protocol such as gRPC is usually more compact and brings HTTP/2 multiplexing and streaming; see the gRPC lesson for what it does and does not buy.
  • Varied client queries. If every mobile screen over-fetches or chains several requests to assemble one view, a client-shaped query layer such as GraphQL may fit, at the cost of weaker HTTP caching.
  • Deep graph traversal. Following relationships across many resource types means many round trips or bespoke composite endpoints.

A useful warning sign is friction: if you are tunneling every action through POST, fighting over-fetching on every screen, or simulating a stream with polling, the workload is telling you something about the tool.

Putting It Together: An Order Lifecycle

One resource, end to end, with the headers that make it safe

The sequence below uses everything above: resource URIs, a Location header, validators for caching and concurrency, target-state updates, and an action modeled as its own resource. The status codes are the conventional ones; your contract may legitimately differ, but it should be consistent.

Order lifecycle (illustrative)
POST /v1/orders HTTP/1.1                       # not idempotent by itself
Idempotency-Key: 6f1c...                       # makes a retry replayable
{"items":[{"sku":"sku_42","qty":2}]}
HTTP/1.1 201 Created
Location: /v1/orders/ord_8f32
ETag: "order-v1"

GET /v1/orders/ord_8f32 HTTP/1.1               # safe, repeatable
HTTP/1.1 200 OK
Cache-Control: private, max-age=0, must-revalidate
ETag: "order-v1"

PATCH /v1/orders/ord_8f32 HTTP/1.1             # target state, guarded
If-Match: "order-v1"
{"delivery_address":"12 Quay St"}
HTTP/1.1 200 OK
ETag: "order-v2"

POST /v1/orders/ord_8f32/cancellation HTTP/1.1 # an action, modeled as a resource
HTTP/1.1 200 OK
{"id":"ord_8f32","status":"CANCELLED"}

DELETE /v1/orders/ord_8f32 HTTP/1.1            # only if your contract allows deletion
HTTP/1.1 204 No Content                        # a repeat may return 404; state is still "gone"

Create returns 201 with Location; reads are safe; updates carry If-Match; cancellation is a state transition rather than a verb in the URL.

Whether cancelling is DELETE, a PATCH to status: CANCELLED, or a POST to a sub-resource is a modeling decision. Prefer the one that matches what the business means: deleting erases the record, whereas cancelling is a state the order moves into and still has a history. Contract-level questions such as error envelopes and versioning live in the API Design lesson, and listing many orders (GET /orders) has its own performance and consistency trade-offs in the Pagination lesson.