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.
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 a POST /tasks endpoint (Node.js with Express) that accepts JSON {title, dueDate}, validates that title is non-empty, persists the task to an in-memory store, and returns 201 Created with a Location header pointing at /tasks/{id} and the new task's id in the body. Handle malformed JSON and validation failures with an appropriate 4xx response.
Sample Answer
Direct answer. Validate the request body before touching storage, return 201 Created with a Location header pointing at the new resource's URL on success, and return a structured 4xx for either a validation failure or malformed JSON, never a 200 for any of these outcomes.
Implementation (Node.js, Express).
const express = require('express');
const app = express();
app.use(express.json());
const tasks = {};
let nextId = 1;
app.post('/tasks', (req, res) => {
const { title, dueDate } = req.body || {};
if (typeof title !== 'string' || title.trim() === '') {
return res.status(400).json({ error: 'title is required and must be a non-empty string' });
}
const id = String(nextId++);
tasks[id] = { id, title, dueDate: dueDate || null };
res.status(201).location(`/tasks/${id}`).json({ id });
});
app.use((err, req, res, next) => {
if (err.type === 'entity.parse.failed') {
return res.status(400).json({ error: 'malformed JSON in request body' });
}
next(err);
});
async function main() {
const server = app.listen(0);
const port = server.address().port;
const base = `http://127.0.0.1:${port}`;
const r1 = await fetch(`${base}/tasks`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Write the answers', dueDate: '2026-08-01' }),
});
console.log('valid create ->', r1.status, 'Location:', r1.headers.get('location'), await r1.json());
const r2 = await fetch(`${base}/tasks`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: '' }),
});
console.log('empty title ->', r2.status, await r2.json());
const r3 = await fetch(`${base}/tasks`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: '{ this is not valid json',
});
console.log('malformed json ->', r3.status, await r3.json());
const r4 = await fetch(`${base}/tasks`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'No due date task' }),
});
const r4Body = await r4.json();
console.log('no dueDate ->', r4.status, r4Body, '| stored as:', tasks[r4Body.id]);
console.log('\ntasks actually stored:', Object.keys(tasks).length);
server.close();
}
main();
Output (actually run):
valid create -> 201 Location: /tasks/1 { id: '1' }
empty title -> 400 { error: 'title is required and must be a non-empty string' }
malformed json -> 400 { error: 'malformed JSON in request body' }
no dueDate -> 201 { id: '2' } | stored as: { id: '2', title: 'No due date task', dueDate: null }
tasks actually stored: 2
Key points. The Location header on the 201 response points at the new resource's own URL, which is what lets a client (or a generic HTTP tool) immediately follow up with a GET on the resource it just created, without having to construct that URL itself from the response body's id. Express's own JSON body-parser rejects malformed JSON before the route handler even runs, so the malformed-JSON case is handled by a dedicated error-handling middleware, not the route itself.
Complexity. O(1) validation and insertion per request; the in-memory object used here for storage is the one part of this example that would become a real database call in production, with everything else (validation, status codes, the Location header) unchanged.
Edge cases. A request with no dueDate still succeeds and is stored with dueDate: null, shown directly above rather than just asserted; a request whose Content-Type is not application/json is not exercised by this example but would need its own explicit handling (typically a 415 Unsupported Media Type) in a production version.
Design the REST API for a data-enrichment microservice that multiple downstream teams will call, needing to sustain 1,000 requests per second at a P95 latency target of 200 milliseconds. Specify the endpoints and request/response contract, your idempotency approach for retried writes, your error model, and how you version the contract as the enriched schema evolves. Sketch, at a high level, how you would validate the design can actually sustain that load.
Sample Answer
Direct answer. Design the API around a small, stable resource shape (an enrichment request/result pair), make writes idempotent from day one (idempotent meaning a retried request produces the exact same end result as the original one, so a client's automatic retry after a timeout never re-runs the enrichment or double-counts a record) given the explicit retry-heavy, multi-consumer context, and version the response schema separately from the endpoint path so downstream teams can adopt schema changes on their own timeline rather than a coordinated flag day.
Endpoints and contract.
POST /enrichmentsaccepts a batch of input records (bounded batch size, say up to 500 per call, to keep P95 latency achievable — P95 latency is the response time under which 95% of requests finish; the slowest 5% are allowed to take longer, which is a stricter bar than an average, since an average can look fine even while a meaningful tail of requests runs long) and anIdempotency-Keyheader; returns 202 Accepted with aLocationpointing at a status resource, since enrichment at this volume is realistically an asynchronous operation even if individual small batches complete quickly.GET /enrichments/{batchId}returns the batch's status and, once complete, the enriched results, or partial results with per-item status if some items in the batch succeeded and others failed.- The RESPONSE schema carries an explicit
schema_versionfield, separate from any URL versioning, so a downstream team's parser can check it and know exactly which fields to expect, without every consumer needing to move in lockstep with every schema change.
Idempotency approach. The Idempotency-Key on the batch submission covers the whole batch as a unit, the same design as a bulk-write endpoint: a retried submission with the same key replays the original batch's result rather than re-running the enrichment (which may call expensive downstream data sources) or double-counting records in whatever aggregate the enrichment service maintains. Given multiple downstream teams calling this service, each team's own key generation needs to be genuinely unique per LOGICAL batch, not accidentally shared across teams; namespacing the key by caller (or requiring the caller's own service identity as part of the key) prevents one team's retries from ever colliding with another's.
Error model. A per-item error structure (not just a single batch-level error) is essential here, since a batch of 500 enrichment requests failing entirely because ONE input record was malformed would be a poor contract for downstream teams; each item's result reports its own success/failure independently, with a batch-level summary count.
Schema versioning as the volume grows. Since "downstream teams" implies multiple independent consumers evolving at different speeds, prefer additive-only changes to the response schema (new optional fields) over breaking ones whenever possible, and reserve an actual version bump for the rare case an existing field's meaning or type must change; this keeps most schema evolution invisible to consumers who do not care about the new field, rather than forcing every consumer to move in lockstep.
Validating the design can sustain 1,000 requests per second at P95 200ms. At a high level: load-test the actual enrichment path (not just the API's own request handling) against realistic downstream-dependency latency, since the enrichment logic calling external or internal data sources is very likely the true bottleneck, not the HTTP layer itself; confirm the idempotency-key storage lookup (a single indexed read per batch) stays cheap under this load, since that lookup sits on every request's critical path; and measure P95, not average latency, specifically, since an average can look fine while a meaningful tail of requests blows past the 200ms target.
Trade-offs and pitfalls. The most common mistake at this specific intersection (idempotency plus versioning plus multiple independent consumers) is designing the idempotency key and the schema-versioning strategy in isolation from each other; a schema change that alters what a stored (already-completed) idempotency result even MEANS can make an old cached response invalid for a client expecting the new schema, which needs an explicit policy (does an idempotency-key replay always return the schema version it was originally created under, or the current one?) rather than being left to accident.
What conventions do you use for naming REST resources and endpoints: plural versus singular nouns, when to nest a resource under its parent (for example /users/{userId}/orders) versus keep it top-level, how to represent an action that is not plain CRUD without falling back to an RPC-style verb in the URL, and how you keep the number of endpoints from exploding as relationships between resources grow.
Sample Answer
Direct answer. Use plural nouns for collections (/users, not /user), nest a resource under its parent only when it genuinely cannot exist independently of that parent (/users/{userId}/orders, since an order without a user makes no sense in this domain), and never encode an action as a verb in the path (no /getUser or /createOrder); if an operation is not naturally CRUD-shaped, model it as a sub-resource or an explicit action endpoint under the resource it acts on, not a bare verb.
Plural vs. singular. Plural nouns for every collection endpoint, consistently, even for a resource that will usually only ever have one instance per parent (a user's single /profile is a defensible, deliberate exception, since it genuinely is not a collection); consistency here matters more than any individual argument for singular naming, since an API mixing /users and /order for no principled reason forces every client developer to memorize which convention applies where.
When to nest, and when not to. Nest when the child resource's identity is meaningless without the parent (a specific order's line items only make sense scoped to that order: /orders/{orderId}/items) and the child is always accessed IN that context. Do NOT nest when the resource has its own independent identity and is commonly accessed on its own (a specific order is meaningfully addressable as /orders/{orderId} directly, even though it also appears as one entry under /users/{userId}/orders); over-nesting (/users/{userId}/orders/{orderId}/items/{itemId}/reviews/{reviewId}) makes URLs unwieldy and couples every deep resource's addressability to knowing its entire ancestor chain, when a flatter /reviews/{reviewId} with the relationship expressed in the response body would serve most clients better.
Actions that are not plain CRUD. For a genuine action (cancel this order, publish this post), prefer a sub-resource action endpoint (POST /orders/{id}/cancel) over inventing a new HTTP verb or falling back to an RPC-style bare verb in the path; this keeps the resource-oriented structure while still allowing operations that do not map cleanly onto GET/PUT/PATCH/DELETE.
Avoiding endpoint explosion as relationships grow. As a domain accumulates more relationships (users have orders, orders have items, items have reviews, reviews have replies...), resist nesting every single one; instead, expose the DEEPLY nested resources at their own flat, top-level path (/reviews/{reviewId}, not /users/{userId}/orders/{orderId}/items/{itemId}/reviews/{reviewId}) and use query parameters or embedded links to express the parent relationship, rather than growing the URL structure to mirror the full entity-relationship diagram.
Trade-offs and pitfalls. The most common mistake is treating nesting depth as free; each additional level of nesting is a small but real tax on every client that has to construct or parse that URL, and a domain's relationships almost always grow faster than anyone expects when the API was first designed.
REST requires the server to hold no client session state between requests. Explain what statelessness does and does not forbid (a server may still hold data about the resource itself, just not about a specific client's conversation), and describe two concrete techniques for handling per-user needs like login sessions without server-side session state. What does statelessness buy you operationally when traffic spikes and an instance needs to be replaced, and what do you give up?
Sample Answer
Direct answer. Statelessness means every request must carry everything the server needs to process it: authentication, the resource being addressed, any filters or pagination position. The server is not allowed to remember what this client was doing between one request and the next. It is allowed to hold state about a resource (a row in a database), just not state about a specific client's conversation.
What it forbids, concretely. The classic violation is a login session: request 1 authenticates and the server stores "this session id is now logged in as user 42" in server memory; request 2 arrives with only the session id and the server looks up who that is from its own memory. That is exactly the per-client conversational state statelessness prohibits, because it means request 2 can only be served correctly by the specific server instance that handled request 1.
Two techniques that avoid it.
- Signed, self-contained tokens (e.g. a JSON Web Token, JWT). The client presents a token on every request; the server verifies its signature and reads the user identity and permissions directly out of the token, with no server-side lookup of who this session is. Any server instance can validate any request with only its own signing key, which is what makes statelessness pay off: you can add or remove instances freely.
- A session id backed by a shared, external store (for example Redis). The server still looks up session data, but the data lives outside any one instance's memory, so any instance can serve any request by querying the shared store. This is a middle ground: it is stateless from the server instance's point of view, even though state still exists somewhere.
What you get, and what you give up, when traffic spikes. With true statelessness (technique 1), you can add ten more instances behind a load balancer during a spike and route any incoming request to any of them, with zero coordination needed between instances, and you can kill an unhealthy instance immediately without worrying about losing anyone's conversation. What you give up: revocation is harder (a signed token is valid until it expires; you cannot instantly invalidate one without an extra deny-list mechanism), and the token itself grows with however much identity or permission data it carries, adding a small amount of bytes to every single request.
Trade-offs and pitfalls. Teams often reach for the shared-store approach (technique 2) because it feels like a smaller change from an in-memory session, but it quietly reintroduces a single dependency every request now needs, and if that store is slow or down, every request is affected, which is a different failure mode than the server that happened to hold your session being down.
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.