Track 2 / Guide 08
REST API JSON Response Design Patterns
Design stable JSON resources, Problem Details errors, cursor pagination, filtering, and versioning with explicit retry and authorization semantics.
On this page
Make response structure an application contract
A response should tell a client what happened without requiring it to infer meaning from inconsistent field names or human prose. Choose stable identifiers, explicit nullability, and predictable resource shapes. Keep transport status, machine-readable error identity, and user-facing detail separate. A client should not parse a sentence to decide whether it can retry or which field needs correction.
Design from consumer workflows rather than from database tables. Internal columns may contain implementation details or sensitive state that should never become public merely because an ORM can serialize them. Use an explicit response projection and test it. A resource representation can evolve independently from storage when the boundary is deliberate.
Specify number and timestamp conventions early. Large identifiers should not depend on client floating-point precision, and timestamps should include a clear timezone convention. Distinguish omitted fields from fields intentionally set to null. The schema guide shows how to encode shape constraints, but the API documentation must still explain semantic meaning, ownership, and lifecycle transitions.
Use current Problem Details semantics
RFC 9457 supersedes RFC 7807 while retaining the familiar Problem Details model. Use the application/problem+json media type for this representation. Standard members describe the problem type, a summary, status, detail, and an instance reference. Extension members can carry structured validation information when their meaning is documented and does not conflict with the standard fields.
Keep the HTTP status authoritative and consistent with any status member in the body. Avoid returning success status for an operation that failed simply because the response contains valid JSON. Clients, proxies, and monitoring systems depend on transport semantics. A stable problem type should identify a category, while detail should describe this occurrence without exposing stack traces, SQL statements, or secrets.
Validation errors can include bounded field paths and codes. Limit the number of entries and use deterministic ordering. A malformed request containing thousands of invalid elements should not trigger a larger diagnostic response than the original body. Treat problem responses as a public contract with the same version discipline as successful resources. Correcting an error sentence should not break a client that uses the stable problem type and extension codes.
A small runnable HTTP contract
The following server exposes a tiny read-only orders endpoint and a Problem Details response for invalid limits or missing routes. Save it as server.cjs and run it with Node.js, then request the endpoint with cURL. The example is intentionally local and uses synthetic records, so it demonstrates response mechanics without pretending to implement authentication or persistent pagination.
The handler validates the query parameter before constructing the response. It uses explicit content types and a no-store policy for the demonstration. Production caching should be selected by resource sensitivity and freshness requirements rather than copied blindly from a sample. Notice that unknown routes produce an actual not-found status, not a success envelope containing an error field.
In a production service, apply the same error construction through a shared response helper or framework mechanism so individual handlers do not drift. Keep request identifiers separate from internal stack traces. If an instance URI is included, decide whether it is a public support reference or a dereferenceable resource and protect any diagnostic endpoint accordingly. Do not accidentally make private incident details accessible through a guessed identifier.
const http = require("node:http");
const orders = [{ id: "o1", totalMinor: 1250 }, { id: "o2", totalMinor: 900 }];
function send(res, status, body, problem = false) {
res.writeHead(status, {
"Content-Type": problem ? "application/problem+json" : "application/json",
"Cache-Control": "no-store"
});
res.end(JSON.stringify(body));
}
http.createServer((req, res) => {
const url = new URL(req.url, "http://localhost:8080");
if (req.method !== "GET" || url.pathname !== "/orders") {
return send(res, 404, {
type: "about:blank", title: "Not Found", status: 404
}, true);
}
const raw = url.searchParams.get("limit") ?? "20";
if (!/^[0-9]+$/.test(raw) || Number(raw) < 1 || Number(raw) > 100) {
return send(res, 400, {
type: "https://example.com/problems/invalid-limit",
title: "Invalid page limit", status: 400,
detail: "limit must be an integer from 1 to 100"
}, true);
}
send(res, 200, { data: orders.slice(0, Number(raw)),
page: { nextCursor: null } });
}).listen(8080, "127.0.0.1");Cursor pagination needs a stable ordering
A cursor should encode enough state to resume a documented order, usually including a unique tiebreaker. Ordering only by creation time is insufficient when multiple records share a timestamp. A pair such as creation time and identifier can define a deterministic boundary. The database query must use the same ordering and comparison direction as the cursor contract.
Make the cursor opaque to clients and validate its contents on the server. Opaque does not mean secret: Base64 alone only changes representation. If clients must not alter cursor state, authenticate it or store server-side state. Bind it to relevant filters, tenant scope, and ordering so a cursor from one query cannot be reused with different semantics.
Document consistency under concurrent writes. Keyset pagination avoids some offset-shift problems but does not automatically provide a snapshot of an evolving collection. Updates to ordering fields, deletions, and late inserts can still affect traversal. If the workflow requires a fixed export, use an explicit snapshot or export job. Do not promise that every list endpoint provides exact once-only traversal under arbitrary concurrent mutations.
Filtering and projection need bounded expressiveness
Offer a documented set of filters and operators rather than forwarding arbitrary client expressions into a database query. Parse values according to their expected types, bind them as parameters, and apply tenant restrictions independently. A filter that looks like ordinary JSON can still become an injection vector if interpreted as an unrestricted query language.
Bound page size, filter complexity, and sorting options. Exposing every database column as a sort key can create expensive plans and leak internal fields. Evaluate indexes for the supported query combinations and reject unsupported combinations clearly. A flexible endpoint that occasionally scans an entire collection is an operational liability even when its JSON contract is elegant.
Projection can reduce over-fetching, but it also creates authorization and caching considerations. Ensure restricted fields remain unavailable regardless of requested projection. Include relevant query parameters in cache keys and avoid sharing personalized responses across users. Keep field names stable and test combinations that omit normally present values so client code does not accidentally depend on undocumented defaults.
Version semantics, not cosmetic formatting
A breaking change alters what existing clients can send, receive, or rely on. Renaming a field, changing its type, narrowing accepted values, or changing null semantics can break consumers even when the response remains valid JSON. Whitespace changes should not matter to ordinary parsers, but exact-byte signatures require a separate contract.
Choose a versioning strategy that fits deployment and client coordination. Path versions are visible and straightforward, while media-type or header negotiation can keep resource paths stable but requires more careful tooling and caching. The important property is explicit selection and a documented support lifecycle, not a universal preference for one syntax.
Use compatibility tests between old clients and new responses. A newly added field may break strict readers, while removing an undocumented field may reveal that consumers relied on it anyway. Track deprecation adoption before removal and publish migration examples. Keep error formats consistent across versions when possible so clients can handle failures even while a resource contract changes.
Retries, authorization, and conditional updates
Authentication establishes a principal, but every resource operation still needs authorization for that principal and object. Do not trust a tenant identifier in the request body merely because the token is valid. Derive or verify scope against server-side policy. The JWT guide explains why verified claims are only the start of authorization.
For retried writes, define whether an idempotency key identifies the operation and how long it remains valid. Store the outcome atomically with the operation when required, and reject reuse with a different request meaning. A timeout does not prove the server did nothing, so clients need a way to recover without duplicating a purchase or job.
Use conditional updates when clients modify previously read state. An entity tag with an appropriate precondition can prevent lost updates. The Patch guide explains why a partial document alone does not solve concurrency. Return a clear conflict or precondition response so the client can refresh and decide whether to retry rather than blindly overwriting another actor's change.
Test the wire contract and its operating limits
Exercise success, invalid input, missing resources, authorization failure, and unexpected server errors. Assert status codes and content types as well as JSON bodies. Verify that error responses do not contain stack traces or sensitive fields. Use a fixture for every documented nullable or optional property and make pagination tests include equal sort values.
Run the cURL requests below against the local example to inspect both the normal response and the problem response. For a real deployment, add tests through the gateway because proxies can rewrite errors or remove headers. An application unit test cannot detect a gateway returning an HTML error page where clients expect Problem Details.
Measure response size and handler latency under the maximum allowed page and filter complexity. Keep metrics bounded by route and error category rather than arbitrary identifiers. Document the retry behavior of each failure class and test it with a client timeout. A production response contract includes failure and load behavior, not just a visually consistent happy-path envelope.
curl -i 'http://127.0.0.1:8080/orders?limit=1'
curl -i 'http://127.0.0.1:8080/orders?limit=0'
curl -i 'http://127.0.0.1:8080/missing'Specify cache validators and pagination examples
A resource validator should change when the representation relevant to the client changes. Decide whether the entity tag identifies a specific representation or a broader resource version, and align conditional requests with that choice. Do not generate a validator from a volatile response field such as request time if the goal is useful cache reuse.
When a response varies by query parameters, language, or authorization context, ensure caches distinguish those variants. A server-side cache keyed only by path can return the wrong projection or tenant data. Explicitly test two users and two filter combinations against the same resource path to catch accidental sharing.
Publish pagination examples that include the first page, a middle page, and the exhausted result. Define whether an empty data array can still have a continuation cursor and what clients should do with it. Avoid requiring clients to infer completion from page length alone when the server can provide an explicit continuation signal.
Document cursor invalidation. If a schema or ordering change makes old cursors unusable, return a stable error and let clients restart rather than producing an unrelated server failure. A cursor should not become an indefinite public commitment to an internal database encoding; version its contents or use a server-side reference when necessary.
Design supportable error and retry contracts
For every write operation, state whether a timeout leaves the outcome unknown and how the client can retrieve the result. An idempotency key can help only when its retention and request-matching rules are clear. Specify which parts of the request define equivalence so two different orders cannot accidentally reuse one outcome.
Separate transient infrastructure failures from permanent request errors. Clients should not retry invalid field values automatically, and servers should not encourage immediate retries during overload. Where retry timing is provided, make it consistent with the service's admission policy. Test a client implementation rather than assuming all consumers interpret the same status identically.
Use support identifiers that do not expose internal topology or personal information. A stable opaque request reference can connect a problem response with protected operational records. Keep those records access-controlled and bounded. Do not publish stack traces as a substitute for a useful problem type.
Review error responses for enumeration risks. Different details for an inaccessible versus nonexistent protected resource can reveal information even when both requests fail. The appropriate policy depends on the product, but it should be deliberate and consistent across list, detail, and update routes.
Finally, include contract tests through the real routing layer for all supported methods. A framework default for an unsupported method may return HTML, while application failures return JSON. Decide whether that distinction is acceptable to clients and configure it accordingly. Consistent operational behavior includes the routes and failures that application handlers never reach.
Engineering Comparison
| Choice | Benefit | Cost or risk | Required discipline |
|---|---|---|---|
| Problem Details | Stable error structure | Sensitive diagnostic leakage | Bounded public extensions |
| Cursor pagination | Efficient ordered traversal | Mutable collection semantics | Unique ordering and scoped cursor |
| Offset pagination | Simple page arithmetic | Deep offsets and shifting rows | Bounded use cases |
| Projection | Smaller representations | Cache and field authorization | Explicit allowlist |
| Conditional update | Avoid lost writes | Client conflict handling | Stable entity version |
| Idempotency key | Safe operation recovery | State retention | Atomic outcome storage |
Use RFC 9457 for new Problem Details implementations; it supersedes RFC 7807. Pagination and retry guarantees must be documented independently of the JSON envelope.