API and Interface Design for Distributed Services Questions
Designing the contracts between services and clients: REST, gRPC, and GraphQL tradeoffs, versioning and backward compatibility, pagination, rate limiting, and idempotent endpoints. Covers request/response modeling, error contracts, and API gateway responsibilities. Focuses on the interface layer that ties distributed components together, not internal data schemas.
Compare Server-Sent Events, WebSockets, and long-polling for delivering live updates to a collaborative dashboard with thousands of concurrent users. Recommend an approach that balances connection cost and server load, explain the delivery guarantee (at-least-once vs exactly-once) you can realistically promise callers, and how older clients or unreliable mobile networks should fall back.
Sample Answer
Direct answer
For a collaborative dashboard with thousands of concurrent viewers, use WebSockets as the primary transport if edits genuinely flow both directions (a user's own edit needs to reach the server as fast as other users' edits reach them); use Server-Sent Events (SSE), a one-directional push protocol built on plain HTTP, if the client mostly receives updates and sends edits through ordinary POST requests. Either way, the API can only honestly promise at-least-once delivery, never exactly-once, across an unreliable network; the contract that makes that safe is a per-channel sequence number in every event, so the client can detect duplicates and gaps itself. Clients that cannot hold a persistent connection (old browsers, restrictive corporate proxies, flaky mobile carriers) fall back to long-polling against the same event schema, so the client's rendering code never has to branch on transport.
Structured elaboration
Transport comparison, as an interface decision (not as an internal scaling mechanism):
| Property | Long-polling | SSE | WebSocket |
|---|---|---|---|
| Direction | Client pulls | Server pushes to client only | Full duplex |
| Built on | Plain HTTP requests | Plain HTTP, text/event-stream | Own protocol, upgraded from HTTP |
| Native reconnect/resume | None, client must re-request | Built into the browser's EventSource, resumes via Last-Event-ID | None, application must implement it |
| Proxy/firewall friendliness | Best, looks like normal HTTP | Good, still one HTTP response | Weakest, some corporate proxies and older mobile networks block or silently drop the upgrade |
| Fits as universal fallback | Yes | Sometimes (needs a polyfill on very old clients) | No |
Delivery guarantee as an API contract (this is the part the caller actually needs documented, not how the backend achieves it internally):
- Every event carries a monotonically increasing
seqper channel/dashboard, assigned once by the server. - The contract the API documents is: "events are delivered at least once; the same
seqmay arrive more than once, the client must deduplicate byseq, and a client resuming after a disconnect will replay from its last acknowledgedseq." That is exactly-once application behavior built on top of an honestly at-least-once transport guarantee; the API should never claim exactly-once delivery, because nothing about the network between server and client can make that promise. - Resumption is exposed to the caller through a documented mechanism: SSE's native
Last-Event-IDrequest header, an equivalentsince_seqquery parameter for WebSocket/long-polling clients, so any transport can ask "replay everything after N."
Fallback contract for older or unreliable clients:
- One canonical event envelope (
{ seq, type, data }) is used across all three transports, so switching transport at the client is a connection-layer decision, not a data-shape decision. - The client negotiates transport through a lightweight capability probe (for example, attempt the WebSocket upgrade; on failure or repeated drop, fall back to SSE; on SSE failure, fall back to long-polling against
GET /dashboards/{id}/updates?since_seq=N), all reading the same event stream by sequence number. - This is the concrete home for the fallback angle: an older browser or a mobile network that kills idle sockets degrades gracefully to polling and still gets a correct, ordered, deduplicated view, just at higher latency.
Worked example
SSE event as the server would emit it (mirrors the WebSocket message body and the long-polling response item):
event: dashboard-update
id: 482991
data: {"seq":482991,"type":"cell-update","dashboardId":"dash_77","cell":"B12","value":481200,"editedBy":"u_582"}
Resumption after a dropped connection: the browser's EventSource automatically resends the last id it saw as a request header:
GET /dashboards/dash_77/stream
Last-Event-ID: 482991
The server looks up its durable event log for that channel starting at seq 482992 and replays from there; nothing is skipped and nothing already-seen needs to be discarded by the client beyond ordinary dedup.
WebSocket frame carrying the identical event (same schema, different transport):
{"seq":482991,"type":"cell-update","dashboardId":"dash_77","cell":"B12","value":481200,"editedBy":"u_582"}
Long-polling fallback for a client that cannot hold either connection open:
GET /dashboards/dash_77/updates?since_seq=482991
{
"events": [
{"seq":482992,"type":"cell-update","dashboardId":"dash_77","cell":"B12","value":481500,"editedBy":"u_311"}
],
"poll_after_ms_hint": 2000
}
Same envelope, same seq field, so a client that fell back from WebSocket to long-polling mid-session can keep deduplicating exactly the same way.
Trade-offs and pitfalls
- Claiming exactly-once delivery is the most common mistake here; the honest, documentable contract is at-least-once transport plus client-side dedup by sequence number, which behaves like exactly-once from the caller's point of view without making a promise the network can't keep.
- SSE is simpler to operate and gets reconnection for free in the browser, but it is one-directional; a dashboard where users also edit needs a separate POST path for outbound edits, and that path needs its own idempotency story (a duplicate POST from a retried edit should not double-apply).
- WebSocket's biggest real-world failure mode is not code complexity, it's the network path: some mobile carriers and corporate proxies silently drop idle WebSocket connections without a clean close frame, which looks to the client like a hang rather than a disconnect. The contract needs an application-level heartbeat/ping so the client can detect a dead connection instead of waiting on a TCP timeout.
- A pitfall on the server-facing side: shipping a different event schema per transport (say, camelCase for WebSocket and snake_case for the long-poll fallback) forces client code to branch on transport, which defeats the point of having a documented, transport-agnostic contract in the first place.
You are designing an API for a social feed consumed by both mobile and web clients, and the mobile team keeps complaining the responses are heavy and hard to work with. Walk through how you would shape the API to minimize client complexity, and justify your protocol choice given the mobile bandwidth constraints.
Sample Answer
Direct answer
The mobile team's complaint ("responses are heavy and hard to work with") is really two separate problems: the payload carries fields nobody on that screen needs, and the client has to reshape what it gets before it can render. The fix is to stop shipping one fixed shape to every consumer and let the server compute exactly the fields each client asks for, while keeping the underlying feed data model the same. Given that mobile and web genuinely want different shapes from the same feed and mobile is bandwidth-constrained, GraphQL's field-selection model is the better protocol fit here, as long as it is constrained (persisted, allow-listed queries; depth and cost limits) so no client can accidentally request an expensive, unbounded query.
Structured elaboration
Requirements to design against: less client-side reshaping work, smaller mobile payloads, predictable caching behavior, and one schema that serves both clients without diverging codepaths.
Protocol comparison for this consumer mix:
| Concern | REST (fixed resource shape) | GraphQL (client-selected fields) | gRPC |
|---|---|---|---|
| Matches "mobile wants less, web wants more" | Poor without ad hoc ?fields= params | Native fit, one query per client shape | Poor, schema is fixed per RPC method |
HTTP-level caching (CDN, Cache-Control) | Strong, stable URLs | Weak by default, needs persisted-query IDs to get cacheable URLs back | Not HTTP-cacheable in the browser sense |
| Browser/mobile-web client support | Native | Native (plain HTTP POST/GET) | Needs gRPC-Web plus a proxy translating HTTP/2 trailers, extra moving part for a browser client |
| Risk of one client hurting others | Low, server controls the shape | Real, a deep nested query can be expensive; needs depth/cost limits | Low |
| Payload compactness for text-heavy feed data | Medium (JSON) | Medium (JSON, but only requested fields) | Best (binary), but the win is smaller for a feed that's already mostly short strings and IDs |
Response-shaping principles regardless of protocol:
- Cursor-based pagination (opaque
next_cursortoken) instead of offsets, so the feed stays stable while new posts are inserted. - Embed hot-path fields the card renders unconditionally (author name, avatar URL, like count); reference cold-path data the card doesn't render by default (full bio, high-resolution media, comment thread) via a separate call or a nested field the client only selects when it needs it.
- Serve media as pre-generated size variants (thumbnail/medium/full URLs) rather than one large image URL the client has to resize client-side; this is a bigger bandwidth win for mobile than almost any protocol choice.
Making GraphQL safe for a bandwidth-constrained mobile client:
- Ship the mobile app with a fixed set of persisted queries (each identified by a hash the client sends instead of the query text). The server only executes registered query hashes and rejects anything else with a 400 response; this closes off arbitrary expensive queries and lets a CDN cache responses by persisted-query hash the same way it would cache a REST URL.
- Enforce query depth and complexity limits server-side so a client cannot nest
author.posts.author.posts...into an expensive fan-out. - Keep the REST fallback in mind: a much lower-effort alternative that gets most of the same benefit is REST with a sparse-fieldset query parameter (
?fields=id,text,author.name,author.avatar_url); it is worth naming as the pragmatic choice for a smaller team that isn't ready to run a GraphQL server.
Worked example
Same feed item, shaped two different ways from one underlying schema.
REST, default embedding (what the mobile team is complaining about):
{
"id": "post_9182",
"text": "Shipped the new onboarding flow today",
"created_at": "2026-07-18T14:02:00Z",
"author": {
"id": "u_4471",
"name": "Priya Shah",
"avatar_url": "https://cdn.example.com/avatars/u_4471_full.jpg",
"bio": "Product engineer, ex-Stripe, likes climbing",
"follower_count": 3821,
"following_count": 512
},
"media": [
{ "url": "https://cdn.example.com/media/m_1.jpg", "width": 4032, "height": 3024 }
],
"like_count": 214,
"comment_count": 18,
"geo": { "lat": 37.7749, "lng": -122.4194 }
}
Everything the card actually renders on mobile is five fields; the rest (bio, follower/following counts, full-resolution image, precise geo) is dead weight on that screen.
GraphQL query the mobile client sends for a feed row (persisted, hash-identified in production; shown here as text for clarity):
query MobileFeed($cursor: String) {
feed(cursor: $cursor, limit: 20) {
edges {
node {
id
text
createdAt
likeCount
author { id name avatarThumbUrl }
media { thumbnailUrl }
}
}
pageInfo { endCursor hasNextPage }
}
}
Response for one edge:
{
"id": "post_9182",
"text": "Shipped the new onboarding flow today",
"createdAt": "2026-07-18T14:02:00Z",
"likeCount": 214,
"author": { "id": "u_4471", "name": "Priya Shah", "avatarThumbUrl": "https://cdn.example.com/avatars/u_4471_thumb.jpg" },
"media": [{ "thumbnailUrl": "https://cdn.example.com/media/m_1_thumb.jpg" }]
}
The web client, on the same schema, asks a different query for the same node and legitimately gets bio, counts, and full-resolution media because its screen renders them; no server-side branching by client type, no separate mobile endpoint to maintain.
Trade-offs and pitfalls
- GraphQL's caching story is genuinely worse than REST's out of the box (an arbitrary query has no stable URL); persisted queries recover most of it by giving each allowed shape a stable, cacheable identifier, but that is an extra piece of infrastructure to build and operate, not a free win.
- The classic GraphQL trap is the N+1 resolver problem: naively resolving
authorper feed item issues one database call per post instead of one batched call for all authors in the page. This needs a batching layer (a per-request data loader that collects author IDs across the whole response and issues one query) or the fix does not show up until load testing. - Over-embedding (putting the full author profile in every feed item) reproduces the original complaint under a new protocol; under-embedding forces one extra round trip per screen, which is worse than a slightly larger response on a high-latency mobile network. The embed-vs-reference line should be drawn by "does this screen render it unconditionally," not by what's convenient to fetch.
- A REST sparse-fieldset compromise is a legitimate answer for a team not ready to operate a GraphQL server; naming it as the deliberately-rejected alternative (and why the full field-selection model wins here specifically because mobile and web diverge so much) is what separates a considered answer from a technology-preference answer.
That is every published API and Interface Design for Distributed Services question for Frontend Developer so far. Browse the other topics in this category, or practice this one interactively.