RESTful API Design Questions
Designing resource-oriented HTTP APIs following REST constraints: resource modeling, URI structure, correct use of HTTP methods, statelessness, and HATEOAS trade-offs. Covers naming conventions, collection vs. singleton resources, filtering/sorting/pagination, and choosing appropriate status codes. The default paradigm most interview questions in this category probe.
Design a globally distributed, read-heavy product catalog API: writes happen centrally in one region, but reads need to be fast for users worldwide via regionally replicated caches. What does your API contract need to expose so clients understand the consistency they are getting (for example, how fresh a read might be after a recent write in another region), and how do you invalidate the regional caches when the central write happens?
Sample Answer
Direct answer. Expose a consistency signal directly in the API contract (a resource version or a last-write timestamp on every response, plus a way for a client to explicitly request the freshest possible read) rather than leaving clients to guess how stale a regional cache might be, and invalidate regional caches via an event propagated from the write region rather than a synchronous cross-region call on every write.
What the contract needs to expose. Every read response includes a version or timestamp field (updated_at or an opaque version token) representing when that specific piece of data was last written at the source of truth. A client that just performed a write (or needs a guaranteed-fresh read for some specific reason, a payment confirmation for instance) can pass that version back on a subsequent read via a header (something like X-Min-Freshness: <version>), and a region whose replica has not yet caught up to that version can either serve a slightly slower "read from the primary region" fallback, or return a distinct signal telling the client the data it has is not fresh enough yet, rather than silently serving a stale answer with no indication anything is off.
A concrete trace. Say product SKU SKU-48213 is priced at $24.99, and a write updates it to $29.99 in the write region (US-EAST) at 14:32:07 UTC, bumping the resource's version from v104 to v105. A read that hits the eu-west regional cache two seconds later, at 14:32:09 UTC, still returns version: v104, price: $24.99 -- the stale, pre-write value -- because eu-west's cache-invalidation event for this key has not landed yet. If the client that just performed the write immediately sends X-Min-Freshness: v105 on its next read, eu-west (still holding v104) recognizes its cached copy does not meet that minimum: it either forwards the read to the primary region for a guaranteed-fresh v105 answer (the slower fallback), or responds with a distinct "not fresh enough yet" signal instead of silently handing back the stale $24.99. This trace is illustrative, not a measured production timing -- the actual invalidation-lag window depends on your own event-propagation latency, not a fixed 2 seconds.
Regional cache invalidation on a central write. Writes commit centrally, then publish an invalidation (or an updated version marker) as an EVENT to all regions, rather than the write path synchronously waiting for every region's cache to be updated (which would defeat the entire point of regional caching by making every write pay a global round-trip cost). Each region's cache subscribes to this event stream and evicts or updates the affected entries asynchronously; the trade-off is explicit and honest: a regional read immediately after a write elsewhere may briefly return the OLD cached version until that region's invalidation catches up, and the contract's version/timestamp field is exactly what lets a client detect that condition rather than being surprised by it.
Communicating the trade-off to clients. Document plainly, per endpoint, which consistency guarantee it actually offers: this is not a single global answer for the whole API, some endpoints (a payment status check) may need to route to the primary region for a guaranteed-fresh read regardless of latency cost, while others (a public product listing) are entirely fine serving a regionally-cached, occasionally-a-few-seconds-stale response in exchange for much lower latency worldwide.
Trade-offs and pitfalls. The most common mistake is treating "eventually consistent" (meaning every region's cached copy will converge on the same value given enough time, just not immediately -- so a read right after a remote write, as in the v104/v105 trace above, can briefly return the old value) as an implementation detail that does not need to be surfaced in the contract at all; a client that is never told a read might be stale has no way to build correct behavior around it (for instance, retrying a read after a write until it observes the new version), and ends up discovering the staleness window in production as a confusing, hard-to-reproduce bug instead of a documented, designed-for property of the API.
For each of GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS, state whether it is safe, whether it is idempotent, and whether it is cacheable, then give a short example endpoint where using the wrong method caused a real bug (for instance, a client retry duplicating a purchase, or a caching layer serving a stale response for a method it should not have cached).
Sample Answer
Direct answer. GET, HEAD, and OPTIONS are safe (they must not change server state) and idempotent (calling them N times has the same effect as calling them once). PUT and DELETE are idempotent but not safe. POST is neither safe nor idempotent by default. PATCH is technically unspecified but should usually be treated as non-idempotent unless you deliberately design it to be. Cacheability tracks safety closely but is not identical to it: GET and HEAD are cacheable by default, OPTIONS is safe but has no real caching convention, and none of PUT, DELETE, POST, or PATCH are cacheable by default.
The full classification.
| Method | Safe | Idempotent | Cacheable | Typical use |
|---|---|---|---|---|
| GET | yes | yes | yes, by default | read a resource, no side effects |
| HEAD | yes | yes | yes, by default | GET without a body, for existence/metadata checks |
| OPTIONS | yes | yes | no | discover allowed methods, CORS preflight |
| PUT | no | yes | no | replace a resource entirely at a known URI |
| DELETE | no | yes | no | remove a resource; deleting twice leaves the same end state (gone) |
| POST | no | no | no by default | create a new resource, or trigger a non-idempotent action |
| PATCH | no | usually not | no | partially update a resource |
Why cacheability does not just follow safety. GET and HEAD are cacheable by default because a cache can reuse their response without risking a stale side effect, the same property that makes them safe in the first place. OPTIONS is also safe, but nothing about discovering allowed methods or a CORS preflight benefits from caching the way a resource representation does, so there is no real caching convention for it in practice. POST responses CAN technically be cached per the HTTP spec if the response carries explicit freshness information (Cache-Control or Expires), but this is rarely implemented, so treat POST as effectively not cacheable. PUT, DELETE, and PATCH have no meaningful default caching semantics either: caching the result of a mutation makes little sense when the whole point of the call was to change state.
Why this matters beyond vocabulary. Idempotency is the property that makes retries safe. If a client's network call to a PUT times out and it retries, the end state is identical whether the first request actually landed or not, because PUT is defined as "the resource now looks like this", not "apply this delta". A POST retried the same way can create two resources, because POST means "do this action again", and doing a creation action twice creates two things. Safety is what a cache, a browser prefetcher, or a crawler relies on: none of them should ever issue a POST speculatively, because a safe method is one where the caller assumes no side effect happened.
A real bug from getting this wrong. A checkout flow implemented "add item to cart" as a GET request (because it was convenient to trigger from a link). A corporate web-security scanner crawled every link on the page, including that one, adding dozens of items to real users' carts, because the scanner (correctly, per the HTTP contract) assumed GET was safe to call without consequence. The fix was not to block the scanner, it was to make cart mutation a POST, which is exactly what the safety property exists to protect against.
Trade-offs and pitfalls. The subtlest mistake is assuming PATCH is idempotent by default. A PATCH body of {"counter": "increment"} is not idempotent (retrying it increments twice); a PATCH body of {"counter": 5} (set to an absolute value) is. The method name alone does not tell a client which one they are getting, so this needs to be documented per endpoint, not assumed from the HTTP verb.
Implement an idempotent POST /orders endpoint (Python, Flask or FastAPI) that reads an Idempotency-Key header and uses it to prevent duplicate order creation when a client retries. Show the database schema you would use to store keys against their result, the transaction boundaries, and what the server returns when a second request arrives with the same key while the first one is still being processed.
Sample Answer
Direct answer. Read the Idempotency-Key header; if a request with that key has already completed, replay the stored response verbatim instead of re-running the creation logic; if one is currently in flight, reject the duplicate with a distinct status instead of racing it.
Implementation (Python, Flask). This uses an in-memory store for a self-contained, runnable example; in production the same design sits on a Postgres table with a unique index on the idempotency key, so the have-I-seen-this-key check and the reserve-it step are atomic at the database level, not just inside a Python lock.
import threading
import uuid
from flask import Flask, request, jsonify
app = Flask(__name__)
# Stand-ins for a Postgres "idempotency_keys" table and an "orders" table.
idempotency_keys = {}
orders = {}
lock = threading.Lock()
@app.post("/orders")
def create_order():
key = request.headers.get("Idempotency-Key")
if not key:
return jsonify({"error": "Idempotency-Key header is required"}), 400
body = request.get_json(force=True)
with lock:
existing = idempotency_keys.get(key)
if existing is not None:
if existing["status"] == "in_progress":
return jsonify({"error": "already being processed"}), 409
return jsonify(existing["response_body"]), existing["response_status"]
idempotency_keys[key] = {"status": "in_progress", "response_body": None, "response_status": None}
order_id = str(uuid.uuid4())
orders[order_id] = {"id": order_id, "item": body.get("item"), "qty": body.get("qty")}
response_body = {"order_id": order_id, "item": body.get("item"), "qty": body.get("qty")}
response_status = 201
with lock:
idempotency_keys[key] = {"status": "done", "response_body": response_body, "response_status": response_status}
return jsonify(response_body), response_status
if __name__ == "__main__":
client = app.test_client()
r1 = client.post("/orders", json={"item": "widget", "qty": 3}, headers={"Idempotency-Key": "key-abc"})
print("first request ->", r1.status_code, r1.get_json())
r2 = client.post("/orders", json={"item": "widget", "qty": 3}, headers={"Idempotency-Key": "key-abc"})
print("retry request ->", r2.status_code, r2.get_json())
print("same order_id returned on retry:", r1.get_json()["order_id"] == r2.get_json()["order_id"])
print("total orders actually created:", len(orders))
Output (actually run):
first request -> 201 {'item': 'widget', 'order_id': '72664f4a-d7da-4618-8e70-38c7bff94a38', 'qty': 3}
retry request -> 201 {'item': 'widget', 'order_id': '72664f4a-d7da-4618-8e70-38c7bff94a38', 'qty': 3}
same order_id returned on retry: True
total orders actually created: 1
Key points. The reservation of the key (marking it in_progress) happens before the real work starts, inside the same lock as the lookup, closing the race where two near-simultaneous retries both pass the have-I-seen-this check. The stored result is the exact response (body and status), replayed verbatim, not re-derived.
Complexity. Each request does O(1) dictionary work plus the actual creation work; the lock is held only for the cheap lookup-and-reserve step, not for the full creation logic, so concurrent requests with different keys are not serialized against each other in the real (database-backed) version, only requests racing on the same key are.
Edge cases. A second request with the same key but a different body (a client bug, not a legitimate retry) is not checked in this minimal example; a production version should also compare a hash of the request body against what was stored for that key, and reject the request with a 422 if they do not match, since replaying the original response to what looks like a different logical request would silently hide the client bug.
You are designing the error handling for an API. Pick the single most appropriate HTTP status code, and briefly justify it, for each of: malformed JSON in the request body, a validation error on a business rule, an authentication failure, an authorization failure, a request for a resource that does not exist, a request accepted for asynchronous processing, and a successful resource creation versus a successful update with no content to return. Name one common mistake engineers make on at least two of these.
Sample Answer
Direct answer. Malformed JSON is 400 Bad Request. A business-rule validation error is also 400 (or 422 Unprocessable Entity if you distinguish syntactically-valid-but-semantically-wrong from not-even-parseable). An authentication failure (no or invalid credentials) is 401 Unauthorized. An authorization failure (valid credentials, insufficient permission) is 403 Forbidden. A missing resource is 404 Not Found. Work accepted for asynchronous processing is 202 Accepted. A successful creation is 201 Created; a successful update with nothing new to return is 200 OK (or 204 No Content if the body is genuinely empty).
Working through the scenario.
- Malformed JSON in the request body: the server never even got a well-formed request to evaluate, so this is a client error before any business logic runs. 400.
- A validation error on a business rule (say, a date field that must not be in the past): the JSON parsed fine, but the content violates a rule. 400 is defensible; many APIs use 422 specifically to separate "your request is not even shaped right" from "your request is shaped right but the values are invalid." Pick one convention and apply it consistently across the whole API.
- Authentication failure: 401, and the response should include a WWW-Authenticate header naming the expected scheme.
- Authorization failure: 403. The distinction from 401 matters for the client: 401 means try again with different credentials, 403 means these credentials are fine, you just cannot do this.
- Resource not found: 404. For a public API, be deliberate about whether a 404 for does-not-exist versus exists-but-you-cannot-see-it leaks information; many APIs intentionally return 404 for both to avoid confirming a private resource's existence to an unauthorized caller.
- Accepted for async processing: 202, ideally with a Location header pointing at a status resource the client can poll.
- Successful creation vs. update-with-nothing-new: 201 with the new resource's URI in Location for creation; 200 with a body, or 204 with no body, for an update, depending on whether you have anything useful to return.
Common mistake. Returning 200 for every successful outcome regardless of what actually happened, including creations (should be 201) and validation failures caught late in a handler (silently returning {"success": false} with a 200 status). This breaks every generic HTTP client, cache, and monitoring tool that reasons about status codes rather than parsing your custom body shape, and it is one of the most common REST code-review findings.
Trade-offs and pitfalls. 401 vs. 403 is the pair engineers mix up most: 401 always means I do not know who you are, or I do not believe your credentials; 403 always means I know exactly who you are, and the answer is still no.
For a public partner API, an internal high-throughput service mesh, and a mobile client on unreliable networks, would you reach for REST, GraphQL, or gRPC? Compare the three on caching, client flexibility, network overhead, versioning, and developer ergonomics, and justify a different choice (or the same one) for each of the three scenarios.
Sample Answer
Direct answer. REST for the public partner API (broad client reach, cacheability, and simplicity matter most and you cannot control what partners' HTTP tooling supports); gRPC for the internal high-throughput service mesh (binary framing, streaming, and generated strongly-typed clients matter more than human-readability once every caller is a service you control); and REST, or GraphQL if the client genuinely needs to shape a nested response itself, for the unreliable mobile client, prioritizing tolerance of dropped connections and minimizing round trips over raw throughput.
Comparison across the five axes.
- Caching: REST's biggest structural advantage. A GET request maps naturally onto HTTP caching (Cache-Control, ETag, CDN edge caching) because the URL itself identifies the cacheable resource. GraphQL's single POST-based endpoint and gRPC's binary RPC calls both defeat generic HTTP caching by default; both need a purpose-built caching layer if they want it at all.
- Client flexibility: GraphQL wins outright, since the client specifies exactly which fields it wants in one round trip; REST either over-fetches (returns the whole resource) or requires bespoke
?fields=parameters per endpoint; gRPC returns whatever the fixed protobuf schema defines, no client-side shaping at all. - Network overhead: gRPC wins, using compact binary Protocol Buffers (a binary message format — smaller on the wire and faster for both sides to parse than text-based JSON, at the cost of not being readable directly in a browser's network tab) over HTTP/2 multiplexing (many requests and responses share one underlying connection at the same time, instead of the client opening a new connection per request); REST and GraphQL both typically ship JSON, which is larger and slower to parse, though GraphQL's field-selection can reduce payload size by avoiding over-fetch even with the same wire format.
- Versioning: REST has the most established conventions (URI/header/media-type, well understood by every client). GraphQL favors additive, non-breaking schema evolution with field deprecation over versioned endpoints entirely. gRPC's protobuf has strict, well-defined field-numbering rules for safe evolution, but breaking those rules (reusing a field number) is a hard, occasionally very difficult to detect production bug.
- Developer ergonomics: REST is the lowest common denominator, understood by literally every HTTP client, browser dev tool, and API testing tool with zero extra tooling. GraphQL and gRPC both need generated client code or specialized tooling to get real ergonomic benefit, which is friction for a public partner integration but a non-issue for an internal service mesh where every caller already runs your codegen pipeline (codegen — client-side code automatically generated from the service's schema, rather than hand-written by each team calling it).
Applying this to the three scenarios. A public partner API optimizes for the lowest integration cost for partners you do not control and for free CDN caching; REST wins on both. An internal high-throughput mesh optimizes for raw efficiency between services you fully control and can codegen against; gRPC wins. A mobile client on unreliable networks optimizes for minimizing round trips and tolerating partial failure; either a well-designed REST API with sparse fieldsets (a ?fields= query parameter letting the client name only the fields it actually needs back, so the server skips returning the rest of the resource), or GraphQL if the client's data needs are genuinely nested and variable screen-to-screen, both beat a chattier multi-call REST design, and gRPC's lack of native browser/mobile-friendly tooling (without a gRPC-Web proxy layer — an extra translation service that sits in front of gRPC, since a browser cannot speak raw HTTP/2 gRPC directly) makes it the weakest fit here despite its raw efficiency.
Trade-offs and pitfalls. The mistake to avoid is picking one paradigm company-wide "for consistency"; the three scenarios above genuinely optimize for different things, and a mesh forced into REST-for-consistency pays a real latency and serialization cost with no corresponding benefit, just as a public API built in gRPC for consistency loses the free caching and universal client support that made REST the right call there.
Unlock Full Question Bank
Get access to all 39 RESTful API Design interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.