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.
HTTP/2 and HTTP/3 change some of the assumptions REST API design grew up under: request multiplexing over one connection, header compression, and (in HTTP/3) a transport built on QUIC instead of TCP. What actually changes for a REST API's design and operations when you move from HTTP/1.1 to HTTP/2 or HTTP/3, for example does the old advice to avoid too many small requests still apply, and what should change in your load-balancing and CDN configuration?
Sample Answer
Direct answer. HTTP/2's multiplexing removes the old "avoid too many small requests, batch them into one big one" advice almost entirely for requests to the SAME origin, since many requests can now share one connection with no head-of-line blocking at the HTTP layer; HTTP/3's move to QUIC (over UDP instead of TCP) additionally removes TCP-level head-of-line blocking and speeds up connection setup, which matters most on flaky mobile networks, and both change what your load balancer and CDN need to actually do.
Multiplexing changes API design guidance that predates it. Under HTTP/1.1, browsers opened a limited number of parallel TCP connections per origin, so an API client benefited from batching many small requests into fewer, larger ones (a classic piece of REST API advice: avoid chatty, many-small-calls API shapes). Under HTTP/2, many logical requests share ONE connection via multiplexed streams, so issuing several smaller, well-scoped requests to the same origin is no longer the performance problem it used to be; the old advice does not fully disappear (a request still has real per-call overhead: headers, server-side routing, business logic), but the SPECIFIC "too many TCP connections" penalty it was originally guarding against is gone. Concretely: under a common browser limit of about 6 concurrent connections per origin under HTTP/1.1, a page issuing 30 small API calls to the same origin would need to queue them into 5 sequential batches of 6; under HTTP/2's multiplexing, all 30 can be in flight at once over the single connection, with no batching required at all.
Header compression (HPACK/QPACK). HPACK is HTTP/2's header-compression format, and QPACK is its HTTP/3 counterpart, redesigned so header compression still works correctly when QUIC delivers streams out of order, which plain HPACK cannot tolerate. Both work by having each side maintain a shared table of previously-seen header name/value pairs: once a header (an auth token, a standard Accept or User-Agent value) has been sent once on a connection, later requests on that SAME connection can send a short reference into that table instead of the full header text again. Repeated headers across many requests to the same connection get compressed incrementally this way, which meaningfully reduces overhead for an API client making many small, frequent calls, exactly the pattern multiplexing now makes more attractive in the first place.
HTTP/3 and QUIC specifically. Because QUIC runs over UDP and manages its own stream multiplexing independently, one lost packet on one logical stream no longer blocks delivery of data on OTHER streams sharing the same connection, unlike TCP where a single lost packet stalls the entire connection until it is retransmitted; this specifically helps a mobile client on a lossy network, which is exactly the scenario where TCP-level head-of-line blocking used to hurt the most. QUIC's connection setup also folds the transport and TLS handshakes together, cutting the round trips needed before the first real request can even be sent, which matters disproportionately on high-latency mobile connections where each round trip is expensive. Concretely: a fresh HTTPS connection over TCP + TLS 1.3 needs 2 round trips before the client can send its first request byte (1 to establish the TCP connection, 1 for the TLS 1.3 handshake); QUIC combines both into one combined handshake, needing just 1 round trip for a new connection (and its 0-RTT mode can send request data on the very first flight when resuming a connection to a host the client has already talked to). On a mobile connection, where a single round trip commonly costs on the order of 50-100ms, that one saved round trip is a real, directly-felt latency difference before any actual request-response work even starts.
What changes in load-balancing and CDN configuration. Load balancers and CDNs need to actually terminate and understand HTTP/2 or HTTP/3 themselves (not simply pass bytes through) to preserve these benefits end-to-end, and per-request load-balancing algorithms that assumed "one connection roughly equals one in-flight request" need rethinking, since one HTTP/2 or HTTP/3 connection can now carry many concurrent logical requests, changing what "an overloaded backend" or "a slow connection" even looks like from the load balancer's point of view. Many CDNs and cloud load balancers already handle this transparently, but a self-managed edge layer built assuming HTTP/1.1 semantics needs deliberate upgrading, not an assumption that the protocol change is invisible to it.
Trade-offs and pitfalls. The most common mistake is treating this purely as an infrastructure upgrade with no API-design implications; an API whose CLIENTS were specifically designed around HTTP/1.1's connection limits (aggressively batching everything into one mega-request "to save connections") may actually see LESS benefit from multiplexing than a more naturally-shaped API would, since it already paid the batching cost the new protocols were meant to make unnecessary.
A list endpoint causes heavy database load whenever clients page deep with a large offset, on a table with tens of millions of rows. Propose two different mitigations (for example a covering or composite index strategy, keyset pagination, or a denormalized read model) and, for each, describe what it costs you operationally and what changes for the client.
Sample Answer
Direct answer. The database load comes from having to scan or index-skip past every row before the offset, so the fix is to stop asking the database to count through rows it is about to throw away: either replace offset with keyset pagination, or add a covering/composite index that makes the skip itself cheap, or materialize a pre-sorted read model so the "deep page" query is a direct lookup instead of a scan.
Mitigation 1: keyset (cursor) pagination. As covered in the pagination-comparison sub-area, this eliminates the "skip N rows" cost entirely by anchoring on the last row seen instead of a row count; the cost of fetching page 10,000 becomes roughly the same as page 1. Cost to you: you lose the ability to jump straight to an arbitrary page number, only "next" and "previous" remain meaningful; client changes: any UI built around numbered page links (1, 2, 3 ... 47) needs to become a "load more" or "next" pattern instead.
Mitigation 2: a covering composite index. If you cannot give up numbered pages (say, an admin tool genuinely needs "jump to page 400"), a composite index on exactly the columns used for filtering, sorting, and the primary key lets the database satisfy the query entirely from the index without touching the underlying table rows at all, which is meaningfully cheaper than a table scan even though the offset cost itself does not disappear. Cost to you: extra storage and slightly slower writes (every index has to be maintained on insert/update); client changes: none, numbered pages keep working exactly as before.
Mitigation 3: a denormalized, pre-sorted read model or materialized view. For a specific hot query shape (say, "the most recent 10,000 items in category X"), maintain a separate table that already holds exactly that sorted slice, refreshed on a schedule or via change-data-capture (a process that watches the database's write log and streams every insert/update out to other systems as it happens, instead of re-querying the source table on a timer), so a deep-page request against it is a cheap direct read rather than a live aggregation over the full dataset. Cost to you: the read model can be slightly stale, and you now have a second copy of the data to keep in sync; client changes: usually none, the client is still calling the same paginated endpoint, the difference is invisible to it.
Choosing between them. Keyset pagination is the right default whenever the client's actual need is "keep scrolling", not "jump to page 400" specifically; the composite index is the right minimal fix when you must keep numbered pages and the dataset is not so large that index-only scans are still too slow; the materialized read model is worth the operational cost only when one specific deep-page query shape is hit often enough, and is expensive enough even with a good index, to justify maintaining a second, purpose-built copy of the data.
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 contract for a time-series metrics endpoint that a dashboard will query: what parameters does it take (time range, granularity, filters), what does the response shape look like, and how does it fail gracefully when a client asks for too wide a range or too fine a granularity? Why do your choices make this API easy for dashboard developers to build against and hard for a single misbehaving client to overload?
Sample Answer
Direct answer. Take a time range, a granularity, and a set of filter dimensions as query parameters, return a compact array-of-points shape rather than a deeply nested object, and fail with a clear 400 (not a slow, expensive query) when a client asks for a combination that would be too expensive to compute on demand.
The contract.
GET /metrics/{metric_name}?start=2026-07-01T00:00:00Z&end=2026-07-28T00:00:00Z&granularity=hour&dimension=region:us-east
Response:
{
"metric": "api_requests_total",
"granularity": "hour",
"points": [
{ "ts": "2026-07-01T00:00:00Z", "value": 18234 },
{ "ts": "2026-07-01T01:00:00Z", "value": 17902 }
],
"truncated": false
}
start/end: an explicit ISO 8601 range, never an open-ended "give me everything," which is both a usability requirement (a dashboard always renders a bounded window) and a load-safety requirement.granularity: an enum (minute/hour/day), not a free-form duration string, so the server can reject an unsupported value outright rather than silently rounding it to something else.dimension: an optional filter (region, endpoint, status class), following the same query-parameter-per-concept convention as general filtering elsewhere in a REST API.points: a flat array of {timestamp, value} pairs, the simplest possible shape a charting library can consume directly with no client-side reshaping.truncated: an honest signal that the server capped this response at its per-call point ceiling (say, 10,000 points) and did not return every point the requested range/granularity combination would otherwise imply, so the client knows to narrow its request rather than silently receiving an incomplete-looking chart with no explanation. This only fires for combinations that are large but still inside the hard limit below; a combination that exceeds the hard limit is rejected outright before it ever runs (see 'Failing gracefully'), not silently truncated.
Failing gracefully instead of overloading the service. The soft cap behind truncated and the hard rejection below are two different thresholds, not the same one: reject (400) a request whose range/granularity combination would produce a number of points far beyond even the truncation ceiling (say, a full year at minute granularity, which is roughly half a million points, verified: 365 days times 24 hours times 60 minutes is 525,600, against a 10,000-point truncation ceiling) BEFORE running the underlying query, with a message naming the actual limit and suggesting a coarser granularity or a narrower range; this protects the service from being asked to compute and serialize an enormous response, and gives the client an immediately actionable error instead of a slow timeout. A combination that is large but still under the hard limit gets computed and returned with truncated: true instead, since it is cheap enough to be worth computing even though the client asked for more than the response will actually contain.
Why this is easy for dashboard developers. The flat points array needs no client-side transformation to feed into a charting library; the explicit granularity and truncated fields let a dashboard show an honest "zoom in for more detail" affordance instead of silently rendering incomplete or misleadingly-aggregated data.
Why it is hard to overload. The combination of a mandatory bounded time range, an enum-constrained granularity, and an up-front rejection of overly-broad requests means no single client request can accidentally (or deliberately) demand an unbounded amount of server-side computation; the cost of any single valid request is bounded by construction, not by hoping clients behave well.
Implement conditional GET support (ETag and Last-Modified) for a resource that changes frequently and is large enough that recomputing a hash on every request would be expensive. Show the header exchange for both a cache hit (304 Not Modified) and a cache miss, and describe an efficient way to generate and validate the ETag that does not require re-serializing the whole resource just to check whether it changed.
Sample Answer
Direct answer. Generate the ETag from a cheap, already-available signal of the resource's version (a monotonically incrementing row version, or a last-modified timestamp with enough precision to be unique per write), not by hashing the full serialized response body, so validating a conditional GET costs a version lookup instead of a full re-render.
Why hashing the full body is expensive at scale. The naive approach (serialize the resource, hash the bytes, compare to the client's If-None-Match) means every single conditional GET, even ones that end in a 304, still pays the FULL cost of building the response body just to throw it away. For a resource that changes frequently and is expensive to assemble (joins, computed fields, a large payload), that defeats much of the point of conditional GET, which exists specifically to let the SLOW path (assembling the body) be skipped when nothing changed.
An efficient alternative. Store a version column (an integer, incremented on every write, or a updated_at timestamp with microsecond precision) directly on the row. The ETag is derived from that single cheap field: ETag: "product-42-v17" or a short hash of (id, version) rather than of the whole body. Validating a conditional GET becomes: look up the CURRENT version for this id (a single indexed row read, not a full assembly of the response), compare it to what the client's If-None-Match implies, and only build the full body on an actual cache miss (version changed).
The header exchange for both cases.
Cache hit (304):
GET /products/42 HTTP/1.1
If-None-Match: "product-42-v17"
HTTP/1.1 304 Not Modified
ETag: "product-42-v17"
Cost paid: one indexed row read for the version, nothing else.
Cache miss (200, resource changed):
GET /products/42 HTTP/1.1
If-None-Match: "product-42-v17"
HTTP/1.1 200 OK
ETag: "product-42-v18"
Cost paid: the version lookup PLUS the full body assembly, exactly the same cost a non-conditional GET would have paid anyway; conditional GET never makes the changed case slower, only the unchanged case cheaper.
Trade-offs and pitfalls. A version counter requires every write path to actually increment it, including writes that happen through a different code path (a bulk import job, a direct database fix) that might bypass the application layer; if any write path forgets to bump the version, the ETag silently lies (says unchanged when it was not), and a client keeps serving stale data past a real update. A timestamp-based version has a subtler failure mode: two writes within the same clock tick (common under high concurrency without microsecond precision) can produce the same "version," making them indistinguishable to a client's cache even though the content differs.
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.