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.
Critique these endpoints and redesign them to follow resource-based REST conventions: GET /getUser?id=123, POST /user/create, GET /v1/get-all-books, and /accounts/123/transactions?start=.... For each, say specifically what is wrong (a verb in the path, inconsistent pluralization, an ambiguous or missing resource identifier) and show your redesigned path and method.
Sample Answer
Direct answer. All four endpoints violate resource-based conventions by putting a verb or an ambiguous shape in the path where a noun-and-HTTP-method combination should carry that meaning instead.
1) GET /getUser?id=123 -> GET /users/123. The verb "get" in the path is entirely redundant: the HTTP method GET already says "read," and the path should just identify the resource (a specific user) as a noun with its id as a path parameter, not a query parameter, since the id is not an optional filter, it is the resource's own identity.
2) POST /user/create -> POST /users. Two problems: the "create" verb is redundant (POST to a collection already conventionally means "create a new member of it"), and the resource noun is singular ("user") when it should be the plural collection ("users") being posted into.
3) GET /v1/get-all-books -> GET /v1/books. Same redundant-verb problem ("get-all"), plus the hyphenated multi-word verb phrase compounds it; a plain plural collection name, with GET as the method, already communicates "list all books" without any verb needed in the path at all.
4) /accounts/123/transactions?start=... -> mostly fine already, worth keeping as GET /accounts/123/transactions?start=...&end=.... This one is actually a reasonably good example of RESTful nesting (a transaction genuinely belongs to and is scoped by its account) with query parameters correctly used for a date-range FILTER rather than for identifying the resource itself; the only real gap is that the HTTP method was left unstated in the original, which matters, since the same path could describe either GET (list transactions) or, if this were a POST, something entirely different (create a transaction) with no way to tell from the URL shape alone.
Why these choices, generally. Pluralization is applied consistently to every collection (users, books), never mixed with singular naming for no reason. Nesting is used only where 4) already earns it (a transaction genuinely belongs to one account) and not forced onto the others, which do not have an obvious required parent. Query parameters are reserved for filtering, sorting, and pagination (as in the date-range example), never for identifying WHICH resource a request is about, which belongs in the path as in the /users/123 fix.
Trade-offs and pitfalls. The most common residual mistake even after fixing the obvious verbs is to leave query parameters doing double duty (both filtering AND identifying a resource), which example 1's original design did by putting the user's own id in a query parameter instead of the path; a resource's own identity always belongs in the path, never in a query string.
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.
Explain how Cache-Control, ETag, Last-Modified, and Vary work together to let a client avoid re-downloading a resource it already has. Walk through the exact sequence of headers exchanged on a cache hit versus a cache miss for a GET request, and say when you would reach for stale-while-revalidate instead of a hard max-age.
Sample Answer
Direct answer. Cache-Control tells any cache (browser, CDN, shared proxy) how long a response can be reused and who is allowed to store it; ETag and Last-Modified let a client ask "has this actually changed since I last saw it" so the server can answer with an empty 304 instead of resending the whole body; Vary tells a cache that the SAME URL can have different valid responses depending on a request header (like Accept-Language), so it must not serve one client's cached copy to another.
The header exchange.
Cache miss (first request):
GET /products/42 HTTP/1.1
HTTP/1.1 200 OK
Cache-Control: public, max-age=60
ETag: "a1b2c3"
Last-Modified: Tue, 28 Jul 2026 09:00:00 GMT
The client (or an intermediate cache) stores the body along with the ETag and remembers it is fresh for 60 seconds.
Within 60 seconds: the cache serves its stored copy directly, no request to the origin at all; this is the fast path and the entire point of Cache-Control.
After 60 seconds (cache hit, revalidation):
GET /products/42 HTTP/1.1
If-None-Match: "a1b2c3"
If-Modified-Since: Tue, 28 Jul 2026 09:00:00 GMT
HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=60
Last-Modified: Tue, 28 Jul 2026 09:00:00 GMT
Most real APIs send both validators together, as shown above: ETag for a precise, byte-level check and Last-Modified as a coarser, human-readable fallback for a cache that only understands the older mechanism. Per the HTTP caching spec, when a client sends both If-None-Match and If-Modified-Since, the server MUST evaluate If-None-Match first and ignore If-Modified-Since once that comparison already resolves the request, since ETag is the more precise of the two.
The resource has not actually changed, so the server sends only a status line and fresh Cache-Control, no body at all. This is meaningfully cheaper than a full 200 response when the underlying resource changes rarely relative to how often it is read.
After 60 seconds (cache miss, resource actually changed):
GET /products/42 HTTP/1.1
If-None-Match: "a1b2c3"
If-Modified-Since: Tue, 28 Jul 2026 09:00:00 GMT
HTTP/1.1 200 OK
Cache-Control: public, max-age=60
ETag: "d4e5f6"
Last-Modified: Tue, 28 Jul 2026 09:30:00 GMT
A new ETag means the client discards its stale copy and stores the fresh one.
When to reach for stale-while-revalidate instead of a hard max-age. Cache-Control: max-age=60, stale-while-revalidate=600 tells the cache: serve the stale copy IMMEDIATELY if it is within 600 seconds of going stale, while fetching a fresh copy in the background for the NEXT request. Use this for content where a few extra seconds of staleness is invisible to the user but a slow, blocking round-trip to the origin on every 61st-second request is not (a product listing page, a news feed), as opposed to a hard max-age where you genuinely need every request past the TTL to wait for a fresh answer (an inventory count you cannot afford to show as stale even briefly).
Trade-offs and pitfalls. Vary is easy to forget and expensive to get wrong in both directions: omit it when you actually vary by a header, and a shared cache serves the wrong locale's or wrong content-negotiated response to the wrong client; add it for a header that never actually changes the response, and you needlessly fragment your cache into many near-identical copies that each individually cache-miss more often.
Design a REST API for a long-running bulk job (for example a bulk data export) that must support submit, status polling, pause, resume, and cancel, plus resuming correctly after a failure. Define the job's state machine and which transitions are valid from which states, ensure operations stay idempotent under retries, and describe what the client-visible progress and error model looks like.
Sample Answer
Direct answer. Model the job as an explicit state machine (queued, running, paused, cancelled, failed, completed), expose one action endpoint per valid transition rather than a single generic status update, and make every transition idempotent under retry the same way a single mutating endpoint would be.
The state machine. States: queued -> running -> (paused <-> running) -> completed, with cancelled and failed reachable from queued, running, or paused, but not from completed. Each transition is its own endpoint: POST /jobs/{id}/pause, POST /jobs/{id}/resume, POST /jobs/{id}/cancel, plus GET /jobs/{id} for status and progress. A transition request that does not correspond to a legal edge from the job's CURRENT state (say, resuming a job that already completed) returns 409 Conflict, naming both the attempted transition and the actual current state, the same discipline as the order-lifecycle state-machine design.
Idempotency for each transition. Each action endpoint accepts an Idempotency-Key the same way a POST /orders create endpoint would: retrying POST /jobs/{id}/pause with the same key after a dropped connection replays the stored result (confirming the job is now paused) rather than erroring or double-processing the pause. This matters more here than for a single mutating endpoint precisely because a long-running job's client is far more likely to experience a network interruption mid-operation, simply because the whole point of the job is that it takes a long time.
Resuming correctly after a failure. On resume (whether client-initiated after a deliberate pause, or automatic after a worker crash mid-job), the job must pick up from its last durably-recorded checkpoint, not restart from the beginning; this requires the job's own internal progress to be persisted incrementally (a processed-so-far marker written to durable storage as the job runs), not held only in the worker process's memory, so a crash loses at most the work since the last checkpoint, not the whole job.
Client-visible progress and error model. GET /jobs/{id} returns the current state, a progress indicator (items processed out of an estimated or exact total, when knowable), and, on a failed state, a structured error explaining what went wrong and whether the failure is one the client can address (bad input data) versus one that is purely operational (a transient infrastructure failure the client should simply retry the whole job for). Progress should be a real, monotonically-increasing signal derived from checkpoints, not simply "polling started N seconds ago", so a client (or a monitoring dashboard) can distinguish a genuinely stuck job from one that is legitimately still working through a large dataset.
Trade-offs and pitfalls. The most common mistake is implementing pause as "stop processing but keep the in-progress state only in the worker's memory," which looks correct in every test where the same worker process later resumes it, and silently loses all progress the first time an actual resume happens against a different worker instance after the original one was recycled.
Compare URI-path versioning (/v1/resource), header-based versioning (a custom Accept media type), and query-parameter versioning (?version=1) for exposing multiple API versions at the same time. For a public B2B API with many long-lived client integrations, which mechanism would you default to, and how does your choice interact with caching, discoverability, and how much complexity it pushes onto the client?
Sample Answer
Direct answer. For a public B2B API with many long-lived client integrations, default to URI-path versioning (/v1/, /v2/), because it is the most discoverable and the most compatible with the widest range of client tooling, proxies, and caches; header-based versioning is a legitimate alternative when you specifically want version selection to be invisible in the URL, at the cost of being harder for a developer to discover just by looking at a request.
URI-path versioning (/v1/users). Maximally discoverable (a developer can see the version in every request and every piece of documentation without inspecting headers), trivially cacheable (the version is part of the cache key automatically, since it is part of the URL), and compatible with every HTTP client, proxy, and browser address bar without special support. The downside: it implies the ENTIRE API surface moves in lockstep between versions, which is not always true (often only a handful of endpoints actually changed), and "the same resource lives at two different URLs" can feel philosophically at odds with REST's idea that a URI identifies one resource.
Header-based versioning (Accept: application/vnd.myapi.v2+json). Keeps the URL itself stable across versions, which is arguably more RESTfully "correct" (the resource's identity does not change just because its representation format evolved) and lets you version different resources independently if you choose to. The cost: it is invisible to a quick glance at a URL, harder to test by pasting a URL into a browser, and it interacts poorly with caches and proxies that key on the URL alone and are not configured to also vary on this custom header (the response needs an explicit Vary header naming it, and not every intermediate cache respects that correctly).
Query-parameter versioning (?version=1). The weakest of the three in practice: it is easy to omit accidentally (defaulting to whatever the server picks, silently, if the developer forgets the parameter) and it does not compose cleanly with caching or with existing query parameters used for filtering, since a cache or proxy has to know that THIS particular parameter, unlike most others, actually changes the shape of the response entirely.
Why URI-path for THIS specific scenario. Many long-lived integrations means you will realistically be running two or three versions concurrently for a long time, and you want every one of those integrator teams to be able to look at their own code, or a support ticket's request log, and see immediately which version they are on, without any header inspection; that discoverability benefit outweighs the philosophical purity argument for header-based versioning once the number of long-lived external client teams gets large.
Trade-offs and pitfalls. Whichever mechanism you pick, the mistake that causes real pain later is not deciding it up front and mixing conventions ad hoc across endpoints as they evolve; a client integrating against your API should never have to learn a second versioning convention for a different endpoint of the same API.
Unlock Full Question Bank
Get access to all 34 RESTful API Design interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.