REST API Design — Cheat Sheet
Interview Q&A · 100 topics. Download the PDF or the Instagram carousel and share it.
What actually makes an API RESTful?
REST is an architectural style with six constraints, not a synonym for "JSON over HTTP". Client-server separation. Statelessness: each request carries everything needed, and the server holds no client session between requests. Cacheability: responses declare whether they may be cached. Uniform interface: resources are identified by URIs and manipulated through a standard set of methods with self-descriptive messages. Layered system: a client cannot tell whether it is talking to the origin or a proxy. And optionally code-on-demand. The honest position to take in an interview is that almost nothing labelled REST satisfies all of these — HATEOAS in particular is rare — and that this is fine. What matters is the parts that buy you something: statelessness enables horizontal scaling, the uniform interface makes caching and proxying possible, and standard method semantics let intermediaries retry safely. So the useful answer is not "here are Fielding's constraints" but "here is which constraints I keep and why". Claiming full REST compliance is usually a sign someone has read the dissertation and not shipped an API.
What does statelessness mean in practice, and what does it rule out?
Each request must carry everything the server needs to process it. The server keeps no per-client context between requests. What it rules out is server-side session state held in memory. A login that stores a session object on one instance means every subsequent request must reach that instance — which is why sticky sessions exist and why they cause the problems they do. What it permits is any instance handling any request. That is what makes horizontal scaling, rolling deployment and instance failure all trivial rather than disruptive. The distinction people get wrong is between application state and resource state. Statelessness is about client session state, not about the server being a stateless function — of course there is a database. A shopping cart stored in a database and identified by a cart ID in the request is perfectly stateless; the same cart held in a server-side session is not. The practical implementation is either a token the client carries — a JWT or opaque token — or a session ID resolved against shared storage. Both keep instances interchangeable, which is the actual goal.
What is HATEOAS and why does almost nobody implement it?
Hypermedia as the Engine of Application State means responses include links describing what the client can do next, so a client discovers available transitions rather than hardcoding URLs. An order response might include links to cancel it, to pay, or to the customer — and the cancel link is present only when cancellation is actually permitted. The client follows links rather than constructing URLs and duplicating business rules. The theoretical payoff is decoupling: the server can restructure URLs and change state machine rules without breaking clients. It is rarely implemented because clients almost never work that way. Real clients are written against documentation, hardcode the endpoints they need, and would need substantial machinery to navigate dynamically. The extra payload and complexity buy nothing if no client uses it. Where it does earn its place is workflow-heavy APIs where the permitted next actions genuinely vary by state and you want that logic in one place — payment flows are the common example. The defensible position is to include links where they encode real authorisation or state logic, and skip them otherwise.
When would you choose gRPC or GraphQL over REST?
gRPC suits internal service-to-service communication. It gives a strict contract from protobuf, efficient binary encoding, code generation in many languages, streaming in both directions, and HTTP/2 multiplexing. The costs are poor browser support without a proxy, binary payloads that are harder to debug, and tooling that assumes you control both ends. GraphQL suits clients with varied and evolving data needs — particularly mobile, where over-fetching costs battery and bandwidth. One request can assemble exactly the fields needed across several resources, which removes both over-fetching and the round trips of REST. The costs are real: caching is much harder because everything is one POST endpoint, arbitrary query shapes create unpredictable database load and N+1 problems, and you need query depth and complexity limits to avoid a denial of service. REST remains the right default for public APIs and anything consumed by third parties. It is universally understood, works with every HTTP tool and proxy, caches naturally, and needs no client library. The honest framing is that these coexist: gRPC internally, REST at the public edge, GraphQL where a rich client aggregates.
What is the Richardson Maturity Model?
A four-level scale describing how closely an HTTP API follows REST constraints. Level 0 is a single endpoint receiving all operations, typically POST to /api with a method name in the body. This is RPC over HTTP — SOAP works this way. Level 1 introduces resources: distinct URIs per entity, but still one method, usually POST for everything. Level 2 uses HTTP methods and status codes correctly — GET to read, POST to create, PUT to replace, DELETE to remove, with meaningful status codes. This is where the vast majority of real APIs sit, and where most of the practical benefit lives, because intermediaries can now cache and retry correctly. Level 3 adds hypermedia controls, where responses link to available next actions. The useful thing to say about it is that level 2 is the sensible target. It is where you gain caching, safe retries, and the ability for proxies and clients to reason about your API without special knowledge. Level 3 has real cost and rarely pays back. Treating the model as a ladder you must climb to the top is a misreading.
What is the difference between an API being idempotent and being safe?
Safe means the operation does not modify state. Idempotent means performing it repeatedly has the same effect as performing it once. All safe methods are idempotent, but not the reverse. DELETE is idempotent — deleting an already-deleted resource leaves the same end state — but it is certainly not safe. The practical consequence is retry behaviour. A client or proxy that times out cannot know whether the request was processed. If the operation is idempotent, retrying is harmless. If it is not, retrying may create a duplicate order or charge a card twice. This is why POST needs explicit help. The standard solution is an idempotency key: the client generates a unique identifier per logical operation and sends it as a header. The server records the key with the result, and a repeat returns the stored result rather than acting again. Stripe popularised this and it is now the expected pattern for any API handling money. The subtlety worth raising is that idempotency is about observable end state, not about side effects like audit logs or emails, which may legitimately fire once per attempt unless you deduplicate them too.
Should an API be synchronous or asynchronous, and how do you design a long-running operation?
Synchronous is right when the work completes within a reasonable request timeout — a few seconds at most. It is simpler for the client and gives immediate confirmation. Beyond that, holding a connection open is wasteful and fragile: proxies time out, clients retry, and you hold a thread for the duration. The standard asynchronous pattern is to accept the request and return 202 Accepted with a Location header pointing at a status resource. The client polls that resource, which returns the current state and eventually the result or a link to it. That gives the client a clear contract, survives disconnection, and lets you scale the work independently of the API tier. Refinements: include a Retry-After header so clients poll at a sensible interval rather than hammering. Return the final result at a stable URL so it can be fetched later. And support a webhook or event as an alternative to polling for clients that can receive callbacks. The design mistake to avoid is making the operation synchronous but slow, which pushes the timeout problem onto every caller and makes your p99 everyone else's p99.
What is the difference between a public API and an internal one, in design terms?
The difference is who you can change and how fast. An internal API is consumed by teams you can talk to. You can coordinate a breaking change, deploy both sides together, and deprecate quickly. That means you can optimise for clarity and iterate freely, and versioning can be lighter. A public API is consumed by people you cannot reach, running clients you cannot update, sometimes for years. Every field you expose becomes a commitment, every behaviour becomes something someone depends on — including behaviour you consider a bug. Hyrum's law applies with full force. So public APIs need stricter versioning, longer deprecation windows, explicit stability guarantees, and far more conservative design. You add fields rather than change them, and you never repurpose one. They also need different operational treatment: rate limiting per consumer, authentication that supports key rotation, documentation that is genuinely accurate, and a changelog. The design instinct that follows is to expose less. Anything you do not publish, you can change. A minimal public surface with a richer internal one is almost always the right split.
What does "self-descriptive messages" mean and why does it matter?
A message should carry enough information for the recipient to understand how to process it, without out-of-band knowledge. Concretely: the Content-Type declares how to parse the body, the status code declares the outcome category, cache headers declare cacheability, and the method declares the semantics. Why it matters is intermediaries. A proxy, cache or gateway that has never seen your API can still cache a GET marked public with a max-age, retry an idempotent request, and know that a 503 means try later. None of that requires knowing what your resources mean. That is the practical payoff of the uniform interface, and it is what you lose when you tunnel everything through POST /api with an operation name in the body. Such an API cannot be cached, cannot be safely retried by anything generic, and every intermediary is blind. The common violation is returning 200 with an error object in the body. Every monitoring system, load balancer and client library now believes the request succeeded, so error rates look perfect while the service is failing. That single mistake defeats an enormous amount of surrounding infrastructure.
How do you decide the granularity of an API — chatty versus chunky?
A chatty API exposes fine-grained resources, so a client assembles what it needs from several calls. A chunky one returns larger aggregates in fewer calls. Chatty is cleaner conceptually and caches well, because each resource is independently cacheable and invalidated. But it costs round trips, and over a mobile network thirty sequential calls is seconds of latency regardless of bandwidth. Chunky reduces round trips but couples concerns, returns data the client may not need, and caches poorly because any change invalidates the whole aggregate. The resolution is usually to design resources at a natural granularity and then provide explicit affordances for aggregation: an expand or include parameter that embeds related resources on request, and a fields parameter for sparse fieldsets. That keeps the default clean while letting demanding clients reduce their call count. The alternative is a purpose-built endpoint for a specific screen — the backend-for-frontend pattern — which is pragmatic and widely used, at the cost of an endpoint coupled to one UI. What you should avoid is guessing. Look at what clients actually call in sequence, and optimise those paths.
What is the backend-for-frontend pattern and when is it justified?
A backend-for-frontend is an API layer built for one specific client — a web app, an iOS app, a partner integration — rather than a single general-purpose API serving all of them. It exists because different clients want genuinely different things. A mobile client wants few round trips and minimal payloads; a web client can afford more calls; a partner wants a stable contract that never changes. A single API serving all three ends up as a compromise that suits none, accumulating query parameters and conditional shapes until it is unmaintainable. The BFF absorbs that variation. It aggregates from downstream services, shapes responses for its client, and can evolve at the pace of that client — often owned by the same team, which removes a coordination bottleneck. The costs are duplication across BFFs and another deployable to operate. Logic that belongs in the domain can leak into the BFF, and then diverge between them. So it is justified when clients differ substantially and are owned by different teams. It is over-engineering when you have one web client, where you should simply design the API for it.
What is Hyrum's law and why does it matter for API design?
With a sufficient number of users, every observable behaviour of your system will be depended on by somebody — regardless of what you documented. So the contract is not what you wrote down; it is everything a client can observe. The order of items in a list you never promised to sort. The exact wording of an error message someone is regex-matching. A field that happens to be null in practice. Response timing. Even a bug, if a client has worked around it. The consequence for design is to minimise observable surface. Do not expose fields you might want to remove. Do not return data in an order you might change without documenting it as unordered — or better, deliberately randomise it so nobody can depend on it. Do not leak internal identifiers or implementation details. It also argues for explicit contracts and contract tests, so what you intend to guarantee is checked, and for deliberately varying unspecified behaviour so clients cannot accidentally couple to it. The practical instinct is that every response field is a promise, so add them reluctantly and remove them almost never.
How should you name and structure resource URLs?
Use plural nouns for collections and identifiers for members: /orders and /orders/123. Avoid verbs in paths — the method supplies the verb. Nest to express containment where the child does not stand alone: /orders/123/items. But limit nesting to one or two levels. Deep hierarchies like /customers/1/orders/2/items/3/discounts/4 are brittle, because they encode a structure that changes and force the client to know the whole path. If items have global identifiers, /items/3 is better and the parent is a filter. Use lowercase with hyphens, not underscores or camelCase, since URLs are case-sensitive in the path and hyphens read better. Keep query parameters for filtering, sorting and pagination rather than baking them into paths. The judgement call worth articulating is when to nest: nest when the child is genuinely owned by the parent and has no independent identity, and use a flat resource with a filter otherwise. Getting that wrong is the most common structural mistake, and it is expensive because URLs are the hardest part of an API to change.
How do you model an action that is not a CRUD operation?
This is where strict resource orientation gets uncomfortable, and the honest answer acknowledges that. The first move is to look for a resource hiding inside the verb. "Cancel an order" can be modelled as creating a cancellation, or as updating the order's status. "Publish an article" is a state transition on the article. Often the action really is a resource — a refund, a shipment, an approval — and modelling it as one gives you history and idempotency for free. When that genuinely does not fit, a sub-resource action endpoint is acceptable and widely used: POST /orders/123/cancel. It is not pure REST, and it is far clearer than contorting the model. The reason a sub-resource beats a status field for some transitions is that transitions often have their own parameters and rules — a cancellation has a reason and may be forbidden — and expressing that as a PATCH on a status field hides the business logic. Prefer POST for these, since they are neither safe nor reliably idempotent, and support an idempotency key if repeating them would be harmful.
Should you expose database IDs in your API?
Generally not sequential integer primary keys. They leak information: a competitor can infer your order volume from the difference between two IDs, and they make enumeration trivial — an attacker walks /users/1, /users/2 and finds everything you failed to authorise properly. That is insecure direct object reference, and sequential IDs make it easy to exploit at scale. They also couple your API to your storage. Changing the primary key strategy, sharding, or migrating databases becomes a breaking change. The alternatives: UUIDs, which are unguessable but large and index poorly in some databases; ULIDs or similar sortable identifiers, which keep the index locality of sequential IDs while remaining unguessable; or an opaque external identifier stored alongside the internal one, which fully decouples the two at the cost of an extra column and lookup. The practical position: use an external identifier distinct from the primary key for anything public-facing, and treat it as opaque in the contract so you can change its format later. And whatever you choose, authorisation must still be checked per request — an unguessable ID is obscurity, not access control.
How do you model a many-to-many relationship in a REST API?
Two approaches, and which you pick depends on whether the relationship carries data. If the relationship is bare — a user belongs to groups, nothing more — expose it as a sub-collection: GET /users/1/groups to list, PUT /users/1/groups/5 to add, DELETE to remove. PUT is right because adding an existing membership is idempotent. If the relationship has its own attributes — a membership with a role and a joined date — it is a resource in its own right. Model it as /memberships with its own identifier, and expose /users/1/memberships and /groups/5/memberships as filtered views. The second is more work but far more honest, and it is the one people get wrong. As soon as you find yourself wanting to add a field to the relationship, the sub-collection model breaks and you have to restructure. So the question to ask upfront is whether the association will ever need attributes. If there is any chance, model it as a resource from the start — promoting it later is a breaking change. Also decide whether both directions are navigable, and keep them consistent.
Should nested resources be addressable independently?
Usually yes, and designing so they are avoids a lot of pain. If an order item has a globally unique identifier, both /orders/123/items/456 and /items/456 can exist, with the nested form being a convenience that also validates the parent relationship. The advantage of independent addressability is that clients holding a reference to an item do not need to know its parent, links are stable if the hierarchy is reorganised, and caching is simpler. The case for nesting only is when the child genuinely has no meaning outside its parent and its identifier is only unique within that scope — a line number within an invoice, for instance. Then /invoices/1/lines/1 is correct and /lines/1 is meaningless. The pattern to avoid is deep mandatory nesting for entities that do have global identity, because it forces every client to carry the full ancestry and makes every URL fragile. A reasonable rule: if you would store it in its own table with its own primary key, it probably deserves its own top-level resource, with nested paths as filtered views rather than the only way in.
How do you handle bulk operations?
REST has no standard for this, so you are choosing between imperfect options. The simplest is a collection-level endpoint accepting an array: POST /orders with a list, or PATCH /orders with a list of changes. It is efficient and easy to consume. The hard question is partial failure. If forty of fifty succeed, what status do you return? 200 implies everything worked; 400 implies nothing did. The usual answer is 207 Multi-Status, or 200 with a per-item result array giving each item's status and error. The client must then inspect the body, which means bulk endpoints need clear documentation about that. The alternative is all-or-nothing transactional semantics, which is simpler to reason about but often not what the caller wants — one bad record should not reject the batch. Other considerations: cap the batch size and document the limit, or a caller will send a hundred thousand items. Make the operation idempotent with a batch-level key. And for very large volumes, an asynchronous job with a status resource is better than a synchronous bulk call.
What is a singleton resource and when is it appropriate?
A singleton is a resource with no identifier because exactly one exists in its context — /me, /settings, /account/subscription. It is appropriate when the resource is uniquely determined by the request context, usually the authenticated principal. GET /me is clearer than requiring the client to know its own user ID and call /users/{id}, and it avoids leaking that identifier into client code. It also improves authorisation: /me cannot be manipulated to read someone else's record, whereas /users/{id} must check ownership on every call. Singletons support GET, PUT and PATCH naturally. POST and DELETE are usually meaningless — you cannot create a second one, and deleting a settings resource is ambiguous. The design caution is not to overuse them. A singleton hides its identity, which makes it awkward when you later need to address the same resource in another context — an admin reading another user's settings, for instance. The usual resolution is to have both: /me as an alias and /users/{id} as the canonical form, with /me resolving to the caller. Defining that aliasing explicitly avoids duplicated logic.
How should search endpoints be designed?
Simple filtering belongs on the collection as query parameters: GET /orders?status=pending&customer=123. It is cacheable, bookmarkable, and needs no special handling. That works until the query gets complex. Once you need boolean combinations, ranges, nested conditions or full-text relevance, query strings become unwieldy and eventually hit URL length limits. At that point the pragmatic answer is POST /orders/search with the query in the body. It is not idempotent-looking and it is not cacheable, which are real losses, but it is honest about what is happening and every large API ends up doing it. The alternative that preserves semantics is a search resource: POST creates a search, returning a URL you then GET for results. That restores cacheability and lets results be paginated stably, at the cost of two round trips. Whatever you choose, decide deliberately about a query language. Accepting arbitrary structured queries against your database is a denial-of-service risk and couples clients to your schema — so constrain the allowed fields and operators explicitly rather than passing filters through.
Should URLs be plural or singular?
Plural for collections, consistently: /orders, /orders/123, /users, /users/456. The argument for plural is that /orders is genuinely a collection, and /orders/123 reads as "the order with id 123 within the orders collection" — which is what it is. Mixing singular and plural, or choosing per-resource, produces an API where clients must remember which convention each endpoint uses. The main counter-argument is that some resources are awkward to pluralise, and singletons genuinely are singular — /me, /settings. That is fine: singletons are a different case and being singular is correct for them. The practical point is that this is a low-stakes decision where consistency matters far more than which side you pick. An API that is uniformly singular is better than one that is inconsistently plural. What is worth saying in an interview is that you would establish it as a written convention with the rest of your API guidelines, because the cost of this kind of thing is not the initial choice but the drift across teams over years — and URLs are the part of an API you can least afford to change later.
How do you expose computed or derived data?
Three reasonable options, and the choice depends on cost and how often it is needed. Include it as a field on the resource when it is cheap and almost always wanted — an order's total, an item count. The client gets it for free. Expose it as a sub-resource when it is expensive or independently useful: /orders/123/summary, /users/456/statistics. That keeps the base resource fast and lets the derived data be cached with its own TTL, which is often much longer. Make it opt-in via a query parameter when it is expensive but sometimes wanted alongside the base resource: /orders/123?include=totals. The mistake to avoid is putting an expensive computation in a field that every caller pays for, especially in a list endpoint — a per-item aggregate that requires a query each turns a list call into an N+1 disaster. The other thing worth stating is caching semantics. Derived data often has different freshness requirements from the underlying resource, and modelling it separately lets you express that instead of forcing one TTL on both.
How do you design an API for a resource that has multiple representations?
Content negotiation is the mechanism REST provides: the client sends an Accept header and the server responds with the matching representation, indicating which it chose in Content-Type. So the same URL can return JSON, CSV or PDF depending on what was asked for. That is conceptually clean — one resource, several representations — and it keeps URLs stable. In practice, format-in-the-path or a query parameter is common: /reports/123.csv or /reports/123?format=csv. It is less pure, but it is far easier to use from a browser, a link, or a tool that cannot set headers, and it makes caching by URL straightforward. The honest answer is to support content negotiation properly and additionally accept a format parameter as an override, since the practical benefit outweighs the impurity. The detail that matters for caching is the Vary header. If the response varies by Accept, you must send Vary: Accept, or a shared cache will serve a JSON response to a client that asked for CSV. Forgetting that is a real and confusing bug, and it is the main reason people avoid negotiation.
What is the difference between a resource and a representation?
A resource is the conceptual thing identified by a URI — an order, a user, today's weather in Pune. A representation is a concrete serialisation of that resource at a point in time: the JSON document you actually receive. The distinction is not pedantry; several design decisions follow from it. One resource can have many representations — JSON, XML, a summary view, a full view, different language versions. Content negotiation selects among them, which is why Accept and Vary exist. The resource identity is stable while representations change. That is what lets caching and conditional requests work: an ETag identifies a particular representation, and a change to the resource produces a new one. It also clarifies what PUT means. PUT replaces the resource with the representation you supply, which is why a partial PUT is wrong — you are stating the complete new state. PATCH exists precisely because that is often not what you want. And it explains why the URI should not encode the format: /orders/123 is the order, and .json is a representation detail that content negotiation ought to handle.
What is the difference between PUT and PATCH?
PUT replaces the entire resource with the representation you send. Fields you omit are, semantically, being set to absent — so a PUT with three fields on a resource with ten should clear the other seven. PATCH applies a partial modification. Only the fields you send are affected. The practical consequence is that most APIs implementing "PUT" are actually implementing PATCH, ignoring omitted fields. That is a real correctness problem, because a client that omits a field expecting it to be preserved gets different behaviour from one that expects replacement, and the API has not said which it does. PUT is idempotent by definition. PATCH may or may not be — a patch that says "set status to shipped" is idempotent; one that says "increment the counter" is not, which is why JSON Patch operations like "add" to an array are not. The recommendation is to implement PUT with true replacement semantics or not offer it at all, and to use PATCH for partial updates with a documented format — JSON Merge Patch is the simplest, JSON Patch the most expressive.
When should POST create a resource versus PUT?
POST when the server assigns the identifier; PUT when the client does. POST /orders creates an order and returns 201 with a Location header pointing at the new resource. The client did not know the URL in advance, which is why POST goes to the collection. PUT /orders/{clientSuppliedId} creates the resource at a URL the client chose. This is right when the client has a natural identifier — an idempotency key, an external reference, a slug. The important practical difference is idempotency. PUT with a client-supplied ID is naturally idempotent: repeating it produces the same resource, so a retry after a timeout is safe. POST is not, so a timeout leaves the client unable to tell whether an order was created. That is a genuine argument for client-generated identifiers in systems where duplicate creation is costly. Letting the client generate a UUID and PUT to it removes an entire class of problem. Where POST is unavoidable, an idempotency key header gives the same guarantee, with the server storing the key and returning the original result on repeat.
What should DELETE return, and what if the resource does not exist?
On success, 204 No Content is the usual choice — the deletion succeeded and there is nothing to return. 200 with a body is acceptable if you want to return the deleted representation, which some APIs do so the client can undo or log it. For a resource that does not exist, there are two defensible positions. 404 is literally accurate: you asked to delete something that is not there. But it makes DELETE awkward to retry, because a client that times out and retries gets a 404 on the second attempt and cannot tell whether its first attempt succeeded or the resource never existed. 204 regardless treats DELETE as fully idempotent: the end state is what you asked for, so report success. This makes retries clean. The pragmatic answer most APIs land on is 404 for a first-class "you are asking about something unknown" case and 204 where idempotent retry matters more. Whichever you choose, document it, because clients genuinely branch on this. The related decision is soft versus hard delete, which changes whether a subsequent GET returns 404 or 410 Gone.
Why is returning 200 with an error in the body a bad idea?
Because it lies to everything between you and the client. Load balancers, proxies, CDNs, monitoring systems, client HTTP libraries and retry middleware all interpret the status code. Returning 200 means every one of them believes the request succeeded. The consequences are concrete. Your error rate dashboards show zero while the service is failing. Automatic retry logic does not retry, because there was nothing to retry. A cache may store the error response and serve it to others. Circuit breakers never open. Alerting is blind. It also forces every client to parse the body before knowing whether the call worked, which defeats the entire point of a uniform interface and means generic tooling cannot help. The usual justification is that some clients — old browsers, certain frameworks — handle non-2xx badly. That was a real constraint fifteen years ago and rarely is now. The correct approach is a meaningful status code plus a structured error body giving detail. RFC 7807 problem+json is the standard format, and it gives you both: the code for machines and intermediaries, the body for humans and detailed handling.
When do you use 400 versus 422?
400 Bad Request means the server could not understand the request — malformed JSON, a missing required parameter, a completely wrong content type. The message is unparseable or structurally invalid. 422 Unprocessable Entity means the syntax was fine and the server understood the request, but the content is semantically wrong — an email field containing something that is not an email, a start date after an end date, a quantity of negative three. The distinction is parse failure versus validation failure. In practice many APIs use 400 for both, and that is defensible — 422 comes from WebDAV and is not universally understood, and some clients and gateways treat unknown 4xx codes oddly. The more important thing than which code you pick is that the response body identifies which fields failed and why. A 400 with no detail forces the client developer to guess, which is the actual pain point. So the answer to give is: pick one convention, apply it consistently, and invest the effort in a structured error body listing field-level violations. That is what makes an API pleasant to integrate against.
What is the correct status code for a successful creation, and what else should the response include?
201 Created, with a Location header giving the URL of the new resource. The Location header is the part most often omitted, and it matters: it tells the client where the thing now lives without requiring it to construct the URL from an ID, which keeps URL structure a server concern. The body should generally contain the created resource, including any server-assigned fields — the identifier, timestamps, computed defaults, and the canonical form of anything normalised. That saves the client an immediate follow-up GET. For asynchronous creation where the resource does not exist yet, 202 Accepted is correct instead, with a Location pointing at a status resource rather than the eventual resource. Two related decisions worth mentioning. If creation is triggered repeatedly — a retry — and you support idempotency keys, the repeat should return the original 201 and the same body rather than a 409, so the client sees a consistent outcome. And if the request would create a duplicate of something that must be unique, 409 Conflict is the right response, with a body explaining which constraint was violated.
How should you use 409 Conflict?
409 signals that the request conflicts with the current state of the resource, and that the client could potentially resolve it and retry. The two main uses are uniqueness violations — creating a resource whose unique field already exists — and optimistic concurrency failures, where the client's version is stale. The concurrency case is the more interesting one. The client sends If-Match with the ETag it read; if the resource has changed since, the server returns 412 Precondition Failed, or 409 if you are not using conditional headers. Either way the client knows its update was based on stale data and must re-read. That is the mechanism that prevents lost updates — two clients reading the same record, both modifying it, and the second silently overwriting the first. Without it, last write wins and the first user's change vanishes with no error. The body should explain what conflicted and ideally include enough information to resolve it — which field, what the current value is. The distinction from 422 is that 409 is about state, not content: the same request would have succeeded at a different moment.
What is the difference between 401 and 403, and what about 404 for authorisation?
401 means unauthenticated despite its name — no credentials, invalid credentials, or an expired token. The response should carry WWW-Authenticate. A client seeing 401 should refresh its token and retry. 403 means authenticated but not permitted. Retrying with the same credentials will never work, so a client should not refresh or retry. Getting these right is not pedantry: client libraries branch on it, and returning 403 for an expired token means clients never refresh and users are logged out unnecessarily. The third option is returning 404 for resources the caller may not access. This hides existence, which matters when the identifier itself is sensitive — GitHub returns 404 for private repositories you cannot see, because a 403 would confirm the repository exists and leak information about private projects. The trade is debuggability: a legitimate user who has lost access sees a confusing 404 rather than a clear denial. The rule of thumb: 403 when existence is not sensitive and a clear message helps, 404 when existence itself is information you must not disclose. Decide per resource type rather than globally.
How should rate limiting be communicated to clients?
429 Too Many Requests when the limit is exceeded, with a Retry-After header telling the client how long to wait — in seconds or as an HTTP date. Retry-After is the important part. Without it, clients guess, and a client that retries immediately makes the problem worse. Beyond the rejection, good APIs expose the limit state on every response, not just failures. The convention is a set of headers: the limit, the remaining quota, and when the window resets. That lets a well-behaved client pace itself rather than discovering the limit by hitting it. The header names are not standardised — X-RateLimit-* is common, and RFC-draft RateLimit-* headers are emerging. Design decisions worth stating: what you key on — API key, user, or IP, remembering IP is shared behind NAT; whether limits are per endpoint or global, since an expensive endpoint may warrant its own; and whether you use a token bucket allowing bursts or a strict rate. And document the limits. An undocumented rate limit that clients discover in production is a support burden and an integration blocker.
When would you use 410 Gone rather than 404?
410 means the resource existed and has been permanently removed, and the client should not expect it to return. 404 means simply not found, with no statement about whether it ever existed or might appear. The practical value is to clients and crawlers. A search engine treats 410 as a strong signal to deindex immediately, while 404 may be retried for a while in case it was transient. A client with a stored reference can clean it up confidently on 410 rather than retrying. It is worth using when you soft-delete and can distinguish "deleted" from "never existed", and when you retire an endpoint permanently after a deprecation period. The cost is that you must retain enough information to know the difference, which means keeping tombstones. If you hard-delete rows, you cannot distinguish the two and 404 is the honest answer. The related case is a resource that moved: 301 with a Location header is better than either, since it tells the client where to go. Reserve 410 for genuine permanent removal.
Should GET ever have a request body?
No, in practice, even though the specification technically permits it while saying it has no defined semantics. The problems are numerous. Many HTTP clients and libraries will not send a body with GET. Proxies and caches may strip it. Caching is keyed on the URL, so two GETs with different bodies would collide. And nothing in the ecosystem expects it. The temptation arises with complex search queries that do not fit in a query string. The right answers there are either POST to a search endpoint, accepting the loss of caching and idempotent semantics, or creating a search resource with POST and then GETting the results by its ID, which restores both. The QUERY method has been proposed to fill exactly this gap — a safe, idempotent method that takes a body — but it is not yet widely available. So the practical guidance is: keep GET bodyless, use query parameters until they become unwieldy, and switch to POST-based search with clear documentation when they do. Attempting to be clever here costs more than it saves.
What are the OPTIONS and HEAD methods for?
HEAD is GET without a response body. The server returns the same headers it would for a GET — including Content-Length, ETag and cache headers — but no content. It is useful for checking whether a resource exists, whether it has changed, or how large it is before downloading. Link checkers use it, and clients use it to validate a cached copy cheaply. OPTIONS reports what is permitted for a resource, returning an Allow header listing supported methods. Its overwhelmingly common use in practice is the CORS preflight: the browser sends OPTIONS before a non-simple cross-origin request, and the server responds with the allowed origins, methods and headers. Handling that correctly is usually the only reason an API implements OPTIONS at all. Both should be implemented consistently if you implement them. A HEAD that returns a different status from the equivalent GET, or omits headers, breaks the clients relying on it. Most frameworks derive HEAD from GET automatically, which is the right default — implementing it separately risks the two drifting apart.
Should responses be wrapped in an envelope?
An envelope wraps the payload in a container: { "data": {...}, "meta": {...}, "errors": [...] }. The argument for it is a uniform shape. Clients always parse the same structure, metadata like pagination has an obvious home, and partial success in bulk operations can be expressed. The argument against is that HTTP already provides the envelope. Status codes carry the outcome, headers carry metadata, and wrapping duplicates that in the body. It also makes responses more verbose and slightly more awkward to consume — every access goes through .data. The modern preference leans toward no envelope for single resources, returning the resource directly, with metadata in headers. For collections, some wrapping is usually pragmatic because pagination metadata has to go somewhere, and Link headers alone are underused by clients. The important thing is consistency: an API that wraps some responses and not others is worse than either choice applied uniformly. And if you do envelope, never use it as an excuse to return 200 for errors — the status code must still be correct.
How should you handle null versus missing fields?
They mean different things and the distinction matters most in PATCH. In a response, an explicit null usually means "this field exists and has no value", while omitting it can mean "not applicable" or "not included in this view". Being consistent about which you do is what lets clients write reliable code. In a PATCH request the difference is critical. A field set to null usually means "clear this value"; a field omitted means "leave it alone". If your API cannot distinguish them — because your deserialiser turns both into a null field on the object — you cannot support clearing a value, which is a real functional gap. The fix is a wrapper type that distinguishes absent from present-and-null, or JSON Merge Patch which defines null as delete, or JSON Patch which has explicit operations. For responses, the pragmatic recommendation is to always include fields with explicit null rather than omitting them, because it makes the schema predictable and stops clients treating absence as an error. And document the convention, because clients will otherwise assume the opposite of whatever you chose.
What naming convention should JSON fields use?
camelCase or snake_case, chosen once and applied everywhere. camelCase matches JavaScript conventions and the majority of modern APIs. snake_case matches Python and Ruby idioms and many older APIs. Neither is wrong; mixing them is. The practical argument for camelCase is that the largest consumer of JSON is JavaScript, where snake_case fields read awkwardly and often get transformed at the boundary — and every transformation is a place bugs live. More important than the choice are the rules around it. Do not abbreviate inconsistently: if you use "id" do not also use "identifier". Do not encode types in names — "orderList" should just be "orders". Use plural for arrays and singular for objects. Prefer clarity over brevity, since these names are read far more often than typed. Boolean naming deserves care: prefix with is, has or can so it reads as a predicate — isActive rather than active, which could plausibly be a status string. And whatever you choose, enforce it with a linter on your schema rather than in code review, because consistency degrades otherwise.
How should dates and times be represented?
ISO 8601 in UTC with an explicit offset: 2026-08-31T14:30:00Z. Always as strings, never as epoch integers unless you have a specific reason. The reasons are readability, unambiguity, and that every language has a parser. Epoch timestamps are compact but opaque in logs and ambiguous about units — seconds or milliseconds is a genuine and recurring source of bugs. Always include the offset or Z. A timestamp without one is ambiguous, and someone will parse it as local time in a different timezone. Store and transmit in UTC, and convert to local time only at the presentation layer. Doing conversion server-side means the API is coupled to a user's timezone, which breaks caching and complicates everything. The exception worth naming is future events tied to a wall-clock time in a specific place — a meeting at 9am in Mumbai next year. Storing that as UTC is wrong, because if the timezone rules change the meeting moves. Those need the local time plus the IANA timezone identifier stored separately. And distinguish dates from timestamps: a birthday is a date with no time and no timezone.
How should monetary amounts be represented in an API?
Never as a floating point number. Binary floating point cannot represent most decimal fractions exactly, so 0.1 + 0.2 is not 0.3, and money arithmetic accumulates error. Two good options. An integer in the currency's minor unit — 1050 for ten rupees fifty paise — which is exact, compact and what Stripe uses. Or a string containing the decimal representation, which preserves precision through JSON parsing since many parsers turn numbers into doubles automatically. The integer approach has a catch: not every currency has two decimal places. Japanese yen has zero, Kuwaiti dinar has three. So the amount alone is meaningless — you must always pair it with a currency code. Which brings the general rule: always transmit amount and currency together as a unit, never a bare number. An API returning "price": 1050 with the currency implied by context is a bug waiting for internationalisation. Use ISO 4217 codes. And be explicit in documentation about whether the amount is in minor units, because that is the single most common integration mistake with payment APIs.
How do you support sparse fieldsets and expansion?
Sparse fieldsets let a client request only the fields it needs: GET /orders/123?fields=id,status,total. This reduces payload size, which matters on mobile, and can let the server skip expensive computations. Expansion is the inverse: GET /orders/123?expand=customer,items embeds related resources so the client avoids follow-up requests. Together they address the two failure modes of fixed responses — over-fetching and under-fetching — which is much of GraphQL's appeal, without abandoning REST. The design cautions are real. Expansion must be bounded: allow specific named relations, not arbitrary depth, or a client can request a graph traversal that costs you a thousand queries. Watch for N+1 on expanded collections — expanding items across a list of orders needs batched loading. Caching becomes harder, because each combination of parameters is a distinct cache entry. That fragmentation can undermine your hit rate, so restrict the allowed combinations rather than accepting anything. And keep the default sensible: the response with no parameters should be useful on its own.
What should you do about unknown fields in a request body?
Two policies, and the choice has real consequences. Strict rejection returns 400 when the body contains fields you do not recognise. It catches client typos immediately — a client sending "quantitiy" gets a clear error rather than silently having its value ignored — which is genuinely valuable during integration. Lenient ignoring accepts and discards unknown fields. It makes forward compatibility easier: a client written against a newer version can talk to an older server, and clients that round-trip a resource — GET, modify, PUT — do not break when the server adds fields. That round-trip case is the strongest argument for leniency, and it is a common client pattern. The usual resolution is to be lenient by default but offer a strict mode, or to be strict during a beta and relax at general availability. Whichever you pick, be explicit about it in documentation. The failure mode of silent ignoring is a client that believes it set a field and did not, which can go unnoticed for a long time — so if you are lenient, consider returning a warning in the response.
How should enums be designed in an API?
Use strings rather than integers. "status": "pending" is self-explanatory in logs and debugging; "status": 2 requires a lookup table and is a common source of confusion when the mapping changes. Use a consistent case convention — lowercase or SCREAMING_SNAKE — and stick to it. The important design question is evolution. Adding a new enum value is a breaking change for any client that switches exhaustively on the current set, because it will hit an unhandled case. Since adding values is inevitable, you must plan for it: document that clients should handle unknown values gracefully, ideally with a defined fallback behaviour, and treat that as part of the contract from version one. Some APIs include a catch-all "unknown" value so clients have something to map to. Removing or renaming a value is unambiguously breaking and needs a version. The other caution is not to overuse enums for things that are really open sets. A country code or a currency should be a string with a documented standard, not an enum you must redeploy to extend.
How do you handle file uploads in a REST API?
Three approaches, with different trade-offs. multipart/form-data posts the file alongside metadata in one request. Universally supported, simple for clients, but the file passes through your application server, consuming memory and connection time proportional to its size. Base64-encoding the file into a JSON field keeps everything as JSON, but inflates the payload by a third and forces the whole file into memory. Acceptable only for small files. Pre-signed URLs are the right answer at scale. The client asks your API for an upload URL, the API returns a time-limited signed URL for object storage, and the client uploads directly. Your servers never handle the bytes, which removes the bandwidth, memory and timeout problems entirely, and it scales without touching your application tier. The flow needs care: the client must notify the API once the upload completes so you can record it, or you poll storage events. Validate content type and size limits in the signature. And handle the orphan case where an upload completes but the confirmation never arrives. For large files, support resumable uploads.
What is content negotiation and how much of it should you implement?
Content negotiation lets the client state what it wants — Accept for media type, Accept-Language for language, Accept-Encoding for compression — and the server picks a matching representation. Accept-Encoding is the one everybody implements, usually transparently, and it is worth ensuring gzip or brotli is enabled since it typically cuts JSON payloads by 70 to 80 percent. Accept for media types is worth implementing if you genuinely serve multiple formats. If you only ever return JSON, honouring it amounts to rejecting requests that ask for something else, which is correct but low value. Accept-Language matters for user-facing content and error messages, and is often neglected. The critical operational detail is the Vary header. Any response that differs based on a request header must declare that header in Vary, or shared caches will serve the wrong variant to the wrong client. Forgetting Vary: Accept-Encoding is a classic bug that delivers gzipped content to a client that cannot decompress it. So the pragmatic scope is: always compress, always send Vary correctly, and implement media type negotiation only where multiple formats genuinely exist.
Should you return the full resource after an update?
Usually yes, and it is worth defending. Returning the updated resource saves the client a follow-up GET, which is a round trip it would almost always make anyway. It also lets the client see server-side effects it could not predict: normalised values, recomputed derived fields, an updated timestamp, and a new ETag for the next conditional request. That last point matters for optimistic concurrency — without a fresh ETag the client must re-read before its next update. The argument for 204 No Content is bandwidth and simplicity, and it is reasonable when the resource is large and the client demonstrably does not need it. The compromise some APIs offer is a Prefer header: Prefer: return=minimal or return=representation, letting the client choose, with the server confirming via Preference-Applied. That is the tidy answer where both patterns have real users. The thing to avoid is returning a partial or differently-shaped object — the response to an update should be the same representation a GET would return, or clients end up with two models for one resource.
How should you design an API to be friendly to client code generation?
The requirement is a machine-readable specification that is accurate — OpenAPI in practice. What helps generators: consistent response shapes per endpoint, so a single type can be generated. Explicit schemas for every request and response, including error responses, which are frequently omitted and then clients have no typed errors. Named schema components reused across endpoints rather than inline duplicates, so generated models are shared rather than repeated. And required versus optional marked honestly, because a generator will make everything nullable otherwise. What hurts: polymorphic responses without a discriminator, so the generator cannot tell which variant it received. Endpoints whose response shape depends on a query parameter. Free-form objects typed as "additionalProperties: true", which generate as untyped maps and lose all safety. And a specification that drifts from the implementation. That last point is the real one. A hand-maintained spec goes stale within months. Generate it from the code, or generate the code from it, so they cannot diverge — and add contract tests that fail the build when they do.
What are the options for API versioning and which would you choose?
URL path versioning — /v1/orders — is the most common. It is obvious, easy to route, trivially testable in a browser, and unambiguous in logs. Purists object that the resource has not changed so the URI should not, but the practical benefits are substantial. Header versioning uses a custom header or an Accept media type like application/vnd.company.v2+json. It keeps URLs clean and is theoretically more correct, but it is invisible in logs and browser testing, harder to route, and easier for clients to get wrong. Query parameter versioning — ?version=2 — is easy but interacts badly with caching and feels like an afterthought. The recommendation for most APIs is URL path versioning with major versions only. Minor, backward-compatible changes should not need a version at all — additive changes go into the existing version. The deeper point is that versioning is a last resort. Every version you support is code you maintain and test forever. The goal is to design so that most changes are additive and no new version is needed.
What counts as a breaking change?
Breaking: removing a field or endpoint, renaming anything, changing a field's type, making an optional request field required, adding a new validation rule that rejects previously valid input, changing a status code for an existing condition, changing the meaning of a value, or altering default behaviour. Non-breaking: adding a new optional request field, adding a new field to a response, adding a new endpoint, adding a new optional query parameter, or adding a new enum value — with a caveat. That caveat is important. Adding a response field is safe only if clients ignore unknown fields, and adding an enum value is safe only if clients handle unknown values. Both are true of well-written clients and false of many real ones. So you should state these expectations in your documentation from version one, because retrofitting them is impossible. Hyrum's law extends the list further: anything observable can be depended on, including undocumented ordering, timing and error message text. The practical discipline is a compatibility checklist in code review and automated schema diffing in CI that fails the build on a breaking change to a published version.
How do you deprecate an API version responsibly?
Announce, signal, measure, then remove — in that order and with real time between them. Announce with a concrete removal date, a migration guide showing before-and-after for each change, and direct communication to known consumers rather than only a changelog nobody reads. Signal in the responses themselves. The Deprecation header carries the deprecation date and Sunset carries the removal date; a Link header can point at the migration documentation. This reaches developers who never read your announcements but do read their logs. Measure who is still calling. Per-consumer metrics on the deprecated version are what turn a guess into a decision — you can contact the remaining callers directly, and you know whether removal will break someone important. Give a window proportional to your consumers. Internal APIs might be weeks; a public API with third-party integrations needs six to twelve months. Consider a brownout before removal: return errors for short scheduled windows so remaining clients notice while there is still time to react. It is far kinder than a silent cliff, and it surfaces the callers who ignored everything else.
Should you version the whole API or individual resources?
Whole-API versioning is simpler to reason about and to document. Every consumer is on v1 or v2, and you know exactly what that means. The cost is that a breaking change to one resource forces a version bump affecting everything, and clients must migrate the lot even if only one endpoint changed. Per-resource or per-endpoint versioning avoids that, letting each part evolve independently. But the combinatorial surface becomes hard to document, test and reason about — a client on orders v3 and customers v1 is a configuration you probably never tested. The pragmatic middle ground most large APIs land on is whole-API major versions used sparingly, with additive evolution inside them, plus targeted opt-in flags for individual behaviour changes. Stripe's approach is the well-known variant: date-based versions pinned per account, with the API transforming responses to match whichever version a customer is on. That gives per-customer stability without a version in the URL, at the cost of maintaining a chain of transformations — real engineering investment, justified by the scale of their integration base. For most APIs, whole-version with additive change is the right default.
How can you evolve an API without versioning at all?
By making every change additive and tolerant. Add fields, never remove or rename them. If a field must change meaning, add a new one and keep the old populated during a transition. If a field must go, stop documenting it, monitor usage, and remove only when nobody reads it. Make new request fields optional with sensible defaults, so old clients continue working unchanged. Use feature flags or opt-in headers for behaviour changes, so clients adopt them when ready rather than being moved. Design for tolerance from the start: document that clients must ignore unknown fields and handle unknown enum values. That single expectation is what makes additive change safe. The technique that makes removal possible is usage telemetry per field. If you know which consumers read a field, you can retire it with confidence rather than fear. The limits are real — some changes genuinely cannot be made additively, such as tightening validation or fixing a semantic bug that clients now depend on. But most changes can, and an API that avoids versioning for years is usually one that took this discipline seriously from the beginning.
How do you handle a bug in an API that clients have started depending on?
This is a genuine dilemma and the answer is not simply "fix it". First establish the blast radius: how many consumers rely on the buggy behaviour, and would fixing it break them silently or loudly? A silent break — wrong data rather than an error — is far more dangerous. If the bug produces incorrect data with real consequences, fixing it is usually right even at the cost of breakage, but it should be treated as a breaking change: announce it, give a window, and provide a way to opt in early. If the bug is cosmetic or the dependency is widespread, the pragmatic answer may be to document the behaviour as intended and leave it. Plenty of APIs have quirks that are now specification. The middle path is to add a correct alternative alongside the buggy one — a new field or an opt-in header — and migrate consumers over time, then remove the old behaviour at a version boundary. What you should not do is fix it silently in a patch release. That is how you cause an outage in someone else's system with no warning and no explanation.
What is the difference between backward and forward compatibility?
Backward compatibility means a new server works with old clients. Forward compatibility means an old server works with new clients — or more usefully, that an old client tolerates responses from a newer server. Backward compatibility is what most versioning discussion is about, and it is the server's responsibility: do not remove fields, do not tighten validation, do not change semantics. Forward compatibility is largely the client's responsibility, but the server enables it by declaring the rules. If clients ignore unknown fields and handle unknown enum values, the server can add both freely. If they do not, every addition is potentially breaking. This is why the tolerance expectation must be stated in your documentation from the start. You cannot retroactively require clients to be tolerant. In message-based systems the same distinction appears in schema evolution — Avro and Protobuf define exactly which changes are backward compatible, forward compatible, or both, and schema registries enforce it. The combination you usually want is full compatibility: changes that are safe in both directions, which in practice means adding optional fields and nothing else.
How do you test that an API change is not breaking?
Automate it rather than relying on review. Schema diffing is the first line: compare the new OpenAPI specification against the published one and fail the build on any change classified as breaking. Tools exist for this, and the classification rules are the list of breaking changes you have already defined. Contract tests are the second. Consumer-driven contracts — Pact is the common tool — let each consumer publish the subset of the API it actually uses, and the provider's build verifies it still satisfies every published contract. That catches breakage for real consumers rather than hypothetical ones, and it tells you precisely who would break. Recorded traffic replay is a strong third option: capture real production requests and replay them against the new version, comparing responses. It catches behavioural changes no schema diff can see. And keep a suite of tests written from the client's perspective, pinned to the published contract, that you never change when you change the implementation. If those tests need editing, you have made a breaking change — which is exactly the signal you want.
Compare offset and cursor pagination.
Offset pagination uses limit and offset, or page and size. It is simple, lets clients jump to an arbitrary page, and shows a total count. It has two serious problems. Performance: OFFSET 100000 makes the database scan and discard a hundred thousand rows, so deep pages get progressively slower. And correctness: if rows are inserted or deleted while a client paginates, items shift between pages — the client sees duplicates or misses records entirely. Cursor pagination passes an opaque token representing a position, typically encoding the sort key of the last item seen. The query becomes WHERE sort_key > cursor LIMIT n, which uses an index and costs the same regardless of depth. Concurrent inserts do not shift the window. The costs are no random page access, and usually no total count since computing it defeats the purpose. The recommendation: cursor pagination for anything large, real-time, or public-facing. Offset is acceptable for small bounded datasets and admin interfaces where page numbers are genuinely wanted. Offering both is reasonable, with cursor as the default.
How do you implement a cursor correctly?
The cursor encodes the position of the last item in the sort order, and the query continues strictly after it. The critical detail is that the sort key must be unique and stable. Sorting by created_at alone is not enough — two rows with the same timestamp mean the cursor is ambiguous and you will skip or duplicate items. The fix is a compound sort on the timestamp plus a unique tiebreaker such as the primary key, with the cursor encoding both and the query using a row-value comparison. That tiebreaker is what people miss, and the bug it causes is rare enough to survive testing and appear in production. Make the cursor opaque — Base64-encode it — so clients treat it as a token rather than parsing and constructing their own, which would couple them to your schema and let them craft invalid positions. Include enough in the cursor to validate it: if the sort order or filter changes, the cursor is meaningless and you should reject it rather than returning nonsense. And decide whether cursors expire. Encoding a timestamp lets you refuse very old cursors rather than scanning from a long-deleted position.
Should a paginated response include a total count?
It depends on cost, and the honest answer is that people underestimate it. A total requires a separate COUNT query over the filtered set, which on a large table with a complex filter can be far more expensive than fetching the page itself — and it cannot use the LIMIT that makes the page cheap. So for large datasets, omitting the total is a legitimate choice. Many large APIs do exactly this, providing only a next cursor and a hasMore flag. Where a count is genuinely needed for UI, the options are: an approximate count from table statistics, which is fast and usually good enough for "about 40,000 results"; a capped count that stops at some ceiling and reports "1000+"; or computing it only when explicitly requested via a parameter, so the cost is opt-in. With cursor pagination a total is somewhat contradictory anyway — the set may be changing as you page through it, so any total is a snapshot that is already stale. The design guidance is to make it opt-in and document the cost, rather than making every caller pay for something most do not use.
How should filtering and sorting parameters be designed?
Keep the common case simple: exact-match filters as plain query parameters, GET /orders?status=pending®ion=west, with multiple parameters combining as AND. For operators beyond equality, adopt a consistent syntax rather than inventing one per field. Bracketed operators — price[gte]=100&price[lt]=500 — or suffixes — created_after= — both work; what matters is that it is uniform and documented. Sorting: a single sort parameter accepting a comma-separated list with a direction prefix, sort=-created_at,name, is compact and conventional. The non-negotiable rules are about safety. Allowlist which fields may be filtered and sorted; do not pass client input into a query builder. Every sortable field needs an index, or a client can trigger a full table sort. Cap the page size. And be aware that arbitrary filter combinations produce query plans you have never tested — which is how a single API call takes down a database. The deeper judgement is knowing when to stop. Once clients need boolean logic and nesting, you are building a query language, and it is better to offer a deliberate search endpoint than to keep extending query parameters.
Where should pagination metadata go — body or headers?
Both are used, and each has a defensible case. The Link header is the standards-based approach, carrying next, prev, first and last URLs with rel attributes. GitHub popularised it. The advantage is that the body stays a clean array of resources with no envelope, and the URLs are complete so the client does not construct them. The drawback is that many clients ignore headers, header parsing is more awkward in some languages, and it is invisible when someone is exploring the API by eye. Body metadata — a pagination object alongside the data — is more discoverable and easier to consume, at the cost of requiring an envelope around the collection. The pragmatic answer is to do both: Link headers for correctness and tooling, and a pagination object in the body for convenience. The duplication is small and it satisfies every consumer. Whichever you choose, return complete URLs rather than raw cursor tokens where you can. A client that follows a next URL cannot construct it wrongly, which eliminates a whole category of integration bugs.
What is the N+1 problem in an API context and how do you avoid it?
The client fetches a list, then makes one additional request per item to get related data — one call for a hundred orders, then a hundred calls for their customers. It also occurs server-side: an endpoint returns a list and the implementation queries the database once per item to populate a nested field, so one API call becomes a hundred queries. The client-side fix is to give clients a way to avoid it: an expand parameter embedding related resources, or a batch endpoint accepting multiple identifiers. Without one, clients have no option but to loop. The server-side fix is batched loading — collect the identifiers needed and issue a single query with IN, then stitch the results together. DataLoader popularised this pattern in GraphQL, and the same technique applies anywhere. The detection method is to count queries per request in tests, not to eyeball the code. An assertion that a list endpoint issues a constant number of queries regardless of result count catches regressions that code review misses, because the N+1 is usually introduced by adding an innocuous-looking field.
How do you paginate a resource that changes frequently?
Offset pagination is unusable here — inserts and deletes shift the window, so the client sees duplicates or gaps. Cursor pagination is much better because the position is anchored to a value rather than a count, so inserts before the cursor do not affect what comes next. But cursors do not fully solve it either. If you sort by a mutable field, an item can move across the cursor boundary and be seen twice or missed. So sort by something immutable — creation time plus ID, not last-modified. For a genuinely consistent snapshot you need a point-in-time view: capture a timestamp or transaction ID when pagination begins, include it in the cursor, and filter to rows as of that instant. Databases with MVCC can support this, at the cost of holding a snapshot open. The alternative for high-churn data is to stop pretending it is a stable list. Expose a change feed instead — an append-only stream of events since a given position — which is what clients synchronising state actually want. Paginating a moving target is often the wrong abstraction.
What page size limits should an API enforce?
Always enforce a maximum, and choose a sensible default. Without a cap, a client will request everything — limit=1000000 — and either exhaust your memory serialising it, time out, or take down the database. Treating this as a client problem is wrong; it is a server responsibility. A typical shape is a default of 20 to 50 and a maximum of 100 to 1000, depending on how expensive an item is to produce. The behaviour when a client exceeds the maximum is a design choice. Silently clamping to the maximum is forgiving but hides the constraint, so a client believing it got everything may miss data. Returning 400 is explicit and safer, because the client learns immediately. Clamping with the actual page size echoed in the response metadata is a reasonable middle ground — the client can detect it if it looks. Document the default and maximum, since they are among the first things an integrator needs and among the most commonly omitted from documentation. For genuinely large exports, do not raise the limit — provide an asynchronous export job instead.
How would you design an API for exporting a large dataset?
Not as a paginated endpoint that the client loops over — that is slow, fragile, and hammers your database. The standard pattern is an asynchronous job. POST to create an export, specifying the filter and format, and receive 202 with a status resource. The client polls, or receives a webhook, and eventually gets a URL to download the finished file. Generate the file to object storage and hand back a time-limited pre-signed URL, so the download does not pass through your application servers at all. The design details that matter: run the extraction against a replica so it does not affect production load. Stream the output rather than building it in memory. Give jobs an expiry so files are cleaned up. And rate limit export creation, since it is an expensive operation to trigger. For formats, CSV and newline-delimited JSON both stream well; a single large JSON array does not, because it cannot be parsed incrementally without special handling. The alternative for continuous rather than one-off needs is a change feed, which avoids repeated full exports entirely.
Should list endpoints return full resources or summaries?
Summaries by default, with the full resource available at the member URL. The reason is cost. A list of a hundred items returning every field, including expensive computed ones and nested relations, is often far larger and slower than the client needs — most list views display a handful of fields. So the list representation should contain identity, the fields needed to render a row, and a link or ID to fetch the full resource. The risk is under-fetching, where clients must call the member endpoint for every row and you have created an N+1. The mitigation is to include the fields clients actually use, informed by looking at what they call, and to offer a fields or expand parameter for clients that want more. The consistency question is whether the summary and full representations should share a schema. Making the summary a strict subset — same field names and types, just fewer of them — is much easier for clients and code generators than two divergent shapes. Document which fields appear in which context, because the difference is a common source of confusion.
What should an error response body contain?
Enough for a developer to diagnose and for a client to react programmatically. The minimum is a stable machine-readable error code — not the HTTP status, but an application-specific identifier such as "insufficient_funds" that clients can branch on without parsing prose. A human-readable message. And where applicable, field-level details listing which inputs failed and why. A request or trace identifier is enormously valuable: it lets a developer report "request abc123 failed" and lets you find it immediately in logs. Including it in every response, not just errors, is better still. RFC 7807 problem+json is the standard shape — type, title, status, detail, instance — and adopting it means clients may already have handling for it. What to exclude: stack traces, SQL, internal hostnames, or anything revealing implementation. Those leak information useful to an attacker, and they are noise to the caller. The consistency requirement matters as much as the content. One error shape across the whole API means clients write error handling once. APIs where validation errors, auth errors and server errors have three different shapes are painful to integrate against.
How do you report multiple validation errors?
Return all of them at once, not the first. Failing fast on the first invalid field forces the client into a round-trip loop — fix one field, resubmit, discover the next. For a form with ten fields that is a terrible experience, and it is entirely avoidable. The shape is an array of field errors, each with the field path, a machine-readable code, and a message. The path should handle nesting and arrays — something like "items[2].quantity" — so a client can map the error back to a specific input. Use a code rather than relying on the message, because messages get reworded and localised while codes must stay stable. That distinction is what lets a client display its own message or take specific action. Status code is 400 or 422 depending on your convention. Two further considerations: field-level errors should be separable from request-level ones — "these three fields are wrong" versus "this whole request conflicts with current state" — and you should decide whether to include the rejected value in the response, which is helpful for debugging but risky if the field is sensitive.
How much internal detail should an error expose?
Enough to act on, nothing that helps an attacker or couples clients to your implementation. Expose: what went wrong in domain terms, which input was at fault, whether retrying might help, and a correlation identifier. Do not expose: stack traces, database error text, SQL fragments, internal service names, file paths, library versions, or configuration. These reveal your architecture and dependencies, and specific messages have been used to fingerprint vulnerable versions. The subtler leak is differential error messages in authentication. "User not found" versus "wrong password" tells an attacker which usernames exist, enabling enumeration. Both should return the same generic failure. The same applies to authorisation, where returning 403 confirms a resource exists — which is why 404 is sometimes the right answer for resources the caller cannot see. The practical implementation is to log the full detail server-side against the correlation ID, and return the identifier to the client. The developer who needs the stack trace can get it from your logs; the internet cannot. Default to a generic message for unexpected 500s, since those are exactly where uncontrolled detail escapes.
How should an API signal that a request may be retried?
Primarily through the status code, and explicitly where ambiguity remains. 5xx generally means the server failed and a retry may succeed. 4xx means the request was wrong and retrying unchanged will not help. That distinction is the main signal, and it is why returning the wrong class is so damaging — a 500 for a validation error causes pointless retries, and a 400 for a transient failure prevents useful ones. 503 specifically means temporarily unavailable, and should carry Retry-After. 429 likewise. Retry-After turns "retry sometime" into "retry in this many seconds", which is what lets clients coordinate rather than guess. For cases where the outcome is genuinely uncertain — a timeout where the request may or may not have been processed — the API cannot help directly. The answer there is idempotency support, so the client can safely retry regardless. An explicit retryable flag in the error body is a reasonable addition for nuanced cases, such as a 409 that will resolve on its own versus one that will not. And document the retry policy you expect, since clients otherwise invent their own.
What is a correlation ID and how should it flow through an API?
A correlation or trace ID is a unique identifier attached to a request and propagated through every downstream call it triggers, so all the resulting log lines can be tied together. The API should accept one from the client if provided — so the client can correlate its own logs with yours — and generate one if not. Return it on every response, including errors, in a header. Downstream, it must be forwarded on every internal call, which is what makes it useful in a distributed system: one identifier lets you reconstruct the whole path of a request across services. W3C Trace Context standardised the headers — traceparent and tracestate — and using those rather than a custom header means tracing tools understand it automatically. The operational payoff is enormous. A customer reporting a failure gives you the ID from the response; you find every log line and span for that request across every service in seconds, rather than searching by timestamp and guessing. The implementation detail that matters is propagation through asynchronous boundaries — queues, background jobs — where it is most often dropped and most needed.
How do you handle partial failure in an API that calls multiple downstream services?
Decide, per endpoint, whether partial results are acceptable or whether the operation is all-or-nothing. If the missing data is non-essential — a recommendations panel alongside an order — degrade gracefully: return the order, omit or null the recommendations, and indicate that in the response so the client knows it is absent rather than empty. Returning 200 with a partial payload is correct here, provided the absence is distinguishable. If the data is essential, fail the whole request with a 5xx. Returning a response missing critical fields, with a 200, is worse than failing. For operations that write to several systems, partial failure is a consistency problem rather than a presentation one. You need either a saga with compensating actions, or an outbox so the work is eventually completed, or you must accept and document the inconsistency window. What matters in interviews is naming the decision explicitly rather than defaulting. The wrong pattern is a partially-populated 200 that clients cannot distinguish from a genuinely empty result — that turns an upstream outage into silently wrong data.
Should error messages be localised?
Only if they are shown to end users, and even then it is usually better not to. The cleaner design is that the API returns a stable machine-readable code and the client renders the message. That puts localisation where the locale is actually known — the presentation layer — and means adding a language does not require an API change. It also avoids a real coupling problem: if clients display your message directly, you cannot reword it without changing what users see, and someone will inevitably match on the text. Where the API does localise — because it serves many clients that would otherwise duplicate the work — honour Accept-Language, fall back to a default sensibly, and remember to send Vary: Accept-Language so caches do not serve the wrong language. Either way, always include the untranslated code, and consider including parameters separately — the field name, the limit that was exceeded — so a client can construct its own message with the specifics interpolated. Developer-facing detail should stay in English and should not be localised at all, since it goes in logs and bug reports.
What is the most common error handling mistake you see in APIs?
Returning 200 for failures, with the real outcome buried in the body. It defeats every intermediary, every monitoring system and every generic client library, and it makes error rates invisible. Close behind: inconsistent error shapes across an API, so clients need three different parsers. Leaking stack traces to callers. Using 500 for validation failures, which pollutes error budgets and triggers alerts for client bugs. And messages that describe the internal failure rather than what the caller did wrong — "null pointer exception" tells the integrator nothing actionable. The underlying cause is usually that error handling is added late and per-endpoint rather than designed once. Errors are part of the contract and deserve the same design attention as success responses, including being in the OpenAPI specification, which they usually are not. The fix that has the most leverage is a single error type and a global handler that maps every exception to it, so no endpoint can produce a bespoke shape. Then document the error codes as thoroughly as the endpoints, because for anyone integrating, the failure paths are where the time goes.
What are the options for API authentication and when does each fit?
API keys are a shared secret sent in a header. Simple, good for server-to-server and for identifying an application rather than a user. They do not expire on their own, so rotation must be designed in, and they carry no scope unless you add it. Bearer tokens from OAuth 2.0 are the standard for delegated access — letting an application act on a user's behalf without holding their password. Short-lived access tokens plus a refresh token limits the damage from a leak. JWTs are a token format, not an auth scheme. Self-contained and verifiable without a lookup, which is their appeal and their weakness — revocation is hard. Mutual TLS authenticates the client with a certificate at the transport layer. Strong, no bearer secret to leak, but certificate management is real work. HMAC request signing proves possession of a secret without transmitting it and can cover the request body, protecting against tampering. AWS uses this. More complex for clients to implement correctly. The choice follows the threat model and who the caller is — a human user, a first-party app, or another service.
What are the trade-offs of using JWTs for API authentication?
A JWT carries its claims and a signature, so the server can validate it without a database lookup. That statelessness is the entire appeal: any instance can verify any token, which scales trivially. The cost is revocation. Once issued, a JWT is valid until it expires — you cannot invalidate it without introducing exactly the shared state you were avoiding. If a token is stolen, or a user is deactivated, or permissions are reduced, the token keeps working. The usual mitigations are short expiry with refresh tokens, which narrows the window to minutes, and a revocation list for the exceptional cases — which is a cache lookup, but only for revoked tokens rather than every request. Other pitfalls: never accept the alg header from the token, since alg=none and algorithm confusion attacks follow from that. Do not put sensitive data in the payload, which is Base64, not encrypted. And watch size — a JWT with many claims in a header on every request adds up. For a first-party API with a session, an opaque token in Redis is often simpler and revocable. JWTs earn their place across trust boundaries.
Explain the OAuth 2.0 authorization code flow with PKCE.
The application redirects the user to the authorization server, which authenticates them and asks for consent. On approval it redirects back with a short-lived authorization code. The application exchanges that code, plus its credentials, for an access token at the token endpoint. The two-step exchange exists so the token never passes through the browser's URL, where it would land in history, logs and Referer headers. PKCE adds protection for clients that cannot keep a secret — mobile apps and single-page applications. The client generates a random verifier, sends its hash as the challenge with the initial request, and presents the original verifier when exchanging the code. An attacker who intercepts the code cannot exchange it without the verifier. That closes the authorization code interception attack, which was real on mobile where a malicious app could register the same redirect URI. PKCE is now recommended for all clients, including confidential ones, since it costs nothing and defends against code injection. The key point to make is that OAuth is about delegated authorization — letting an app act for a user — not about authenticating the user to your API. OpenID Connect is the layer that adds identity.
What is the difference between OAuth 2.0 and OpenID Connect?
OAuth 2.0 is an authorization framework. It answers "may this application access this resource on this user's behalf?" and produces an access token. It deliberately says nothing about who the user is. OpenID Connect is an identity layer on top. It answers "who is this user?" and produces an ID token — a JWT with standard claims about the user's identity, issued alongside the access token. The distinction matters because using OAuth alone for login is a known anti-pattern. An access token proves the bearer may call an API; it does not prove the user's identity to your application, and treating it as if it does leads to the confused deputy problem where a token issued for one application is replayed against another. OpenID Connect fixes this by binding the ID token to a specific client with an audience claim, and requiring the client to validate it. So: OAuth to call an API on someone's behalf, OpenID Connect to log someone in. "Sign in with Google" is OIDC, even though people describe it as OAuth. Validating the ID token — signature, issuer, audience, expiry, nonce — is the part implementations most often get wrong.
How should API keys be managed?
Treat them as credentials with a lifecycle, not as configuration that is set once. Store only a hash server-side, exactly as you would a password. If your database leaks, the keys should not be usable. Show the full key to the user once at creation and never again. Make them identifiable: a prefix indicating the environment and key type helps developers avoid using a test key in production, and lets secret scanners recognise a leaked key in a repository. Several providers now register their prefixes with GitHub so leaks are detected automatically. Support multiple active keys per account so rotation is possible without downtime — create the new key, deploy, revoke the old one. A single key that must be replaced atomically guarantees either downtime or reluctance to rotate. Scope keys to specific permissions and, where practical, to source IPs. Expose usage metadata — last used, created, by whom — so unused keys can be found and revoked. And give them an expiry by default. Keys that never expire accumulate for years in systems nobody remembers, which is exactly the credential that eventually leaks.
What is CSRF and does a REST API need protection from it?
Cross-Site Request Forgery is a browser attack: a malicious page causes a user's browser to make an authenticated request to your API, relying on the browser automatically attaching credentials. The crucial condition is automatic credential attachment. Cookies are sent by the browser on every matching request regardless of who initiated it, which is what makes the attack possible. So an API authenticated by an Authorization header is not vulnerable, because the browser does not add that header automatically — the attacking page would have to set it, and cross-origin restrictions prevent reading the response anyway. An API authenticated by cookies is vulnerable and needs protection. The defences: SameSite cookies, now defaulting to Lax in browsers, which stops cookies being sent on most cross-site requests and handles the majority of cases. Anti-CSRF tokens, where a token is embedded in the page and echoed in the request. And checking Origin or Referer headers. The practical answer is that a token-based API needs no CSRF protection, a cookie-based one does, and SameSite=Lax plus a token for state-changing requests is the belt-and-braces position.
What is insecure direct object reference and how do you prevent it?
IDOR is when an API exposes an identifier and fails to verify that the caller is authorised for that specific object. Changing /orders/123 to /orders/124 returns someone else's order. It is consistently among the most common serious API vulnerabilities, because authentication is usually implemented correctly and authorisation is checked inconsistently — the endpoint verifies you are logged in, not that this record is yours. The prevention is to check ownership or permission on every single access, not just on the entry point of a flow. The reliable way to do that is structurally: scope every query by the authenticated principal, so the database cannot return another user's row even if the identifier is wrong. WHERE id = ? AND owner_id = ? rather than fetching then checking. Enforcing that at the data access layer, rather than relying on each handler to remember, is what makes it robust. Unguessable identifiers such as UUIDs reduce casual exploitation but are not a control — they are obscurity, and identifiers leak through logs, referrers and shared links. Automated testing helps: a test suite that attempts cross-tenant access on every endpoint catches regressions.
How do you design an API for multi-tenancy safely?
The tenant must be derived from the authenticated credential, never from a request parameter. If a client can send a tenant ID, sooner or later someone will send a different one. Once derived, enforce it structurally rather than per-handler. Options in increasing strength: a query filter applied by the data access layer, row-level security in the database so the constraint is enforced below the application, a schema per tenant, or a database per tenant. The trade is isolation against operational cost. Shared tables with row filtering scale to many tenants cheaply but rely on correct code everywhere. Database-per-tenant gives strong isolation and simple compliance stories but becomes unwieldy past a few hundred tenants and makes migrations painful. Row-level security is a good middle ground because the guarantee lives in the database, so an application bug cannot leak across tenants. Beyond data access, tenancy affects rate limiting, which should be per tenant so one customer cannot starve others; caching, where the tenant must be part of every cache key; and logging, where tenant context is essential for support but must not leak between tenants.
What is mass assignment and how do you prevent it?
Mass assignment is binding a request body directly onto a domain object or database entity, so any field the attacker includes gets set — including ones the API never intended to expose. The classic exploitation is sending "role": "admin" or "isVerified": true in a profile update. If the framework maps the whole body onto the entity, the privilege escalation succeeds silently. GitHub was famously compromised this way, and it remains common because frameworks make the unsafe path the convenient one. The prevention is an explicit input type — a DTO or request model containing only the fields the endpoint accepts — mapped deliberately to the domain object. Anything not on that type cannot be set, regardless of what the client sends. Allowlisting is the principle: enumerate what is permitted rather than blocking what is not, because a denylist misses fields added later. The same discipline applies on output. Serialising a domain entity directly leaks whatever fields it happens to have, including password hashes and internal flags, and adding a field to the entity silently exposes it. A separate response type prevents that. This is the strongest argument for not sharing types between your API and your persistence layer.
How should an API handle sensitive data in logs and responses?
Assume every request and response may be logged somewhere you do not control, and design accordingly. Never put secrets in URLs. Query strings appear in access logs, browser history, proxy logs and Referer headers. Tokens, passwords and personal identifiers belong in headers or bodies. Redact at the logging layer, not by remembering at each call site. A structured logger with a known set of sensitive field names — password, token, authorization, card number — that masks them automatically is far more reliable than developer discipline. Default to redacting unknown fields in sensitive contexts rather than allowlisting. Be careful with error messages, which frequently echo the offending input back — a validation error that includes the rejected password is a real pattern. On responses, do not return data the caller does not need. A user object including an email hash, internal flags or audit fields is unnecessary exposure, and it becomes a compliance question once it reaches a client. Also consider retention: if request bodies are logged for debugging, they are now personal data subject to deletion requests, which is a commitment worth making deliberately rather than by accident.
What is the confused deputy problem in an API context?
A confused deputy is a service with more authority than its caller, tricked into using that authority on the caller's behalf. The classic API version: a backend service holds broad database credentials and exposes an endpoint that fetches a record by ID. The service is authorised to read everything; the caller is not. If the service does not check the caller's permission for that specific record, its own privilege is exploited. It also appears with server-side request forgery: an endpoint that fetches a URL the client supplies. The server can reach internal services and the cloud metadata endpoint that the client cannot, so the client uses the server as a proxy into your private network. This is why 169.254.169.254 is such a common SSRF target. The principle is that having authority is not permission to exercise it on request. Every operation must be checked against the caller's authority, not the service's. For SSRF specifically: allowlist permitted destinations rather than blocking known-bad ones, resolve and validate the address after DNS resolution to prevent rebinding, and block link-local and private ranges explicitly.
How do you handle authorization for fine-grained permissions?
Start by choosing a model. Role-based access control assigns permissions to roles and roles to users — simple, comprehensible, and sufficient for most systems, but it becomes unwieldy when permissions depend on the specific object. Attribute-based control evaluates rules over attributes of the user, the resource and the context. More expressive, harder to reason about and audit. Relationship-based control, as in Google's Zanzibar model, expresses permissions as relationships — this user is an editor of this document, which is in this folder that they own. It handles hierarchical and shared ownership naturally, which RBAC does badly. Whatever the model, the implementation guidance is the same: centralise the decision so it is made in one place with one policy, rather than scattered through handlers where it drifts. A policy engine or a dedicated authorization service gives you that, plus auditability. And make the default deny. An endpoint added without an explicit policy should fail closed, not be open because nobody remembered. Exposing the caller's effective permissions on a resource is also worth doing, so clients can render UI correctly rather than guessing and getting a 403.
How do you make a REST API cacheable?
Start by using GET for anything readable, since only safe methods are cacheable in practice. Then send explicit cache directives. Cache-Control with max-age tells caches how long the response is fresh. public or private controls whether shared caches may store it — anything personalised must be private, or a CDN will serve one user's data to another. Use no-store for genuinely sensitive responses. Provide validators — an ETag or Last-Modified — so that after expiry a client can revalidate cheaply and receive 304 rather than the full body. Send Vary listing every request header the response depends on, or shared caches will serve the wrong variant. Forgetting Vary: Authorization on a personalised response is a serious bug. stale-while-revalidate is the most useful modern directive: serve the stale copy immediately while refreshing in the background, which removes revalidation latency entirely. The design decision underneath all this is separating cacheable from personalised resources. An endpoint mixing public catalogue data with user-specific fields cannot be cached well; splitting them lets the expensive shared part be cached aggressively.
How do ETags support both caching and concurrency control?
An ETag identifies a specific representation of a resource — typically a hash of its content or a version number. For caching it enables conditional GET. The client stores the ETag and later sends If-None-Match. If the resource is unchanged the server returns 304 with no body, saving bandwidth though not the round trip. For concurrency it enables conditional writes. The client sends If-Match with the ETag it read. If the resource has changed since, the server returns 412 Precondition Failed and the write is rejected. That second use is what prevents lost updates: two clients read the same record, both modify it, and without a check the second silently overwrites the first. With If-Match, the second client is told its copy is stale and must re-read. This is optimistic concurrency — no locks, just detection of conflict at write time — and it is the right default for HTTP APIs because holding a lock across a request is not viable. The implementation caution is that ETags must be consistent across instances. Generating one from a file timestamp or inode differs per server and breaks both uses.
How do you prevent a slow endpoint from taking down your API?
Isolate it, bound it, and shed load before you are saturated. Bounding means timeouts on every downstream call and an overall request deadline, so no request can occupy a thread indefinitely. A missing timeout is how a slow dependency becomes an outage. Isolating means bulkheads — separate connection pools and thread pools per dependency — so a slow downstream exhausts only its own capacity rather than every thread in the service. Without this, one degraded dependency blocks requests that do not even use it. Circuit breakers convert sustained failure into fast failure, freeing resources and giving the dependency room to recover. Load shedding is the part people omit: when the queue exceeds a threshold, reject new requests immediately with 503 rather than accepting work you cannot complete. Accepting everything and timing out is worse for everyone, because you do the work and nobody receives it. Rate limiting per consumer prevents one caller monopolising capacity. And expensive operations should be asynchronous rather than synchronous, so a slow job never occupies the request path at all.
When should an API use compression, and what are the caveats?
Almost always for text responses. JSON compresses extremely well — typically 70 to 85 percent — so gzip or brotli is close to free bandwidth savings, and both are negotiated automatically via Accept-Encoding. Brotli compresses better than gzip at similar cost and is widely supported; gzip remains the safe fallback. The caveats. Do not compress already-compressed payloads — images, video, archives — since you spend CPU for nothing. Very small responses can grow slightly, so most servers apply a minimum size threshold of around a kilobyte. Compression costs CPU, which matters at high request rates; offloading it to a reverse proxy or CDN is common. The security caveat is BREACH and CRIME: when a response contains both a secret and attacker-controlled input, compression ratio leaks information about the secret. That is why compressing responses containing CSRF tokens alongside reflected input was exploitable. Mitigations are to avoid reflecting user input into responses containing secrets, or to mask tokens per response. And always send Vary: Accept-Encoding, or a cache may hand a compressed body to a client that cannot decompress it.
How do you decide what to cache and where?
Work outward from the client, since the closer the cache the greater the saving. Browser or client cache is cheapest — no network at all. Suitable for static assets and responses with a meaningful freshness window. Controlled by Cache-Control. CDN or edge cache serves many users from one stored copy. Only for shared, non-personalised responses, and the cache key must include everything that varies. This is where most bandwidth savings come from for public APIs. A shared application cache such as Redis sits in front of expensive computation or database queries. It is under your control, so invalidation can be precise, and it can hold personalised data safely. In-process caching is fastest but per-instance, so it multiplies memory and can serve inconsistent results across instances — acceptable for immutable reference data, risky otherwise. The decision inputs are how expensive the data is to produce, how often it changes, how stale it may be, and whether it is shared or personalised. The hardest part is invalidation, so prefer short TTLs and event-driven invalidation over long TTLs with manual purging.
What is a cache stampede and how do you prevent it?
A cache stampede — or dogpile — is when a popular cached entry expires and every concurrent request simultaneously misses, all hitting the origin at once. The database is hit by hundreds of identical expensive queries, and the resulting slowness can cascade. It is most damaging precisely for the hottest keys, which is what makes it dangerous. The preventions: request coalescing, where the first request to miss computes the value and the others wait for that result rather than duplicating the work. This is the most effective single measure. Probabilistic early expiry, where a request has a small and increasing chance of refreshing the entry before it formally expires, so refreshes are spread out rather than synchronised. Serving stale while revalidating in the background, so no request ever waits on a miss. And jittered TTLs, so entries populated together do not expire together — without jitter, a cold start populates thousands of keys with identical TTLs that then all expire at the same instant. The general principle is the same as with thundering herds: synchronisation is the enemy, and randomisation plus coalescing is the fix.
How do you measure and monitor API performance meaningfully?
Percentiles, not averages. A mean latency hides the tail entirely — an endpoint averaging 50 ms can have a p99 of three seconds, and that p99 is a meaningful fraction of your users having a bad experience. Track p50, p95 and p99 at minimum. Measure per endpoint, not aggregated. A single overall latency metric is dominated by whichever endpoint has the most traffic, so a slow but important endpoint is invisible. Measure from the client's perspective where possible. Server-side timing excludes queueing, network and TLS, which can be most of what the user experiences. The RED method is a good structure: rate, errors and duration for every endpoint. Pair it with saturation metrics for the resources behind it. Exclude health checks and internal traffic, which otherwise skew everything toward fast trivial requests. And instrument with distributed tracing so a slow request can be decomposed into its downstream calls. Knowing an endpoint is slow is much less useful than knowing which of its four dependencies is responsible, and tracing is the only thing that answers that reliably.
What is conditional request handling and why is it underused?
Conditional requests let a client say "only do this if the resource is in the state I expect" using If-None-Match, If-Match, If-Modified-Since or If-Unmodified-Since. For reads, this saves bandwidth via 304 responses. For writes, it provides optimistic concurrency control, rejecting updates based on stale data with 412. It is underused because it requires work on both sides. The server must generate stable, consistent validators — which is easy to get wrong across multiple instances — and the client must store and send them, which most client code does not do by default. The cost of not using it is real though. Without conditional writes, concurrent updates silently overwrite each other, and that class of bug is very hard to detect because nothing errors — a user's change simply vanishes. The recommendation is to support If-Match on updates for any resource where concurrent modification is plausible, and to consider requiring it — returning 428 Precondition Required when the header is absent — for resources where a lost update would be serious. That forces clients into safe behaviour rather than hoping they opt in.
What makes API documentation genuinely good?
Accuracy first — documentation that has drifted from the implementation is worse than none, because people trust it. That argues for generating it from the code or generating the code from it, with contract tests preventing divergence. Beyond accuracy: a quick start that gets someone to a successful call in a few minutes, because that is when people decide whether to persevere. Complete examples with real request and response bodies, not just schemas. Documented error responses, which are usually missing and are where integrators actually spend their time. Authentication explained end to end, including how to obtain and rotate credentials. Rate limits, pagination behaviour, and default and maximum page sizes stated explicitly. A changelog, so consumers can see what changed without diffing. And guidance on the things clients must do to remain compatible — ignore unknown fields, handle unknown enum values, honour Retry-After. The test worth applying is whether someone outside your team can integrate without asking you a question. If they always need to ask, the documentation is incomplete regardless of how thorough it looks.
Should the OpenAPI spec be written by hand or generated?
Either, but they must be tied together so they cannot drift. Spec-first means writing the OpenAPI document, reviewing it as a design artefact, and generating server stubs and client SDKs from it. The advantage is that the contract is designed deliberately and can be agreed with consumers before implementation. It also makes parallel work possible — clients build against a mock while the server is written. Code-first means annotating the implementation and generating the spec. It stays accurate with less effort, and it suits teams that iterate quickly. The failure mode of spec-first is a document that is edited by hand, diverging from what was built. The failure mode of code-first is a spec that reflects implementation accidents rather than intended design, exposing internal shapes. The resolution in both cases is validation in CI: either generate the spec and fail if it differs from the committed one, or validate the running service against the committed spec with contract tests. For a public API, spec-first is usually right because the contract deserves deliberate design. For internal services, code-first is often pragmatic.
How do you design an API that other teams will actually adopt?
Treat it as a product with users, not an implementation detail you have exposed. That means starting from the consumer's use case rather than your data model. An API shaped like your database tables forces every consumer to reassemble your domain, and it couples them to your storage decisions. Involve prospective consumers in the design — a review of the proposed spec before implementation catches mismatches when they are cheap to fix. Make the first call easy: sane defaults, minimal required parameters, and credentials that are quick to obtain. Adoption is decided in the first thirty minutes. Provide a client library or at least a generated SDK for the languages your consumers use, since hand-rolling HTTP calls and error handling is friction. Be explicit about stability guarantees and deprecation policy, because teams will not build on something that might change without warning. And support it: a channel where questions get answered, and someone who owns it. An unowned API decays, and consumers learn to route around it — which is how you end up with three services doing the same thing.
What are API design guidelines and why have them?
A written document specifying the conventions every API in an organisation follows: URL structure, naming case, pagination style, error format, versioning approach, date representation, authentication scheme. The value is not that any particular convention is correct — most of these choices are arbitrary. The value is consistency. A consumer integrating with five internal APIs should not have to learn five error formats and three pagination styles. Without guidelines, each team makes reasonable independent choices and the aggregate is incoherent. The cost is paid by every consumer, repeatedly, forever. The well-known public examples — Google's API Improvement Proposals, Microsoft's REST guidelines, Zalando's — are worth reading and largely worth copying rather than deriving your own from scratch. To be effective the guidelines need enforcement. A linter running against every OpenAPI spec in CI, with rules encoding the conventions, is far more reliable than review. Spectral is the common tool. And they need an exception process, because rules that cannot be broken get ignored entirely. Documenting a deliberate deviation is better than pretending it did not happen.
How do you decide whether something should be an API endpoint or an event?
An API endpoint is a synchronous request for something now, with the caller waiting for a result and knowing who it is calling. An event is an asynchronous announcement that something happened, with no expectation of a response and no knowledge of consumers. Use an endpoint when the caller needs an answer to proceed, when the operation must succeed or fail visibly, or when the caller is entitled to know the outcome. Use an event when other systems need to react but the originator should not care whether they do or how long they take. Order placed, payment received, user registered — these have many interested parties and the publisher should not be coupled to them. The coupling difference is the real distinction. Adding a new consumer to an event requires no change to the publisher; adding a new call to a synchronous flow does, and it adds latency and a failure mode to the caller. Most systems need both, and the common design error is doing synchronously what should be an event — a request path that calls six services because each needs to know, making the whole thing as slow and as fragile as its worst dependency.
If you inherited a badly designed API with many consumers, how would you improve it?
Carefully, and not by rewriting it. First, measure. Instrument per-endpoint and per-consumer usage so you know what is actually called and by whom. Most of the surface is usually unused, and that changes what is worth fixing. Second, stop the bleeding. Establish guidelines and enforce them on new endpoints, so the problem stops growing while you address the existing surface. Third, improve additively. Add the well-designed endpoint alongside the bad one rather than changing it. Add missing pagination as an opt-in parameter. Add a consistent error shape to new responses. None of this breaks anyone. Fourth, migrate consumers deliberately — contact them, provide a migration guide, and track adoption with your usage metrics. This is the slow part and it is mostly communication rather than engineering. Fifth, remove only when usage reaches zero, with deprecation headers and a real sunset window. The temptation to design v2 and cut over is usually a mistake: you end up maintaining both indefinitely because the migration never completes. Incremental improvement inside the existing version, driven by real usage data, finishes more often.