Sweaty Imposter

API Design

25 REST design decisions, each with the canonical endpoint and the reasoning behind it.

You're designing the cart service for an e-commerce app. The frontend needs to add a specific item to a logged-in user's cart. Pick the best endpoint.

Items should be modeled as a sub-resource of the cart · Avoid verbs in the path · Should return the updated cart or the new line item
Canonical Endpoint
POST/v1/carts/{cartId}/items
{ "itemId": "sku-9", "qty": 2 }
→ 201 Created { "lineItemId": "li_44", "itemId": "sku-9", "qty": 2 }

Items are a sub-resource of a cart, so POST /carts/{cartId}/items is the RESTful expression of 'create a new line item under this cart' and returns 201 with the created resource. Option (a) puts a verb in the path; (c) shoves an action into a full-resource PUT; (d) uses GET for a state-changing operation, which violates safe-method semantics and lets caches/prefetchers accidentally mutate the cart.

Dive Deeper

Treat URLs as nouns and HTTP methods as verbs. Sub-resources collect things that only exist in the context of a parent (a line item has no meaning without a cart). Reserve action-style paths like /carts/{id}/checkout for true state-machine transitions that don't fit CRUD.

Your profile screen lets users edit just their display name or just their avatar URL without touching other fields. Pick the best endpoint for saving the change.

Client sends only the fields that changed · Must not blank out fields the client didn't send · Should be safe to call repeatedly with the same payload
Canonical Endpoint
PATCH/v1/users/{id}
{ "displayName": "Ada" }
→ 200 OK { "id": "u_1", "displayName": "Ada", "avatarUrl": "..." }

PATCH is the partial-update verb: the body describes the diff, and unspecified fields are left alone. A naive PUT (option a) is defined as 'replace the resource': many servers and validators will treat missing fields as null, which can wipe the avatar URL. Option (c) reinvents PATCH with a verb path, and (d) reuses POST on an existing resource, which conflicts with POST's 'create' semantics.

Dive Deeper

PUT replaces, PATCH modifies. If you really want PUT semantics for partial updates you have to GET-then-PUT the whole resource, which races against concurrent edits. PATCH bodies are typically JSON Merge Patch (RFC 7396) or JSON Patch (RFC 6902), pick one and document it.

You're building a payments API. Clients sometimes retry after a timeout and you cannot double-charge. Pick the best endpoint for creating a charge.

Retries of the same logical charge must not create two payments · Server should be able to dedupe without the client knowing if the first call committed · Should still feel like normal REST
Canonical Endpoint
POST/v1/payments
{ "amount": 1999, "currency": "usd", "source": "tok_abc" }
→ 201 Created, request includes header Idempotency-Key: 8f3e... ; server caches response per key for 24h

The industry-standard pattern (Stripe, AWS, Square) is POST with a client-supplied Idempotency-Key header; the server stores the response keyed by it, so retries get the cached result instead of a second charge. Option (a) is the obvious bug. Option (b) is the right idea but baking the nonce into the body conflates business data with infra; a header keeps it out of validation and signing surfaces. Option (d) works but forces clients to mint resource IDs and changes the create verb from POST to PUT, which most teams find awkward.

Dive Deeper

Idempotency keys should be scoped per endpoint and per authenticated principal, stored with the full response (status + body + headers), and expire after a window long enough to cover client retries (24h is typical). Crucially the server must distinguish 'same key, same body' (replay → cached response) from 'same key, different body' (conflict → 409 or 422).

A post on your social app can have millions of comments and the feed appends new ones constantly. The mobile client needs to scroll through them. Pick the best endpoint.

List grows while the user is paging · Must not skip or duplicate items as new comments arrive · Deep pagination must stay fast
Canonical Endpoint
GET/v1/posts/{id}/comments?cursor=eyJ0Ijo...&limit=50
→ 200 OK { "items": [...], "nextCursor": "eyJ0Ijo..." }

Cursor (keyset) pagination encodes 'where you left off' (e.g. created_at + id) in an opaque token; it stays O(log n) at any depth and is stable when new rows are inserted at the head. Offset/page pagination (a, b) gets slower the deeper you go and silently shifts items when new comments arrive, causing duplicates and skips. Option (d) uses POST for a pure read, which breaks cacheability and safety semantics.

Dive Deeper

Cursors should be opaque to clients: encode a tuple like (sort_key, tiebreaker_id), base64 it, and reserve the right to change the encoding. Always return a nextCursor (and optionally prevCursor) rather than asking clients to construct it. For UIs that genuinely need 'page 5 of 12', that's an offset use case, accept it for small bounded lists only.

You're shipping the first public version of your API. You know breaking changes are inevitable and you need a versioning scheme that's easy to operate. Pick the best approach.

Easy to test from a browser or curl · Easy to route at the load balancer · Easy for clients to opt into a new major version
Canonical Endpoint
GET/v1/users/{id}
→ 200 OK, major version in path; bump to /v2/ for breaking changes

URL path versioning (/v1/, /v2/) is the most operationally boring choice: easy to curl, easy to route at an edge proxy, easy to deprecate in logs, and unambiguous in client SDKs. Header-based versioning (b) is theoretically purer but fights every CDN, browser tool, and ad-hoc debugging session. Query-param (c) muddles the read-cache key. Server-pinned versions (d) hide the API contract from the client and create silent behavior drift when keys are rotated.

Dive Deeper

Reserve URL versions for breaking changes only; additive changes (new optional fields, new endpoints) should ship without a bump. Pair path versioning with a deprecation policy: announce, dual-run for N months, emit a Sunset header, then 410 Gone. Date-based versions (Stripe-style) are a worthwhile middle ground when you ship breaking changes often.

Customers can request a CSV export of all their orders. Generating it takes 30 seconds to 10 minutes. Pick the best endpoint design.

Must not hold an HTTP connection open for minutes · Client needs to poll or be notified when ready · Should survive client disconnects
Canonical Endpoint
POST/v1/exports
{ "resource": "orders", "format": "csv" }
→ 202 Accepted, Location: /v1/exports/exp_77 { "id": "exp_77", "status": "pending" }

Long-running work is its own resource: POST creates a job, the server returns 202 Accepted with a Location pointing to the job, and the client polls (or subscribes to a webhook) until status flips to succeeded with a download URL. Synchronous designs (a, c, d) tie up sockets, fail load-balancer timeouts, and lose progress on disconnect. 202 is the specific status for 'I have accepted this, it is not done yet.'

Dive Deeper

Model jobs as first-class resources with their own lifecycle (pending, running, succeeded, failed, canceled). Expose GET /jobs/{id} for polling and ideally a webhook for completion. Include progress fields when you can, a result URL when done, and an error object when failed. This generalizes to imports, batch operations, and async ML inference.

Your blog API needs to let an author delete one of their posts. Pick the best endpoint and response.

Should be idempotent, deleting twice is fine · Caller doesn't need the resource back · Use the most specific status code
Canonical Endpoint
DELETE/v1/posts/{id}
→ 204 No Content

DELETE on the resource URL returning 204 No Content is the canonical pattern: the verb says what happened, the status code says 'success, no body to send.' Option (c) is acceptable but wastes a payload when the client already knows what it deleted; reserve a body for soft-delete metadata the client truly needs. Option (b) hides the verb in the path; (d) uses GET for a mutation, which is dangerous because it's safe and cacheable by spec.

Dive Deeper

204 is for successful requests with deliberately empty bodies; 200 implies 'here is a representation.' DELETE should be idempotent: a second DELETE of an already-gone resource returns 204 (or 404 if you want to signal 'wasn't here'), pick one and document it. For soft deletes, prefer PATCH with a status field over reinterpreting DELETE.

Your warehouse system needs to update on-hand counts for up to 5,000 SKUs at once after a nightly stock count. Pick the best endpoint.

One round trip, not 5,000 · Server should report per-item success/failure · Partial failure must not look like total success
Canonical Endpoint
POST/v1/inventory/bulk
{ "items": [{ "sku": "A1", "onHand": 12 }, { "sku": "A2", "onHand": 0 }] }
→ 207 Multi-Status { "results": [{ "sku": "A1", "status": 200 }, { "sku": "A2", "status": 404, "error": "unknown sku" }] }

A bulk endpoint that returns 207 Multi-Status (or 200 with per-item statuses) lets the client see exactly which SKUs failed and retry only those, in one round trip. Option (b)'s full-resource PUT would wipe any SKU not in the body, dangerous. Option (c) is correct REST but pathologically slow. Option (d) collapses partial failure into a single boolean, which loses information the client needs to recover.

Dive Deeper

For batch APIs, decide up front whether the batch is atomic (all-or-nothing, like a DB transaction) or per-item (each succeeds or fails independently). Document it explicitly. Per-item is more common for ingest; atomic is right for accounting or inventory transfers. Cap batch size and document the limit so clients can pre-chunk.

Your storefront needs to list products filtered by category, price range, and in-stock status, with a free-text search query. Pick the best endpoint.

Should be cacheable · Should be linkable / shareable · Filters should compose cleanly
Canonical Endpoint
GET/v1/products?category=shoes&price_min=20&price_max=80&in_stock=true&q=runner
→ 200 OK { "items": [...] }

Reading a filtered collection is GET on the collection with filters as query params; it's cacheable by URL, shareable as a link, and composes naturally as filters are added. POST-for-search (b) is sometimes necessary when the query genuinely exceeds URL length limits or contains structured DSL, but for simple flat filters it forfeits HTTP caching. Path-encoded filters (c) explode combinatorially and pin filter order. (d) buries the resource type as a param.

Dive Deeper

Reach for POST /resource/search only when the query is large enough to overflow ~2KB URLs, contains structured logical operators, or carries data you wouldn't want in access logs. When you do, still return a Location header to a cacheable result resource if results are paged. For range filters, the price_min / price_max convention scales better than overloading a single 'price' param.

Users upload videos up to 5 GB to your platform. Pick the best endpoint design for the upload path.

Don't proxy 5 GB through your application servers · Support resumable uploads on flaky mobile networks · Server still needs metadata to register the file
Canonical Endpoint
POST/v1/uploads
{ "filename": "trip.mp4", "size": 4823918273, "contentType": "video/mp4" }
→ 201 Created { "uploadId": "up_1", "uploadUrl": "https://s3...?X-Amz-Signature=...", "method": "PUT" }: client PUTs bytes to uploadUrl, then POSTs /v1/videos referencing uploadId

The standard pattern is a two-step: client asks the API for a pre-signed upload URL (and optionally a multipart upload session), then PUTs bytes directly to object storage, then notifies the API to finalize. This keeps gigabytes out of your app tier, supports resumable / multipart uploads natively (S3, GCS), and isolates auth at the URL level. Options (a) and (c) burn application bandwidth and memory; (d) base64-inflates payloads by 33% and is JSON parser-hostile.

Dive Deeper

Pre-signed URLs should be short-lived (minutes), scoped to a single key and content-type, and the API should validate the uploaded object server-side before flipping the resource to 'ready', never trust the client's claim that the upload finished or matched. For very large files, S3 multipart or tus.io's resumable upload protocol gives you per-chunk retries.

Two editors open the same document and both save changes a few seconds apart. You need to detect and reject the second save instead of silently overwriting the first. Pick the best endpoint.

Detect concurrent edits without long-lived locks · Use a standard HTTP mechanism · Reject with a clear status code, not 500
Canonical Endpoint
PUT/v1/docs/{id}
{ "title": "New", "body": "..." }
→ 200 OK, request includes If-Match: "etag_abc"; server returns 412 Precondition Failed on mismatch

ETag + If-Match is the standard HTTP optimistic-concurrency mechanism: the server hands out an ETag on GET, the client echoes it via If-Match on write, and the server returns 412 Precondition Failed if the resource changed underneath. Option (a) loses data. Option (c) introduces stateful locks that leak on crashed clients. Option (d) is the same idea but reinvents in-body what HTTP already standardizes, and bypasses CDN / proxy support for conditional requests.

Dive Deeper

ETags can be strong (byte-equal) or weak (semantically equal); use strong for resources where every byte matters. Pair If-Match with If-Unmodified-Since as a fallback. For partial updates, the ETag should reflect the whole resource, not a field, so that PATCH bodies still race-detect correctly. 412 means 'your precondition failed'; reserve 409 for true business conflicts.

An authenticated user requests GET /v1/documents/{id}, but that document belongs to a different tenant and they have no permission to see it. Pick the best response.

Don't leak the existence of private resources · Distinguish 'not signed in' from 'signed in but forbidden' · Stay consistent with how unknown IDs are handled
Canonical Endpoint
GET/v1/documents/{id}
→ 404 Not Found { "error": "document not found" }, same response as for unknown IDs

Returning 404 for both 'doesn't exist' and 'exists but you can't see it' prevents an enumeration oracle that would otherwise let an attacker probe which document IDs are real. 401 (a) means 'no valid credentials', wrong when the user is already authenticated. 403 (b) is semantically accurate but leaks existence; only use it when the resource's existence is already public (e.g. a known username's private profile). 500 (d) is a server-error code for unhandled exceptions, not access decisions.

Dive Deeper

401 vs 403 vs 404 is fundamentally a question of what you're willing to reveal. 401 says 'authenticate'; 403 says 'we know who you are and the answer is no'; 404 says 'no such thing here, from your perspective.' Multi-tenant SaaS APIs almost always prefer 404 for cross-tenant access. Whatever you pick, return the same shape and timing for both cases, timing side-channels can leak existence too.

Earlier you exposed POST /v1/exports to start an export job. Now you need to let the user cancel an in-progress job. Pick the best endpoint.

Cancel is a state transition, not a deletion of the record · Job history should still be visible after cancel · Should not race-condition with completion
Canonical Endpoint
POST/v1/exports/{id}/cancel
→ 200 OK { "id": "exp_77", "status": "canceled", "canceledAt": "..." }, 409 if already finished

Cancel is a state-machine transition, not a deletion: the job record should persist (for audit, billing, and the UI's history view) but flip to a canceled state. A dedicated POST /exports/{id}/cancel makes the transition explicit, lets the server reject illegal transitions (e.g. 409 if already succeeded), and avoids clients ever setting arbitrary status values. DELETE (a) implies the record is gone. PATCH/PUT with status (c, d) let clients write any state, which leaks server-controlled lifecycle into the wire contract.

Dive Deeper

Actions that don't map to CRUD: cancel, retry, publish, archive, refund, are the canonical exception to 'no verbs in URLs.' Express them as POST /resource/{id}/action sub-resources. Make them idempotent where possible (canceling an already-canceled job is fine; refunding twice is not). Pair with a clear list of allowed transitions in the API docs.

Your API needs an endpoint that returns the orders belonging to the currently authenticated user. Pick the best design.

Server already knows who the caller is · Caller should not be able to spoof another user's ID · Should still let admin tools list any user's orders
Canonical Endpoint
GET/v1/me/orders
→ 200 OK, scoped to authenticated principal; admin tools use /v1/users/{id}/orders instead

/v1/me/... is the standard pattern for 'the authenticated principal': the server resolves identity from the token, so clients can't accidentally or maliciously pass another user's ID, and the URL is the same across sessions, which makes caching and client code simpler. Admin or impersonation flows use the explicit /users/{id}/orders form, which can require elevated scopes. Options (a) and (c) let the client name the user, which is an authorization footgun if the server forgets to check that callerId == user_id. (d) buries a read in a POST.

Dive Deeper

Two parallel resource trees, /me/* for self and /users/{id}/* for admin, give you a clean place to attach different OAuth scopes (e.g. orders:read.self vs orders:read.any). Never trust a user_id passed in body or query when you can derive it from the auth token. Document that /me requires authentication and returns 401 (not 404) for anonymous callers.

Your platform sends webhook events to subscribers when a payment succeeds. You're designing the outbound HTTP call. Pick the best design.

Subscriber must verify the call really came from you · Network failures and 500s must not lose events · Subscriber must be able to dedupe replays
Canonical Endpoint
POST{subscriberUrl}
{ "id": "evt_8f3", "type": "payment.succeeded", "createdAt": "...", "data": { "id": "pi_1" } }
→ headers: X-Signature: t=...,v1=hmac_sha256(...); X-Event-Id: evt_8f3, retried with exponential backoff on 5xx/timeout; subscriber returns 2xx to ack

A production webhook needs four things: (1) a stable event ID so subscribers can dedupe replays, (2) an HMAC (Hash-based Message Authentication Code) signature over the body with a shared secret so the subscriber can verify origin without trusting TLS alone, (3) a documented retry policy with exponential backoff on non-2xx / timeouts, and (4) POST with a JSON body. Option (a) silently loses events. Option (c) crams payload into a URL and uses GET for a side-effecting notification. Option (d) ships your secret to whatever URL the subscriber configured, a credential leak waiting to happen.

Dive Deeper

Sign with HMAC-SHA256 over a timestamp + raw body, and include both in the header (e.g. Stripe's t= and v1= scheme) so subscribers can reject replays older than ~5 minutes. Retry with capped exponential backoff for hours, then dead-letter. Subscribers must return 2xx fast (< few seconds) and do heavy work async; 'at-least-once delivery' means subscribers must be idempotent on event ID.

Your public API allows each API key 1,000 requests per hour. A client has just gone over the limit. Pick the best response, including the headers that help clients stay under it.

Client code should back off automatically · Clients should see their remaining budget before they hit the limit · Use the standard status code
Canonical Endpoint
GET/v1/orders
→ 429 Too Many Requests, Retry-After: 120; every response also carries RateLimit-Limit: 1000, RateLimit-Remaining, and RateLimit-Reset (seconds until the window resets)

429 Too Many Requests is the status for 'this client sent too many requests', and Retry-After tells it exactly when to try again. Sending the limit, the remaining count, and the reset time on every response lets well-behaved clients slow down before they are throttled. 403 (a) signals a permission problem, so clients won't retry. A 200 with an empty list (c) silently corrupts client state. 503 (d) blames the server, and clients, monitors, and load balancers treat it as an outage.

Dive Deeper

Early drafts of the IETF RateLimit header fields spec used RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset; later drafts fold them into RateLimit and RateLimit-Policy, and many APIs still send X-RateLimit-* forms. Pick one and document it. Express the reset as seconds remaining so client clock skew doesn't matter, and run the throttle check before expensive work such as database calls, so a flood of 429s stays cheap to serve.

A signup request fails validation: the email is malformed and the password is too short. The web and mobile clients need to highlight each bad field. Pick the best error response.

Clients must map each error to a form field · The error shape should be the same on every endpoint · Messages are for people; codes are for client logic
Canonical Endpoint
POST/v1/users
{ "email": "ada@", "password": "abc" }
→ 422 Unprocessable Content, Content-Type: application/problem+json { "type": "https://api.example.com/errors/validation", "title": "Validation failed", "status": 422, "errors": [{ "field": "email", "code": "invalid_format", "detail": "Enter a valid email address" }, { "field": "password", "code": "too_short", "detail": "Use at least 12 characters", "min": 12 }] }

A structured body with a stable error type, a machine-readable code per field, and a human-readable detail lets clients highlight the right inputs and localize the messages. RFC 9457 Problem Details (application/problem+json) gives that shape a standard envelope, so every endpoint fails the same way. Option (a) forces clients to parse prose. Option (c) hides failure behind a 200, which breaks monitoring, retries, and every HTTP client's error handling. Option (d) reports a client mistake as a server fault and leaks a stack trace.

Dive Deeper

400 and 422 are both defensible for validation failures; a common split is 400 for malformed syntax, such as unparseable JSON, and 422 for well-formed requests that break business rules. Keep error codes stable and documented, since clients branch on them, and treat the detail text as free to change. Include a request ID in every error so support can find the server log, and never return internal exception names or stack traces.

Your internal admin tool shows a sortable table of about 3,000 employee records. Admins jump straight to page 12 and expect to see 'Page 12 of 60'. The list changes a few times a day. Pick the best endpoint.

Users jump to arbitrary pages · The UI shows the total page count · The dataset is small and changes rarely
Canonical Endpoint
GET/v1/employees?page=12&pageSize=50&sort=lastName
→ 200 OK { "items": [...], "page": 12, "pageSize": 50, "totalItems": 2987, "totalPages": 60 }

Offset pagination is the right tool when users need random access and a total count over a small, slow-changing list. Page 12 is a single OFFSET 550 LIMIT 50 query, the count is cheap at 3,000 rows, and a stable sort makes pages repeatable. Cursors (a) suit deep, fast-changing feeds and can't jump to page 12. Returning everything (c) works today and breaks as the table grows. Option (d) uses POST for a read, which gives up caching and shareable links.

Dive Deeper

Offset cost grows with depth because the database still reads and discards the skipped rows, and concurrent inserts shift items between pages. Both effects are negligible at a few thousand rows, which is why admin tables, reports, and search results with a capped depth often use offsets. Add a unique tiebreaker to the sort (lastName, then id) so rows with equal keys never appear on two pages, and cap pageSize on the server.

Mobile clients download a 200 KB app configuration document on every launch, but it changes about once a week. Pick the best way to serve it.

Most requests should transfer no body · Clients must pick up a change on the next launch · Use standard HTTP caching
Canonical Endpoint
GET/v1/config
→ 200 OK, ETag: "cfg-v42", Cache-Control: no-cache; the next launch sends If-None-Match: "cfg-v42" and gets 304 Not Modified with no body until the config changes

An ETag with a conditional GET is HTTP's built-in way to say 'send it only if it changed'. The client stores the ETag, sends it back in If-None-Match, and the server answers 304 Not Modified with an empty body, so a weekly change costs one full download. Cache-Control: no-cache allows storing the response but requires revalidation before each use, which is what delivers the update on the next launch. Option (a) wastes bandwidth on every launch. Option (c) invents a custom protocol that proxies and HTTP client libraries can't help with. Option (d) lets clients run a stale config for up to a week.

Dive Deeper

no-cache means 'store it, but revalidate before use'; no-store is the directive that forbids caching. Weak ETags, marked with a W/ prefix, are fine for conditional GETs when semantically equal bodies count as unchanged. If the config is the same for every user, put it behind a CDN so revalidations never reach the origin. Last-Modified with If-Modified-Since works as a fallback, but its one-second resolution can miss rapid changes.

Your checkout API returns order totals to web and mobile clients in several currencies, including JPY, which has no minor unit. Pick the best way to represent an amount.

No floating point rounding errors · Works for currencies with 0, 2, or 3 decimal places · Clients format amounts for display themselves
Canonical Endpoint
GET/v1/orders/{id}
→ 200 OK { "total": { "amount": 1999, "currency": "USD" } }; a JPY order returns { "amount": 500, "currency": "JPY" } for 500 yen

An integer in the currency's minor unit plus an ISO 4217 code is exact and unambiguous in every language: 1999 USD is $19.99, and 500 JPY is 500 yen because yen has no minor unit. Clients then format with their locale's rules. Binary floats (a, d) can't represent most decimal fractions exactly, so sums drift by fractions of a cent, and (a) omits the currency entirely. A preformatted string (c) forces clients to parse it and bakes one locale's symbol and separators into the API.

Dive Deeper

Document the exponent for each currency (2 for USD, 0 for JPY, 3 for KWD) or include it in the payload, since clients need it to display amounts. Some APIs send decimal strings such as '19.99' instead, which is also exact and readable; the firm rule is to keep binary floats away from money. Use the same representation in requests, responses, and webhooks, and round once, at the step the business rules define.

A third-party scheduling app wants to read your users' calendar events so it can suggest meeting times. It must never be able to create or delete events. Pick the best way to grant access.

Users approve access without sharing their password · Access is limited to reading events · Users can revoke one app without affecting others
Canonical Endpoint
GET/oauth/authorize?client_id=sched_app&response_type=code&scope=calendar.events.read&code_challenge=...
→ 302 to the consent screen; after approval the app exchanges the code at POST /oauth/token for a short-lived access token limited to calendar.events.read, plus a refresh token

The OAuth 2.0 authorization code flow with PKCE (Proof Key for Code Exchange) lets the user approve the app on your consent screen, and the narrow calendar.events.read scope limits the token to reading events. Tokens are short-lived and tied to one app, so revoking that grant leaves the user's other apps untouched. Option (a) hands out a full-access key the user can't narrow. Option (c) gives a third party the user's password. Option (d) uses the implicit flow, which the OAuth security best current practice deprecates, with a broad scope that allows writes.

Dive Deeper

Name scopes by resource and action (calendar.events.read, calendar.events.write) so consent screens read clearly and checks map directly to code. Enforce scopes in middleware or at the gateway on every endpoint, and return 403 with an insufficient_scope error when a token lacks one. Keep access tokens short-lived, from minutes to an hour, rotate refresh tokens on use, and give users a page listing each connected app with a revoke button.

Your product has a web app, an iOS app, and a watch app. Each home screen needs a different slice of the same data (profile, recent orders, recommendations, and unread messages), and today each screen makes five or six REST calls. Pick the best API approach.

Each client gets exactly the fields it needs in one round trip · Several frontend teams ship on their own schedules · Backend services keep ownership of their data
Canonical Endpoint
POST/graphql
{ "query": "{ me { name } recentOrders(first: 3) { id status } unreadCount }" }
→ 200 OK { "data": { ... } }; a gateway resolves each field from the owning service and enforces depth and cost limits per query

GraphQL fits several clients that need different shapes of the same data: each client asks for exactly its fields, gets them in one round trip, and adds fields without waiting on a backend release. A gateway resolves each field from the service that owns it, so ownership stays where it is. The fixed payload (a) overfetches for the watch and grows with every client. Branching on a client parameter (c) turns one endpoint into a pile of special cases, though a dedicated backend for each frontend is a sound alternative with only two or three clients. Separate calls (d) cost five or six round trips on slow mobile networks.

Dive Deeper

GraphQL moves cost from the client to the server. Plan for N+1 resolver queries (batch them with a DataLoader), cap query depth and cost so one request can't fan out to millions of rows, and use persisted queries so production clients send an ID instead of arbitrary query text. HTTP caching weakens because most requests are POSTs to one URL, so caching moves into the client and the resolvers. For a single client with stable needs, REST stays simpler.

Two internal services exchange about 40,000 small requests per second: the pricing service asks the inventory service for stock levels, and inventory must also stream stock changes back as they happen. Both teams want a typed contract checked at build time. Pick the best API approach.

Latency and payload size matter at this volume · Server-to-client streaming is required · Contract changes should fail at compile time
Canonical Endpoint
RPC/inventory.v1.InventoryService/GetStock and /WatchStock
rpc GetStock(GetStockRequest) returns (GetStockResponse); rpc WatchStock(WatchStockRequest) returns (stream StockEvent);
→ Protobuf over HTTP/2 with stubs generated for both services; WatchStock pushes each change on a long-lived stream

gRPC fits high-volume internal calls with a shared contract: Protobuf messages are compact and fast to parse, generated stubs turn contract breaks into compile errors, HTTP/2 multiplexes many calls over one connection, and server streaming delivers stock changes as they happen. JSON over HTTP (a) costs more to encode and parse at 40,000 requests a second, and polling adds up to a second of staleness plus constant load. GraphQL subscriptions (c) add a query layer two backend services don't need. SOAP (d) has typed contracts but heavy XML payloads and thin modern tooling.

Dive Deeper

gRPC's costs show up at the edges. Browsers can't call it directly without gRPC-Web or a proxy, payloads are unreadable without tooling, and load balancers must balance per request over HTTP/2, or long-lived connections pin traffic to one backend. Evolve Protobuf schemas by adding fields with new numbers, never reuse or renumber old ones, and reserve the numbers of removed fields. Set a deadline on every call so a slow dependency can't hold threads indefinitely.

Your v1 user resource has a field called name. Product wants to split it into givenName and familyName. Older mobile app versions read name and will stay in use for a year. Pick the best way to ship the change.

Existing clients must keep working without an update · New clients should move to the new fields · Avoid a whole /v2 for one field
Canonical Endpoint
GET/v1/users/{id}
→ 200 OK { "id": "u_1", "name": "Ada Lovelace", "givenName": "Ada", "familyName": "Lovelace" }; name is marked deprecated in the schema and docs, its reads are tracked by client version, and it is removed in the next major version

Adding a field is backward compatible, while removing or retyping one breaks clients. Ship givenName and familyName alongside name, mark name deprecated, and keep populating it so old apps keep working. Track which clients still read it, then remove it in the next major version once usage is near zero. Option (a) breaks every old app immediately. Option (c) forces every client onto a new version for one field. Option (d) changes the type of an existing field, which crashes clients that expect a string.

Dive Deeper

Additive change only works if clients ignore unknown fields, so make that a documented client requirement from day one. On writes, accept both forms during the transition and define which wins if both arrive. Removal needs evidence: log reads of the old field by client version, announce a date, and send Deprecation and Sunset headers. Enums follow the same rules, since adding a value can break clients with exhaustive switches, so document that new values may appear.

Your notes app works offline on phones. When a device reconnects, it needs every note created, edited, or deleted since its last sync, out of tens of thousands of notes. Pick the best endpoint.

Deletions must reach the device too · Changes that commit during a sync must not be missed · A sync after a short offline period should be tiny
Canonical Endpoint
GET/v1/notes/changes?syncToken=st_8f2a&limit=500
→ 200 OK { "changes": [{ "id": "n_1", "op": "upsert", "note": {...} }, { "id": "n_9", "op": "delete" }], "nextSyncToken": "st_91bc", "hasMore": false }

A changes feed keyed by an opaque sync token is the standard incremental sync design. The server issues tokens from its own change order, returns upserts and delete tombstones in sequence, and pages large backlogs with hasMore. A device that was offline for an hour downloads only that hour's changes. Full downloads (a) waste bandwidth on every reconnect. A timestamp from the device clock (b) misses changes under clock skew and never reports deletions. A server-side modified flag (d) breaks as soon as a user has two devices.

Dive Deeper

Back the token with a log position from change data capture or a commit-ordered sequence; plain timestamps and IDs assigned before commit can become visible out of order and skip rows. Keep tombstones long enough to cover the longest expected offline period, and return 410 Gone with a full resync instruction when a token is older than that. Clients should apply changes idempotently, since a retried page can repeat items.