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.
Design an API and server-side protocol for resumable uploads supporting 10k concurrent clients, files up to 200MB, and intermittent mobile connectivity. Specify endpoints for initiating an upload, uploading chunks, resuming, validating integrity, and finalizing. Explain what server state you would persist, how idempotency applies at each step, and how you would handle abandoned uploads and abuse.
Sample Answer
Direct answer
Model the upload as a stateful resource, an upload session with its own id, and make idempotency work at two different levels: session creation is protected by a client-supplied Idempotency-Key so a retried "start upload" call cannot spin up two competing sessions for the same file, and each chunk is naturally idempotent because it is addressed by its byte range plus a checksum, so re-sending the same range is a safe no-op rather than a duplicate write. Everything the server needs to persist is small: the session's metadata and the set of byte ranges already received; the file bytes themselves live in object storage, not in the session record.
Structured elaboration
Endpoints:
POST /uploads(initiate). Body:filename,total_size,content_type. Headers:Idempotency-Key(recommended). Response201:{ upload_id, expires_at, chunk_size_hint }.PATCH /uploads/{upload_id}(upload a chunk). Headers:Content-Range: bytes {start}-{end}/{total}, a chunk checksum header. Response200:{ received_ranges: [...], next_offset }. Accepts chunks out of order.GET /uploads/{upload_id}(resume). Returnsreceived_rangesandnext_offsetso a reconnecting mobile client knows exactly what is missing.POST /uploads/{upload_id}/complete(finalize). Body: client-computed whole-file checksum. Server assembles/validates and returns the final file id.DELETE /uploads/{upload_id}(abandon explicitly).
Server state persisted:
- Session row:
upload_id,user_id,filename,total_size,content_type,state(initiated / in_progress / completed / aborted),created_at,last_activity_at,expires_at. - Received-ranges record: a compact list of
{start, end, checksum}entries per chunk actually stored, not the chunk bytes themselves (those go straight to object storage). - Nothing here requires holding an open connection between chunks; a mobile client can drop off the network for minutes and resume against the same
upload_id.
Idempotency at each step:
- Initiate:
Idempotency-Keyplus a fingerprint of(filename, total_size, content_type)maps to oneupload_idfor a bounded window (documented, e.g., 24 hours); replaying the same initiate call returns the same session instead of creating a second one. - Chunk upload: idempotent by construction through
Content-Range. If a range that was already received arrives again with a matching checksum, the server responds200and does nothing further (safe retry after a lost response). If the same range arrives with a different checksum, that is a real conflict (409), not a retry, because the client is now sending different bytes for a range it already committed. - Finalize: idempotent on
upload_id; callingcompletetwice after success returns the same final file id both times rather than re-assembling or erroring, so a client that couldn't tell whether its firstcompletecall landed can safely call it again.
Handling intermittent mobile connectivity:
- Small chunk sizes (server hints a size, e.g., a few hundred kilobytes to a few megabytes) so a dropped connection loses at most one chunk's worth of progress, not the whole upload.
GET /uploads/{upload_id}is the resume contract: the client asks what ranges are already received and only re-sends what's missing, rather than restarting from byte zero.
Abandoned uploads and abuse:
expires_aton the session (returned at initiate time so the client can see its own deadline); a background sweeper deletes sessions and their stored chunks only afterexpires_athas passed, never touching a session with recentlast_activity_at.- Per-user quotas (maximum concurrent sessions, maximum total bytes in flight) enforced at initiate time, returning
429when exceeded, the same documented-quota pattern used for any other rate-limited endpoint. total_sizeis validated against the declared 200MB ceiling at initiate time, and each chunk's checksum is verified on arrival so a corrupted or tampered chunk is rejected before it is ever assembled into a final file.
Worked example
Initiate:
// POST /uploads Idempotency-Key: up-init-9f21
{ "filename": "trip-video.mp4", "total_size": 52428800, "content_type": "video/mp4" }
// 201 Created
{ "upload_id": "up_7c14", "expires_at": "2026-07-20T09:00:00Z", "chunk_size_hint": 262144 }
Upload the first chunk (256KB, matching the hinted chunk size, so the byte range is 0 through 262143 of the 52428800-byte total):
PATCH /uploads/up_7c14
Content-Range: bytes 0-262143/52428800
// 200 OK
{ "received_ranges": [[0, 262143]], "next_offset": 262144 }
Client drops off the network, reconnects later, and asks what it still needs:
GET /uploads/up_7c14
{ "upload_id": "up_7c14", "received_ranges": [[0, 262143]], "next_offset": 262144, "total_size": 52428800 }
It resumes exactly at byte 262144, no re-upload of the first chunk. Finalize once all ranges are in:
// POST /uploads/up_7c14/complete
{ "checksum_algorithm": "sha256", "checksum": "<client-computed full-file digest>" }
// 200 OK
{ "file_id": "file_31a0", "state": "completed" }
Trade-offs and pitfalls
- Server-proxied chunk uploads (as shown) are simpler to validate per-chunk but cost server bandwidth; direct-to-object-store multipart uploads (client uploads parts straight to storage using short-lived credentials) save that bandwidth but push part-tracking and checksum bookkeeping onto a more complex client-storage handshake. For 10k concurrent clients, the bandwidth savings usually win, at the cost of that added complexity.
- Chunk size is a real trade-off: smaller chunks tolerate flaky mobile networks better (less to re-send after a drop) but add per-chunk request overhead at scale; larger chunks are more efficient per byte but riskier on an unreliable connection.
- A pitfall specific to idempotency here: treating "same range, different checksum" as a silent overwrite instead of a
409hides a real client bug (a chunk that got corrupted or a retry that picked up stale data) and can quietly assemble a corrupted final file. - Garbage-collecting by
expires_atalone, without checkinglast_activity_at, risks deleting a session that is still actively (if slowly) being uploaded to from a poor connection right at the boundary of its TTL; the sweeper should treat both signals together, the same discipline as garbage-collecting any other long-lived job resource.
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.
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.
Design an API response schema that minimizes overfetch for a messaging app where clients show lightweight previews on list screens and full threads on detail screens. Describe the endpoints, your field-selection strategy, and how you would avoid transferring heavy attachments unless they are actually requested.
Sample Answer
Split the API into a lightweight list endpoint that returns only preview fields (last message snippet, unread count, small avatar thumbnails) and a separate detail endpoint that returns the full thread, and never embed binary attachment data in either response. Attachments are represented only as metadata (id, type, size, a small thumbnail URL) in both responses; the actual file bytes are fetched through a separate, time-limited URL only when the user opens that specific attachment.
Endpoints
GET /conversations: list view, returns an array of conversation previews.GET /conversations/{id}: thread detail, returns paginated messages plus attachment metadata only.GET /attachments/{attachmentId}/download: returns a short-lived signed URL for the actual file bytes; called only on explicit user action.
Field-selection strategy
Support a sparse-fieldset query parameter (e.g., ?fields=id,title,lastMessage,unreadCount) so a client that only needs to render a badge count is not forced to receive fields it will discard. For nested resources, support scoped includes (e.g., ?include=messages.attachments(fields=id,type,thumbnailUrl)) so the detail endpoint can be shaped per screen instead of always returning the maximal thread payload.
Why sparse fields instead of one endpoint per screen: a fixed "preview" and "full" shape covers the two screens named in the question, but a client roadmap eventually adds a third screen (for example a "shared media" view showing only attachments). Field selection generalizes without adding a new endpoint per screen. The trade-off is that the server must validate and whitelist which fields are selectable, so a buggy or malicious client cannot request an unindexed, expensive field on every request.
Attachment handling
Represent attachments as {id, type, sizeBytes, thumbnailUrl}, never inline binary. Thumbnails are themselves small pre-generated images served from a content delivery network (a CDN: a globally distributed cache of static assets placed close to the requesting client), not computed per request. The signed download URL is scoped to one attachment, expires quickly (minutes, not hours), and is issued only after the server re-checks the requesting user's access to that conversation, since a still-valid but stale URL should never become a durable bypass of authorization.
Worked example
List response for one conversation, everything a client needs to render a row:
{
"conversations": [
{
"id": "c_9f2",
"title": "Design Review",
"lastMessage": { "id": "m_881", "snippet": "Sent the updated mocks", "sentAt": "2026-07-18T14:02:00Z" },
"unreadCount": 3,
"participants": [{ "id": "u_12", "name": "Rae", "avatarThumbUrl": "https://cdn.example.com/av/u_12_32.jpg" }]
}
]
}
Detail response for the same conversation, messages included, attachments as metadata only:
{
"id": "c_9f2",
"messages": [
{
"id": "m_881",
"senderId": "u_12",
"body": "Sent the updated mocks, let me know if the spacing works",
"attachments": [
{ "id": "a_44", "type": "image", "sizeBytes": 812000, "thumbnailUrl": "https://cdn.example.com/att/a_44_thumb.jpg" }
]
}
],
"nextCursor": "m_881"
}
The 812000-byte image is never present in this response, only its thumbnail URL string is. A client that opens the image calls GET /attachments/a_44/download, which returns { "url": "https://signed.example.com/a_44?exp=1752854520&sig=...", "expiresIn": 300 }, a URL valid for 300 seconds.
Trade-offs and pitfalls
Sparse fieldsets add server-side complexity (field whitelisting, and cache-key fragmentation, since ?fields=a,b and ?fields=b,a should normalize to the same cache entry) in exchange for one flexible endpoint instead of many narrow ones. A common wrong turn is returning attachment thumbnails as base64-encoded strings inside the JSON body "to save a round trip": this still bloats every list or detail response for every user, even ones who never open the attachment, and it defeats ordinary HTTP-level caching of the image separately from the JSON. Keep binaries out of JSON always, even small ones. Signed URLs need a short expiry to limit exposure if leaked (through logs or a shared screenshot), but too short an expiry causes broken images on slow connections; pairing a short-lived URL with a client-side retry-on-expired pattern (re-request the URL, then retry the download) resolves this without lengthening the exposure window. GraphQL is a real alternative for the field-selection problem, since the client literally specifies the shape in the query, but it moves cost control (query depth and complexity limits) onto the server and adds cache-key complexity per unique query shape; for two well-known screens like this, REST with sparse fields and a couple of named include recipes is usually the simpler production choice.
Describe how you would design API pagination and sync endpoints for a mobile app that must support partial offline sync. The endpoints should let the client reconcile local mutations with server state and fetch only deltas since the last sync point. Provide the high-level request/response contract and how you would surface conflicts to the caller.
Sample Answer
Direct answer
Split the contract into two endpoints threaded together by one opaque sync token: a push endpoint that accepts the client's local mutations tagged with the server version they were based on (so the server can detect conflicts), and a paginated pull endpoint that returns only the deltas since that token plus a new token to resume from. The client always pushes before it pulls, so its own pending changes are reflected in the server state it is about to reconcile against.
Contract shape
Push (client to server), sends local mutations:
POST /sync/push
{
"clientId": "device-abc",
"baseSyncToken": "tkn-123",
"mutations": [
{"localId": "c1", "type": "update", "resource": "note", "id": "srv-45", "baseVersion": 77, "payload": {"title": "New title"}}
]
}
Push response, tells the client what happened to each mutation:
{
"applied": [
{"localId": "c1", "serverId": "srv-45", "status": "applied", "serverVersion": 78}
],
"conflicts": [],
"newSyncToken": "tkn-124"
}
Pull (server to client), paginated deltas since the token:
GET /sync/pull?since=tkn-124&pageToken=null
{
"items": [
{"serverId": "srv-46", "resource": "note", "op": "upsert", "payload": {"title": "Meeting notes"}, "version": 12}
],
"nextPageToken": null,
"newSyncToken": "tkn-125"
}
Conflict signaling
Every mutation in the push request carries baseVersion, the server version the client last saw for that resource. The server compares it to the resource's current version before applying:
| Strategy | When to use | What the client sees |
|---|---|---|
| Reject and surface the conflict | Data loss risk is high (financial fields, anything a human should review) | {"localId": "c2", "reason": "version_mismatch", "serverState": {...}} in the conflicts array; client shows the current server value alongside the pending local change |
| Last-writer-wins by timestamp | Low-stakes fields where losing a rare concurrent edit is acceptable | The mutation applies silently; client sees status: "applied" even though its base version was stale |
| Field-level merge | Structured objects where two edits touch different fields | Server merges non-overlapping fields and returns the merged serverVersion; only overlapping-field conflicts surface |
Whichever strategy is chosen, baseVersion is what makes the push idempotent and safe to retry after a network drop: replaying the same push twice against an already-applied localId returns the same applied result rather than double-applying it.
Worked example
sequenceDiagram
participant C as Mobile client
participant S as Sync API
C->>S: POST /sync/push {baseSyncToken, mutations}
S-->>C: {applied, conflicts, newSyncToken}
C->>S: GET /sync/pull?since=newSyncToken&pageToken=null
S-->>C: {items, nextPageToken=abc, newSyncToken}
C->>S: GET /sync/pull?since=newSyncToken&pageToken=abc
S-->>C: {items, nextPageToken=null, newSyncToken}
Note over C: pull loop ends when nextPageToken is absent
Tracing the token through the exchange above: the push response's newSyncToken (tkn-124) becomes the since value on the first pull; each pull response echoes back the same newSyncToken (it only advances once a full pull pass completes) until nextPageToken comes back absent, which is the client's signal to stop paging and consider itself caught up as of that token.
Trade-offs and pitfalls
Deletes need a tombstone record (a marker saying "this resource was removed," not just its absence) so a client pulling deltas can distinguish "never existed" from "existed then got deleted." Tombstones cannot be kept forever: they need a retention window and a background cleanup pass that garbage-collects (permanently purges) tombstones older than the window, after which a client that reconnects past that window can no longer compute a delta and must fall back to a full resync instead of a partial pull.
A client that reconnects after a very long offline period is the sharpest edge case: if its baseSyncToken predates the server's retained history, the pull endpoint must detect that explicitly (rather than silently returning an incomplete delta) and tell the client to discard local state and re-fetch a full snapshot.
Monotonic version numbers per resource are enough for the common single-writer-per-record case. True multi-device, multi-writer scenarios (the same resource edited concurrently from two devices before either has synced) need richer causality tracking than a single version number can express; that machinery belongs to your consistency model, not this contract, so treat it as a known limitation to flag rather than something to solve inside the sync endpoints themselves.
That is every published API and Interface Design for Distributed Services question for Mobile Developer so far. Browse the other topics in this category, or practice this one interactively.