Backend-for-Frontend and Client-Specific API Design Questions
Shaping API responses to the needs of specific client types: the Backend-for-Frontend (BFF) pattern, one BFF per client versus a shared gateway, client-side composition or a generic API, what belongs in a BFF (including normalizing inconsistent downstream pagination and error formats for one client), and aggregating several backend calls into one client-shaped response. Covers payload shaping (sparse fieldsets and field selection against relational and document stores, partial responses, optional expansion of related collections, delta responses with deletions, compact serialization including binary wire formats), round-trip reduction and composite views for feeds and dashboards, adapting to mobile versus web bandwidth, battery and device constraints (device-appropriate image variants, prefetching and refresh frequency tuned to connection quality), and choosing GraphQL versus REST to fix over- and under-fetching, including incremental migration from REST to GraphQL. Authentication, general REST resource design, versioning, rate limiting, offline sync and real-time transport are covered elsewhere.
Your large REST-based product wants to adopt GraphQL without a rewrite. Propose a migration path that keeps existing clients working while screens move over one at a time, and say how you would decide when the old endpoints can be retired.
Sample Answer
Direct answer
Put a GraphQL layer in front of the existing REST services as a facade (a thin new front door that translates the new query language into calls to the old system) and migrate the product screen by screen. Existing clients keep calling REST untouched, the facade reuses the same services and business rules, and each screen switches behind a feature flag (a configuration switch, read at run time, that turns the new code path on or off for chosen users without a deploy) once its GraphQL version is proven equivalent. Retire a REST endpoint only when measured traffic to it has been zero for longer than your slowest client can take to upgrade.
Phase 1: facade over the existing services
- Stand up one GraphQL server (the facade) whose resolvers (the functions that fetch each field) call the existing REST endpoints or the service layer behind them. No data model rewrite and no REST change, so there is nothing for old clients to notice.
- Model the schema around what screens need, not a 1:1 mirror of the REST resources. This is where the over-fetching (receiving fields the screen ignores) and under-fetching (needing extra calls for missing fields) problems are actually fixed.
- Pass the caller's credentials through and keep authorisation in the services, so the facade cannot become a bypass.
- Guard against the classic resolver cost: a list of 50 orders whose resolver calls the customer service once per order issues 50 calls (the "N+1" problem). Batch lookups per request with a batching helper such as DataLoader, and set query depth and complexity limits, or allow only pre-registered ("persisted") queries from your own apps.
Phase 2: move screens one at a time
- Pick a low-risk, high-pain first screen (one with many REST calls, so the win is visible).
- Build it against GraphQL behind a feature flag, and run a parity check in staging: load the same data through the REST path and the GraphQL path and diff what the screen renders. For example, for order 1042 the REST path renders the total
$84.50and the GraphQL path renders$84.5; the diff flags that one field, and the fix is a formatting rule or a missing field in the schema, not a change to the check. - Roll out by canary (named after the canary in the coal mine: a small slice of users gets the change first, so a problem shows up on few people): for example 1 percent, 10 percent, 50 percent, 100 percent, holding at each step long enough to see error and latency data. Rollback is the flag, not a deploy.
- Compare the same signals for both paths: error rate, 95th percentile latency (the time that 95 percent of requests beat; only the slowest 1 in 20 take longer), payload bytes, and backend calls per page. Stop the rollout if any regresses.
- Repeat for the next screen. New screens are GraphQL from the start, which stops the legacy surface from growing.
Mark REST-shaped parts of the schema that you want people to leave with GraphQL's built-in @deprecated(reason: "...") directive, which the GraphQL documentation describes as the way to annotate deprecated fields; tooling then warns developers.
Gateway, stitching, or one facade
- Single facade owned by one team is the recommendation to start: one schema, one deploy, simple debugging.
- Schema stitching or federation (two ways of combining several teams' separate GraphQL schemas into one graph that clients query as a single API; a gateway routes each field to the team that owns it) pays off when multiple teams own different parts and cannot share one repository. It adds a gateway layer to operate, so adopt it when team boundaries demand it, not on day one.
- A BFF per client type (a backend-for-frontend, a server tailored to one client such as web or mobile) is an alternative when clients differ sharply; GraphQL lets one server serve both because each client chooses its fields.
Keeping existing clients working
REST contracts are frozen: no breaking changes while GraphQL adoption proceeds. Old mobile app versions stay in the wild for months, so the legacy API must outlive the web migration. Add a client-version tag to requests now (a header such as an app version) if one does not exist, since the retirement decision depends on it.
Deciding when to retire an endpoint
Retire per endpoint, never as one big switch. Evidence to require:
- Traffic: request count per endpoint, broken down by client and app version, shows zero from supported versions for a full window. Choose the window to cover your slowest release cadence plus your forced-upgrade policy (for example the longest period a mobile version stays supported); that number is a business decision, not a technical constant. Illustrative policy: each app version stays supported for 12 months after the release that replaced it, so an endpoint last used by version 7 can be retired only once version 7's support has ended (12 months after version 8 shipped), plus a 30-day margin, with zero supported-version traffic in the final 30 days.
- Remaining callers identified: any non-zero traffic maps to a named owner (internal job, partner, old app version), each with a migration path or an agreed end date.
- Replacement proven: the GraphQL path has run at full traffic through at least one complete business cycle (month end, sale peaks) with no regression.
- Staged shutdown: announce, then log warnings and email owners of the remaining callers, then run short scheduled outages of the endpoint to flush out hidden dependants, then remove. The scheduled outages convert silent dependencies into visible, reversible failures.
Communicate throughout: a published timeline, a migration guide per endpoint, a single dashboard showing traffic left, and a named contact for partners. Incentives matter for internal teams: new features ship only on GraphQL, and their performance wins are reported back to them.
Worked example
A mobile "order details" screen currently makes 4 REST calls: /orders/{id}, /customers/{id}, /orders/{id}/items, /shipments?order={id}. The facade exposes one order(id) query asking for 9 of the roughly 40 fields across those responses. In staging, a parity test fetches the screen's data both ways for 200 sampled order ids and fails if any rendered field differs. The screen then ships at 1 percent of users behind a flag; the team watches error rate and latency against the REST cohort for a week before raising it. Illustrative figures, assuming a 150 ms round trip from the phone, 30 ms of server time per call, a 5 ms internal link from the facade to the services, and about 300 bytes per field: the REST screen needs two sequential waves (the order first, then customer, items and shipments in parallel), about 2 x (150 + 30) = 360 ms and 40 x 300 = 12,000 bytes; the GraphQL screen needs one round trip plus the facade's own two waves, about 150 + 2 x (5 + 30) = 220 ms and 9 x 300 = 2,700 bytes. The four REST routes stay alive for the previous app versions; their per-version traffic chart is what later justifies removal.
Trade-offs and pitfalls
- Facade becomes the bottleneck: every GraphQL request now depends on it, so give it its own scaling, timeouts and per-resolver circuit breaking (when one downstream service keeps failing, the resolver stops calling it for a short time and returns an error or a fallback at once, instead of letting every request wait on a service that is down).
- HTTP caching is harder: GraphQL queries are typically
POST, so URL-based caches and CDNs (content delivery networks) do less; plan persisted queries or response-level caching. - Two systems to run during migration: budget for the overlap period and set the end date up front, or the legacy layer lives forever.
- Mirroring REST 1:1 in the schema keeps the old problems with a new syntax.
- What flips the recommendation: if the problem is only one or two chatty screens, a purpose-built BFF endpoint is cheaper than adopting GraphQL across the product.
A mobile detail screen always needs an object but only sometimes needs its large related collection, such as comments. How would you design a single endpoint so the client can get the object with or without the collection? Show a sample request and response and explain the trade-offs.
Sample Answer
Direct answer
Add an opt-in include query parameter to the detail endpoint. GET /v1/articles/7 returns the object plus a cheap commentCount. GET /v1/articles/7?include=comments&comments_limit=20 returns the same object plus a bounded first page of comments under an included key, with a link to fetch more. The collection is never embedded by default, never unbounded, and an unsupported include value is rejected with 400, not ignored.
Sample requests and responses
Run in a Node 22 container with no dependencies (docker run --rm -v "$PWD":/w -w /w node:22 node include.mjs), include.mjs:
// Run: node include.mjs (Node 22, no dependencies)
import http from 'node:http';
const article = { id: 7, title: 'Article 7', body: 'Lorem ipsum '.repeat(150) };
const comments = Array.from({ length: 12 }, (_, k) => ({ id: 700 + k, author: ['Ana', 'Ben', 'Chen'][k % 3], text: 'Nice post '.repeat(5).trim() }));
const SUPPORTED = new Set(['comments']);
const server = http.createServer((req, res) => {
const u = new URL(req.url, 'http://x');
const send = (s, o) => { res.writeHead(s, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
if (u.pathname !== '/v1/articles/7') return send(404, {});
const include = (u.searchParams.get('include') || '').split(',').filter(Boolean);
const unknown = include.filter(i => !SUPPORTED.has(i));
if (unknown.length) return send(400, { error: 'unsupported_include', values: unknown }); // reject, never ignore silently
const out = { data: { ...article, commentCount: comments.length } }; // count is always present and cheap
if (include.includes('comments')) {
const raw = u.searchParams.get('comments_limit');
const asked = raw === null ? 20 : Number(raw);
if (!Number.isInteger(asked) || asked < 1) return send(400, { error: 'invalid_comments_limit' }); // 0, -1, 'abc', 2.5 are rejected
const limit = Math.min(asked, 50); // server-side cap on the collection
const items = comments.slice(0, limit);
out.included = { comments: { items, next: items.length < comments.length ? `/v1/articles/7/comments?after=${items.at(-1).id}` : null } };
}
send(200, out);
});
await new Promise(r => server.listen(0, r));
const base = `http://127.0.0.1:${server.address().port}`;
for (const q of ['', '?include=comments&comments_limit=2', '?include=likes', '?include=comments&comments_limit=-1']) {
const r = await fetch(base + '/v1/articles/7' + q); const t = await r.text();
console.log(`GET /v1/articles/7${q}\n -> ${r.status}, ${Buffer.byteLength(t)} bytes`);
console.log(' ' + t.replace(/"body":"[^"]*"/, '"body":"..."'));
}
server.close();
Output (the long body is elided by the script's print step):
GET /v1/articles/7
-> 200, 1865 bytes
{"data":{"id":7,"title":"Article 7","body":"...","commentCount":12}}
GET /v1/articles/7?include=comments&comments_limit=2
-> 200, 2114 bytes
{"data":{"id":7,"title":"Article 7","body":"...","commentCount":12},"included":{"comments":{"items":[{"id":700,"author":"Ana","text":"Nice post Nice post Nice post Nice post Nice post"},{"id":701,"author":"Ben","text":"Nice post Nice post Nice post Nice post Nice post"}],"next":"/v1/articles/7/comments?after=701"}}}
GET /v1/articles/7?include=likes
-> 400, 50 bytes
{"error":"unsupported_include","values":["likes"]}
GET /v1/articles/7?include=comments&comments_limit=-1
-> 400, 34 bytes
{"error":"invalid_comments_limit"}
With the 12-comment data set the cap of 50 cannot be seen. Changing length: 12 to length: 60000 and requesting comments_limit=500 returns a page of 50 comments, while comments_limit=-1, 0 and abc all return the same 400. Without the integer check, -1 would reach slice(0, -1) and return every comment but the last.
Design choices
- Opt-in, default off. The screen that does not need comments pays nothing.
commentCountis always returned, so the screen can show "12 comments" and a button without loading any. - Bounded by the server.
comments_limitmust be a whole number of at least 1 (anything else is a400) and is capped on the server (50 here) whatever the client asks. An "include everything" request on an article with 50,000 comments would otherwise become the new over-fetch.nextis a link to the rest of the list as its own endpoint (a sub-resource). Itsafter=701part is a cursor: a bookmark that says "continue after the item with id 701", which is the last comment already delivered. The app requests that URL when the user scrolls, and the server answers with the next comments after 701, so nothing is repeated or skipped even if new comments arrive meanwhile. - A separate
includedsection, not nesting insidedata. The primary object keeps one stable shape with or without the include. A compound document is one response that carries the main resource plus related resources in a separate section; for example the article indataand its comments inincluded, rather than comments mixed into the article's own fields. This mirrors the compound-document idea in JSON:API, a public REST convention whose specification also says a server that does not supportincludemust answer400 Bad Request, and that it must not return resources nobody requested. - Strict validation.
include=likesreturns400with the offending value. Silently ignoring it would let a typo ship and the screen would just show no likes.
Trade-offs
| Option | Round trips | Bytes when comments are not needed | Cache behaviour |
|---|---|---|---|
| Always embed comments | 1 | Large (always paid) | One URL per article |
| Two endpoints (article, then comments) | 2 when needed, 1 otherwise | Minimal | Each URL cached on its own; comments can have a shorter lifetime than the article |
Opt-in include (this design) | 1 either way | Minimal | One cache entry per include combination |
In the run above, the plain request is 1,865 bytes and the request with two embedded comments is 2,114 bytes. The 249-byte difference is the two comments plus their wrapper, and it grows with the limit.
- Cache-key growth. Each distinct
includeandcomments_limitvalue is a separate URL for a CDN (content delivery network) or HTTP cache. Offer a few fixed combinations (limit 20 only) so the hit rate stays high. - Different change rates. If the article changes weekly and comments change by the minute, embedding ties their freshness together: the cached composite is only as fresh as its most volatile part. When comments are very volatile, prefer the two-endpoint option.
- Latency. With
include, the server waits for the slowest of the two data sources before sending anything. If comments come from a slow service, make the client fetch them separately, so the article paints first. - GraphQL alternative. A query can ask for
article { title comments(first: 20) { text } }or leavecommentsout, which solves the same problem without a parameter convention, with the caching trade-offs of aPOSTendpoint.
Pitfalls
- Letting
includeaccept arbitrary nested paths (comments.author.friends), which turns a detail endpoint into a graph traversal. Support a short fixed list. - Returning
included.comments: []when the user is not authorized to see comments. Check authorization for included data the same way as for the primary resource. For example, if comments on a members-only article are visible only to subscribers, a non-subscriber requesting?include=commentsshould get403, or noincludedsection with a clear reason, not an empty list that looks like "no comments yet". - Forgetting the count: without
commentCountthe client has to load the collection to know whether one exists.
Design an endpoint that lets a client receive only the changes since its last sync. Specify the request parameters, the ordering and idempotency guarantees you give, and the response format for creates, updates and deletes. What happens when the client's marker is missing or too old?
Sample Answer
Direct answer
Expose a read-only GET /changes endpoint driven by an opaque cursor (a bookmark the server hands back and the client returns unchanged). The server keeps an append-only change log with a monotonically increasing sequence number, returns the latest change per entity after the cursor in sequence order, and encodes deletes as tombstones (small records that say "this id is gone"). Delivery is at-least-once (a change may arrive more than once but never zero times; the alternative, exactly-once, is very hard to promise over a mobile network that can drop a reply after the server has already sent it), and the client makes that safe by being an idempotent consumer (a receiver that ends in the same state however many times it is handed the same item) that compares versions, so replays are harmless. No cursor means "send a snapshot", and a cursor older than the retained log gets 410 Gone with a machine-readable code that tells the client to throw away its marker and resync.
Request parameters
| Parameter | Meaning |
|---|---|
cursor | Opaque token from the previous response. Absent means first sync. The client never parses or builds it. |
limit | Page size, a positive integer: default 100, capped at 500 by the server so one request cannot be made arbitrarily large. Zero, negative or non-numeric values fall back to the default (parse it as an integer and check the range, because a limit of 0 makes a page that can never advance and a non-number silently becomes NaN). |
page | Snapshot paging token, only present while walking a snapshot. A token that does not decode to the expected shape gets 400 with invalid_page. |
types (optional) | Comma list of entity types, so a screen that only needs orders does not pull invoices. |
Response format
One envelope for both modes:
{
"mode": "delta",
"items": [
{ "op": "upsert", "id": "c", "version": 2, "data": { "title": "C2" } },
{ "op": "delete", "id": "b", "version": 2 }
],
"has_more": false,
"next_cursor": "eyJzIjoxMH0"
}
- Create and update are the same operation,
upsert: the item carries the full current representation plus a per-entityversion. The client does not need to know whether it has seen the id before, which removes a whole class of "update for a row I never got" bugs. - Delete is
{ "op": "delete", "id", "version" }with no body. has_moresays whether to call again immediately.next_cursoris always present; the client stores it only after the page has been applied to local storage.
Ordering and idempotency guarantees
-
Order: items inside a response, and across pages, are in ascending change-sequence order.
-
Compaction (keeping only the newest log entry per entity and dropping the older ones): if one entity changed three times since the cursor, only its latest state is returned. The client still converges to the right state and the payload shrinks. The cost is that intermediate states and cross-entity transaction boundaries are not observable, and the contract should say so.
-
Idempotency:
GETis safe to repeat. Replaying the same cursor returns the same changes or a superset that includes newer ones. (The cursor in the example above,eyJzIjoxMH0, is just the JSON{"s":10}encoded as base64url, a URL-safe text encoding; it means "sequence 10", and the client never needs to decode it.) The client applies an item only whenitem.versionis greater than the version it holds, so applying a page twice, or two overlapping pages, gives the same end state. Delivery is therefore at-least-once with an idempotent consumer, which is simpler and more robust than trying to promise exactly-once over a flaky mobile network. -
Cursor advance: the client replaces its stored cursor only after the page is durably applied. A crash between receiving and applying just replays the page.
-
Commit order caveat: if sequence numbers are assigned when a row is written but become visible when its transaction commits (a transaction is a group of writes that become visible together, at commit), a slow transaction can commit after a later one. A client that already moved past that sequence would skip the row. Assign the sequence at commit time, or only serve up to a safe watermark (a sequence number below which every change is known to be committed, so nothing can still appear under it) set just below the oldest in-flight transaction.
Worked timeline with real values: (t1) transaction A writes a row and is assigned sequence 41, but is still open. (t2) Transaction B writes a row, is assigned 42 and commits. (t3) The client syncs. Without a watermark the server sees only 42 as committed, returns it, and the client stores cursor 42. (t4) Transaction A commits; its row carries sequence 41, below the client's cursor, so no later call ever returns it and the row is lost to that client. With a watermark, at t3 the oldest open transaction is A (41), so the server serves only up to 40, returns nothing, and the client keeps cursor 40. After A commits, the next call returns 41 and 42 in order.
Missing marker and too-old marker
- Marker missing: serve a snapshot, a paged list of live rows only (no tombstones needed). The server pins the head sequence at the moment the snapshot starts and carries it inside the
pagetoken; the final page returnsnext_cursorfor that pinned head. Changes made while the client walks the snapshot are picked up by the first delta call. An entity may then arrive twice, which the version check absorbs. - Marker too old: the log cannot be kept forever, and purged tombstones mean a long-offline client can no longer be told what was deleted. Record the highest purged sequence; a cursor below it is rejected with
410 Goneand{ "error": "cursor_expired", "resync": "/changes" }. MDN describes 410 as a permanent "no longer available" condition, which fits: retrying that cursor will never work. SendCache-Control: no-store(a header telling caches not to keep the response) so no cache replays it. The client discards local synced data (or marks it stale) and runs the snapshot flow. - Marker malformed or forged:
400withinvalid_cursor. Keeping 400 and 410 distinct lets the client tell "my bookmark is bad" from "my bookmark is too old". - Choosing retention: keep tombstones longer than the longest gap you expect a real client to be offline (30 days is a common starting point, an assumption to tune against your own app-open data). Longer retention costs log storage; shorter retention means more full resyncs on the slowest, most expensive path.
Worked example
The program below implements the server and a client, then drives them through a first sync, a delta with compaction and a delete, a replay, an expired cursor and a forged cursor.
import http from 'node:http';
// ---- server: change log with compaction and tombstone retention ----
const log = []; // { seq, id, op, version, data?, at }
let seq = 0;
let purgedThrough = 0; // highest seq whose entries were discarded
const entities = new Map();
function write(id, data) {
const version = (entities.get(id)?.version ?? 0) + 1;
entities.set(id, { id, data, version, deleted: false });
log.push({ seq: ++seq, id, op: 'upsert', version, data });
}
function remove(id) {
const version = entities.get(id).version + 1;
entities.set(id, { id, version, deleted: true });
log.push({ seq: ++seq, id, op: 'delete', version });
}
function purgeUpTo(s) { // retention job: drop log entries up to seq s
for (let i = log.length - 1; i >= 0; i--) if (log[i].seq <= s) log.splice(i, 1);
purgedThrough = Math.max(purgedThrough, s);
}
const enc = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
const dec = (s) => { try { return JSON.parse(Buffer.from(s, 'base64url')); } catch { return null; } };
function changes(url) {
// limit: a positive integer, default 100, capped at 500; zero, negative or non-numeric values fall back to the default
const asked = Number.parseInt(url.searchParams.get('limit') ?? '', 10);
const limit = Number.isInteger(asked) && asked >= 1 ? Math.min(asked, 500) : 100;
const raw = url.searchParams.get('cursor');
let after, snapshotMode = false;
if (raw === null) { after = null; snapshotMode = true; }
else {
const c = dec(raw);
if (!c || typeof c.s !== 'number') return [400, { error: 'invalid_cursor' }];
if (c.s < purgedThrough) return [410, { error: 'cursor_expired', resync: '/changes' }];
after = c.s;
}
if (snapshotMode) {
// Snapshot: live rows only, paged by id; the cursor to resume deltas is pinned at the head seq
// read when the snapshot began (carried inside the page token) so nothing between pages is missed.
const pageRaw = url.searchParams.get('page');
const t = pageRaw ? dec(pageRaw) : { head: seq, last: '' };
if (!t || typeof t.head !== 'number' || typeof t.last !== 'string') return [400, { error: 'invalid_page' }];
const rows = [...entities.values()].filter((e) => !e.deleted && e.id > t.last).sort((a, b) => (a.id < b.id ? -1 : 1)).slice(0, limit + 1);
const more = rows.length > limit; const page = rows.slice(0, limit);
const items = page.map((e) => ({ op: 'upsert', id: e.id, version: e.version, data: e.data }));
return [200, { mode: 'snapshot', items, has_more: more,
page: more ? enc({ head: t.head, last: page.at(-1).id }) : null,
next_cursor: more ? null : enc({ s: t.head }) }];
}
// Delta: newest entry per entity only (compaction), ordered by seq ascending.
const latest = new Map();
for (const e of log) if (e.seq > after) latest.set(e.id, e);
const all = [...latest.values()].sort((a, b) => a.seq - b.seq);
const page = all.slice(0, limit);
const more = all.length > limit;
const cursorSeq = more ? page.at(-1).seq : seq; // when drained, jump to the head
return [200, { mode: 'delta', items: page.map(({ seq: s, ...r }) => r), has_more: more, next_cursor: enc({ s: cursorSeq }) }];
}
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://x');
const [code, body] = changes(url);
res.writeHead(code, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
}).listen(0);
const base = `http://127.0.0.1:${server.address().port}/changes`;
// ---- client: applies items idempotently, advances cursor only after applying ----
const local = new Map();
function apply(items) {
for (const it of items) {
const cur = local.get(it.id);
if (cur && cur.version >= it.version) continue; // replay or stale: ignore
if (it.op === 'delete') local.set(it.id, { version: it.version, deleted: true });
else local.set(it.id, { version: it.version, data: it.data });
}
}
const view = () => [...local].filter(([, v]) => !v.deleted).map(([k, v]) => `${k}=${v.data}`).sort().join(' ');
async function get(q) { const r = await fetch(`${base}?${q}`); return [r.status, await r.json()]; }
async function syncFromScratch() {
local.clear();
let q = 'limit=2', body;
do {
[, body] = await get(q); apply(body.items);
q = 'limit=2&page=' + body.page;
} while (body.has_more);
return body.next_cursor;
}
async function syncDelta(cursor) {
let c = cursor;
for (;;) {
const [status, body] = await get(`limit=2&cursor=${c}`);
if (status === 410) return { resync: true };
apply(body.items); c = body.next_cursor;
if (!body.has_more) return { cursor: c, last: body };
}
}
for (const id of ['a', 'b', 'c', 'd', 'e']) write(id, id.toUpperCase() + '1');
let cursor = await syncFromScratch();
console.log('1 snapshot :', view());
write('a', 'A2'); write('a', 'A3'); remove('b'); write('f', 'F1'); write('c', 'C2');
const r1 = await syncDelta(cursor);
console.log('2 delta :', view());
console.log(' last page items:', JSON.stringify(r1.last.items));
// Replay the same cursor: same result, nothing changes locally
const r2 = await syncDelta(cursor);
console.log('3 replay :', view(), '(unchanged)');
// A stale copy arriving late (version 1 of 'a' after the client holds version 3) must be ignored
apply([{ op: 'upsert', id: 'a', version: 1, data: 'A1' }]);
console.log('3b stale item:', view(), '(unchanged)');
// Retention purges everything up to seq 8 while this client was offline at its old cursor
remove('d'); write('g', 'G1');
purgeUpTo(8);
const r3 = await syncDelta(cursor);
console.log('4 old cursor:', JSON.stringify(r3));
const r4 = await syncDelta(r1.cursor);
console.log('5 fresh cursor still valid:', r4.resync ? 'resync' : 'ok', view());
console.log('6 garbage cursor status:', (await get('cursor=%%%'))[0]);
cursor = await syncFromScratch();
console.log('7 after resync:', view());
for (const q of ['limit=0', 'limit=-1', 'limit=abc', 'page=%%%']) console.log('8 bad input', q.padEnd(9), '->', (await get(q))[0]);
server.close();
It prints (the demo client passes limit=2 explicitly so pages stay small):
1 snapshot : a=A1 b=B1 c=C1 d=D1 e=E1
2 delta : a=A3 c=C2 d=D1 e=E1 f=F1
last page items: [{"id":"f","op":"upsert","version":1,"data":"F1"},{"id":"c","op":"upsert","version":2,"data":"C2"}]
3 replay : a=A3 c=C2 d=D1 e=E1 f=F1 (unchanged)
3b stale item: a=A3 c=C2 d=D1 e=E1 f=F1 (unchanged)
4 old cursor: {"resync":true}
5 fresh cursor still valid: ok a=A3 c=C2 e=E1 f=F1 g=G1
6 garbage cursor status: 400
7 after resync: a=A3 c=C2 e=E1 f=F1 g=G1
8 bad input limit=0 -> 200
8 bad input limit=-1 -> 200
8 bad input limit=abc -> 200
8 bad input page=%%% -> 400
Reading it: a was written three times but arrives once as A3 (compaction), b was deleted so it disappears from the view, replaying the same cursor changes nothing (idempotent), a stale copy of a (version 1, after the client holds version 3) is ignored by the version check (remove that check and line 3b prints a=A1), the cursor at sequence 5 is below the purge point and gets the resync signal, and the cursor at sequence 10 is above it, so it still works and also delivers the later delete of d, the forged cursor is a 400, and a limit of 0, -1 or abc falls back to the default page size instead of crashing the handler (with the naive Math.min(Number(limit), 100) a limit=0 throws inside the server on the first snapshot request), while a forged page token is a 400.
Reading the listing: write and remove append to log with the next seq; purgeUpTo is the retention job that discards old entries and records purgedThrough. changes decodes the cursor (bad cursor gives 400, a sequence below purgedThrough gives 410), serves a snapshot when there is no cursor, and otherwise builds a delta by keeping the newest entry per id (the latest map is the compaction) in seq order. The client's apply skips any item whose version is not newer than the one it holds, and syncDelta only moves its cursor to next_cursor after applying the page.
Tracing the output with the real sequence numbers: the five initial writes are seq 1 to 5, so the snapshot ends with cursor {"s":5}. Step 2 adds a (6), a (7), delete b (8), f (9), c (10). After compaction the entries after 5 are a@7, b@8, f@9 and c@10; with limit=2 page one is a and the delete of b (cursor {"s":8}, has_more true) and page two is f and c (cursor {"s":10}), which is why the last page printed holds f and c. Step 4 adds delete d (11) and g (12) and purges through 8: the old cursor 5 is below 8, so it gets 410, while cursor 10 is not, so it still works and delivers d and g.
Trade-offs and pitfalls
- Timestamps as cursors (
updated_since=2026-10-01T10:00:00Z) are tempting but fail on clock skew, equal timestamps at a page boundary, and rows committed late with an older timestamp. A sequence-based opaque cursor avoids all three and lets you change its internals later. - Hard-deleting rows with no tombstone makes deletes invisible to every client that was offline, so they keep showing deleted data until a manual refresh.
- Per-entity versions versus the global cursor: the cursor says where to resume; the version decides whether to apply an item. They solve different problems and both are needed.
- What flips the design: if clients must see every intermediate state (an audit feed), drop compaction and return the full log. If the dataset is small and changes constantly, a plain full fetch with an
ETagis cheaper than maintaining a log.
Running the code
docker run --rm --ulimit core=0 -v "$PWD":/w -w /w node:22 sh -c 'timeout 60 node changes.mjs'
Save the listing as changes.mjs (Node 22 ES module, no install step, uses the built-in fetch).
What are over-fetching and under-fetching in client-server APIs, and how do REST and GraphQL each deal with them? Describe what a frontend or mobile client feels from each approach.
Sample Answer
Direct answer
Over-fetching means the response carries more data than the screen uses. Under-fetching means one response is not enough, so the client must make follow-up requests to assemble a screen. Both waste the client's time, data allowance and battery. REST fixes them by shaping resources and endpoints on the server: sparse fieldsets (a fields= parameter that lists the only fields the client wants, shown below), embedded related data, or screen-specific endpoints. GraphQL, a query language where the server publishes a schema (a typed list of every object and field a client may ask for), fixes them by letting the client name exactly the fields it wants in a single request, at the price of a heavier server and a harder caching story.
What each problem looks like
Take a feed screen. Each row shows a post title, the author's name and a thumbnail.
- Over-fetching.
GET /postsreturns the whole post: body text, tags, view counts, edit history. The row uses three fields. If each post is 2,000 bytes and a row needs about 100, a page of 20 rows downloads 20 x 2,000 = 40,000 bytes to render 20 x 100 = 2,000 bytes of content, a factor of 20. The phone also parses all of it as JSON (JavaScript Object Notation) and holds it in memory. - Under-fetching.
GET /postsreturnsauthorIdbut not the name. If the 20 posts have 3 distinct authors, the client makes 1 + 3 = 4 requests (the pattern is called N+1: one request for the list, then one more per distinct related item). The author lookups cannot start until the list has arrived, so the screen waits for at least two sequential network round trips (list, then authors), and each extra hop on a weak cellular link is a chance to stall.
How REST deals with it
REST (resources addressed by URL, standard HTTP verbs) has no built-in query language, so you shape responses with conventions:
- Sparse fieldsets:
GET /posts?fields=id,title,author.name,thumbnailUrl. - Embedding or inclusion:
GET /posts?embed=authororinclude=author, so related data rides along (JSON:API, a public REST convention, definesfields[TYPE]andincludeand requires400 Bad Requestwhen a server does not supportinclude). - Screen-shaped endpoints:
GET /screens/feed, which belongs to a Backend-for-Frontend (BFF, a server layer owned by the client team that returns exactly what one client needs).
How GraphQL deals with it
GraphQL exposes one typed schema. The client sends a query that names fields, and the response mirrors its shape:
{ posts(first: 20) { id title author { name } thumbnailUrl(width: 96) } }
Over-fetching disappears because only named fields come back. Under-fetching disappears because the nested author { name } is resolved on the server in the same request (a resolver is the server function that produces the value of one field, here the author's name). Per the GraphQL over HTTP documentation, servers must accept POST for queries and mutations (mutations are the GraphQL operations that write data) and may accept GET for queries only. A response can hold both data and errors, and a response with non-null data should still use a 2xx status, because HTTP has no status for partial success.
What the client feels
| Concern | REST with fieldsets / BFF | GraphQL |
|---|---|---|
| Bytes per screen | Small if the server implements shaping; fixed endpoints over-fetch for other screens | Small by construction, per query |
| Round trips | 1 with embeds or a screen endpoint, otherwise 1 + N (one list request plus one per related item) | 1 per screen query |
| Client CPU | Parses a small JSON; no query building | Parses a small JSON; a client library may normalize results into a cache (split each response into one record per object, keyed by id, so an author shared by many posts is stored once), and that splitting and merging costs CPU on low-end devices |
| CDN (content delivery network) caching | Easy: URL is the cache key, GET is cacheable | Hard with POST; possible with GET plus persisted query ids (short identifiers that replace long query text, which also avoids URL length limits) |
| Developer velocity | Frontend waits for a new field or endpoint if the shape is missing | Frontend adds a field to its own query, no backend ticket, if the schema already exposes it |
| Versioning | New fields are additive; breaking shapes need /v2 or negotiation | Add fields freely and mark old ones with the built-in @deprecated directive (an annotation on a schema field that tells tools and developers the field is going away); usage is visible per field |
| Error handling | HTTP status per request tells the story | Often HTTP 200 with an errors array and partial data, so clients must inspect the body |
Which screens feel it most
- Personalized feed: many small rows, several related entities per row (author, counts, reaction). Under-fetching dominates, so one composed request (GraphQL query or BFF endpoint) matters most.
- Settings screen: one small object, rarely changing. Plain REST is fine and caches well; GraphQL adds little.
- Analytics sync: the client uploads or downloads bulk data in the background. The cost is bytes and radio wake-ups (every time the phone's cellular radio leaves idle to send a request it draws extra battery, so many small requests cost more energy than one batched request), not flexible field choice. Batch it into one request and compress it; a query language is not the lever.
Pitfalls
- GraphQL does not remove over-fetching on the server: a resolver (the function that produces one field) may still load the whole database row. Field selection saves wire bytes, not necessarily database work.
- A permissive
fields=parameter can leak internal columns. Validate it against an allowlist. - Choosing GraphQL to fix under-fetching, then discovering that each nested field issues its own database query (the N+1 problem), swaps a client problem for a server one.
Your team is deciding between GraphQL and plain REST for a new API that mobile clients will call. What scenarios would push you toward each, and what would you ask before deciding?
Sample Answer
Direct answer
I would not decide on technology first. I would ask five questions about the clients and the team, and then recommend: GraphQL (a query language where the server publishes one typed schema and each client query names the fields it wants) when the mobile screens change often, several client types read the same data in different shapes, and the team can own the schema (the typed contract listing every object and field a client may request), a query-cost policy (a rule that rejects queries that could return too much, with a worked example below) and the tooling. Plain REST (resource URLs with standard HTTP verbs), with fields and include parameters or a BFF (Backend-for-Frontend, a server layer owned by the client team that returns exactly what one client needs), when there are few screens, the data is public or heavily cached, or the team is small. For a new mobile-facing API with no strong signal either way, REST with sparse fieldsets is the lower-risk start because every HTTP cache, proxy, log and monitoring tool already understands it, and it can be wrapped by GraphQL later.
Questions to ask before deciding
- How many distinct client shapes exist? Count screens x platforms (iOS, Android, web). If the same three fields feed every screen, REST is enough. If twelve screens each want a different slice of the same entities, endpoint count explodes and GraphQL earns its cost.
- How fast do screens change relative to backend releases? If a new screen needs a new field every sprint, waiting on a backend ticket each time is the real cost. GraphQL lets the client add a field to its own query when the schema already exposes it.
- How long do old app versions live? A web page updates on refresh; a mobile app can run old releases for months. Additive schema evolution is easy in both styles, but GraphQL reports which fields are still queried, so you know when a deprecated field is safe to remove. With REST you version endpoints or keep fields forever.
- How cacheable is the traffic? Public, read-heavy content (catalogs, articles) wants CDN (content delivery network) caching, where the URL is the cache key. Personalized data is cached per user anyway.
- Who runs it? GraphQL needs a schema owner (the person or team who approves changes to the shared contract), resolver performance work (a resolver is the server function that produces one field, and each one needs to be fast and to avoid a database call per item), query-cost limits and client tooling. If nobody owns that, it decays.
Scenarios that push each way
| Signal | Leans toward |
|---|---|
| Many screens, each wanting a different slice; frontend teams blocked on backend changes | GraphQL |
| Nested, graph-like data (objects that link to other objects, such as user, posts, comments, reactions) read in many combinations | GraphQL |
| Several clients (iOS, Android, web, partner) with divergent needs | GraphQL, or one BFF per client |
| Public content that a CDN should cache by URL | REST |
| A handful of stable screens; CRUD (create, read, update, delete) with simple resources | REST |
| File upload or download, streaming, webhooks, simple health checks | REST (GraphQL has no native file transfer) |
| Small team, no one to own a schema and a cost policy | REST |
| Strict per-endpoint rate limits or quotas | REST (limits per URL are simple; GraphQL needs cost-based limits) |
What each choice costs on a phone
- GraphQL gives one request per screen and a response shaped to the query. Mobile clients commonly add a normalized cache (a store that keeps each entity once, keyed by id, so an edit shows on every screen), which uses client CPU and memory. HTTP caching is weaker: per the GraphQL over HTTP documentation, servers must accept
POSTfor queries, and may acceptGETfor queries only, so CDN-friendly reads needGET. Long query strings can exceed URL limits imposed by browsers and CDNs, so the documentation suggests persisted documents, automatic persisted queries or trusted documents. These are three names for one idea: the query text is registered with the server ahead of time (at build time, or on first use) and the client then sends only a short identifier instead of the query text. Partial failures come back as HTTP2xxwith anerrorsarray, so the app must read the body, not just the status. - REST gives per-URL caching, plain status codes and easy debugging, but multiple round trips on composite screens unless you add
includeor a BFF endpoint.
Controls I would require if GraphQL wins
- Only allow queries the app build ships (persisted or trusted documents), which blocks arbitrary expensive queries from the public internet and shrinks request size.
- Batch loading (collect all ids requested during one tick, meaning one turn of the server's event loop, so lookups made at effectively the same moment are grouped, and fetch them with one backend call) to avoid the N+1 problem, where a list of 20 items triggers 20 separate lookups.
- Depth and cost limits. Depth is how many levels of nesting a query may have; cost is an estimate of how many objects it could return. Illustrative cost model: each list field costs its
firstargument multiplied by the cost of what is nested under it. With a limit of 1,000, this query is rejected before any database work:
{ posts(first: 50) { id comments(first: 50) { id author { name } } } }
Worst case it asks for 50 posts + 50 x 50 = 2,500 comments + 2,500 comment authors = 5,050 objects, which is over 1,000, so the client must ask for fewer comments per post or page through them. The limit and the cost formula are policy choices, not fixed standards.
- Field-level authorization: the check of who may read a field runs inside the resolver for that field, so a user who may read
author { name }but notauthor { email }gets the name and an error or null for the email, even though both sit in one query.
What would flip my answer
- Mobile is the only client and has four screens: stay on REST.
- A second platform and a partner integration both ask for different shapes in the same month: move to GraphQL or a BFF per client.
- CDN hit rate drives your cost: REST with cache headers, or GraphQL
GETwith persisted ids.
Unlock Full Question Bank
Get access to all 11 Backend-for-Frontend and Client-Specific API Design interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.