Loading...
Loading...
API contracts plus the resilience layer: retries, timeouts, cancellation, and races.
Module 9 · API Design & Data Fetching
REST vs GraphQL, API versioning, offset vs cursor pagination, retries with backoff, timeouts, request cancellation, debouncing, throttling, polling, and race-condition handling.
REST resource design, GraphQL trade-offs, versioning and idempotency, offset vs cursor pagination, then the client resilience layer: retries with backoff, timeouts, cancellation, debouncing, throttling, polling, and race-condition handling.
Every frontend system design answer eventually reaches the API layer. Interviewers want contract literacy plus resilience: what the endpoint looks like, and what your UI does when it is slow, flaky, or answers out of order.
This module covers REST resource design, GraphQL trade-offs, versioning strategies, idempotency, and the two pagination models, then moves into the client patterns that actually break in production.
You will learn retry with exponential backoff and jitter, timeout budgets, AbortController-based cancellation, debounce vs throttle, polling vs push, and the three standard fixes for autocomplete race conditions.
The canonical drill is Google-style search autocomplete: input → debounce → request → cache → results, with stale responses discarded rather than rendered.
Phase 4 (Week 4) of the interview roadmap. Senior Track spans modules 1–19; the Staff Track starts at module 20.
Every frontend system design answer reaches the API layer. Interviewers want the contract and the failure story: what the endpoint looks like, what happens on a 500, what happens when three autocomplete responses come back out of order, and how you avoid rendering stale data.
Every system design answer ends up here. Bring the contract and the failure story.
Frontend engineers are not asked to design databases, but they are expected to be fluent in API contracts and, more importantly, in what the UI does when those contracts fail to deliver: slow responses, partial failures, duplicates, and answers that arrive in the wrong order.
Sketch the endpoints first, then say: “Now let me cover what happens when this is slow or fails.” Interviewers rarely have to prompt for the second half, which is exactly why volunteering it scores.
Nouns for resources, verbs for intent, status codes that mean something.
| Method | Semantics | Idempotent | Typical success |
|---|---|---|---|
| GET | Read, no side effects | Yes | 200 |
| POST | Create or invoke | No | 201 / 202 |
| PUT | Replace whole resource | Yes | 200 / 204 |
| PATCH | Partial update | Not inherently | 200 |
| DELETE | Remove resource | Yes | 204 |
Solves over-fetching and waterfalls. Moves the cost elsewhere.
| Dimension | REST | GraphQL |
|---|---|---|
| Shape control | Server decides | Client decides |
| Round trips | Often several | Usually one |
| HTTP caching | Native, per-URL | Weak; needs client cache |
| Versioning | Explicit versions | Additive + deprecation |
| Failure mode | Whole request fails | Partial data + errors[] |
| Main risk | Over/under-fetching | Expensive nested queries |
GraphQL earns its complexity when many clients need different shapes of deeply related data. For a single product surface with stable screens, REST plus a well-designed aggregate endpoint is usually simpler and caches better at the CDN.
Clients you cannot force-update are the whole reason versioning exists.
| Strategy | Example | Trade-off |
|---|---|---|
| URL path | /v2/orders | Visible, CDN-cacheable, but forks routes |
| Header | Accept: application/vnd.api.v2+json | Clean URLs, easy to forget, harder to cache |
| Query param | /orders?version=2 | Simple, but pollutes cache keys |
| Additive evolution | New optional fields only | No version at all; requires discipline |
The reason you can safely retry a payment.
An operation is idempotent when performing it twice has the same effect as performing it once. GET, PUT, and DELETE are idempotent by definition. POST is not, which is why blind retries on POST can double-charge a customer.
// The client generates the key once, then reuses it for every retry. const idempotencyKey = crypto.randomUUID(); await fetch("/v1/payments", { method: "POST", headers: { "Content-Type": "application/json", "Idempotency-Key": idempotencyKey, }, body: JSON.stringify({ orderId, amountCents }), });
The key must be generated once per logical operation, not per attempt. Generating a fresh UUID inside the retry loop defeats the entire mechanism.
Feeds use cursors. Admin tables use offsets. Know why.
| Aspect | Offset | Cursor |
|---|---|---|
| Shape | ?page=3&limit=20 | ?after=eyJpZCI6...&limit=20 |
| Jump to page N | Yes | No |
| Stable under inserts | No: items shift and duplicate | Yes |
| Deep-page cost | Grows with offset | Constant |
| Total count | Easy | Expensive or omitted |
| Best for | Admin tables, search results | Feeds, chat, activity logs |
// Cursor response: the client never computes the next position itself. { "items": [ /* ... */ ], "pageInfo": { "nextCursor": "eyJpZCI6MTIzLCJ0cyI6MTcwMH0", "hasNextPage": true } }
On an offset-paginated feed, three new posts arriving between page 1 and page 2 push three items you already saw onto page 2. Users see duplicates and silently miss others. Cursors anchor to a position in the data, so inserts cannot shift the window.
A consistent error shape is what lets the UI respond intelligently.
{ "error": { "code": "RATE_LIMITED", "message": "Too many requests.", "retryable": true, "retryAfterMs": 2000, "correlationId": "b3f1c9e2-..." } }
Retry the right things, back off exponentially, and always add jitter.
| Condition | Retry? |
|---|---|
| Network error / DNS failure | Yes |
| 408 Request Timeout | Yes |
| 429 Too Many Requests | Yes, honour Retry-After |
| 500, 502, 503, 504 | Yes |
| 400, 404, 422 (bad input) | No |
| 401 / 403 | No: refresh auth or stop |
async function withRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> { let lastError: unknown; for (let i = 0; i < attempts; i++) { try { return await fn(); } catch (error) { lastError = error; if (!isRetryable(error) || i === attempts - 1) break; // Exponential backoff with full jitter: 0–200ms, 0–400ms, 0–800ms. const ceiling = 200 * 2 ** i; await new Promise((r) => setTimeout(r, Math.random() * ceiling)); } } throw lastError; }
Without jitter, every client that failed at the same moment retries at the same moment. The recovering server is hit by a synchronized thundering herd and falls over again.
A request with no timeout is a spinner with no end.
const res = await fetch("/v1/search?q=" + encodeURIComponent(q), { signal: AbortSignal.timeout(3000), }); if (!res.ok) throw new ApiError(res.status);
AbortController is the single most useful API in this chapter.
useEffect(() => { const controller = new AbortController(); fetchResults(query, { signal: controller.signal }) .then(setResults) .catch((e) => { if (e.name !== "AbortError") setError(e); }); return () => controller.abort(); }, [query]);
Same goal, opposite guarantees.
| Debounce | Throttle | |
|---|---|---|
| Fires | Once, after a pause | At most once per interval |
| Guarantee | Only the final value | Steady stream of updates |
| Use for | Search input, autosave, resize-end | Scroll, mousemove, progress |
| Typical delay | 250–300ms | 100–200ms |
| Risk | Feels laggy if too long | Still fires during idle bursts |
Deduplicate identical in-flight requests by returning the same promise to every caller. Ten components asking for the same user profile should produce one network request, not ten.
Simple, cheap, and often the right answer before you reach for sockets.
// Adaptive polling: fast while active, slow when nothing is happening. let delay = 2000; async function tick() { const changed = await pollOnce(); delay = changed ? 2000 : Math.min(delay * 2, 60_000); if (document.visibilityState === "visible") setTimeout(tick, delay); }
Responses do not arrive in the order you sent them.
You type sho then shoes. The second request is served from a warm cache and returns in 40ms; the first hits a cold path and returns in 400ms. Without protection, the UI shows results for sho under the text shoes.
| Fix | How it works | Note |
|---|---|---|
| Abort previous | Cancel the in-flight request on each new input | Cleanest; also saves bandwidth |
| Sequence guard | Ignore any response older than the latest id | Works when abort is unavailable |
| Key by input | Cache results per query string | Stale response lands under its own key |
// Sequence guard: cheap, framework-free, and easy to explain on a whiteboard. const latest = useRef(0); async function search(q: string) { const id = ++latest.current; const data = await fetchResults(q); if (id === latest.current) setResults(data); // else: stale, drop it }
The most-asked frontend design question, end to end.
Input ↓ local state updates immediately (never block typing) Debounce 250ms ↓ skip queries under 2 characters Cache lookup ↓ hit → render instantly, no network Request (AbortController + 3s timeout) ↓ previous request aborted Response ↓ discard if not the latest sequence Render + keyboard nav (↑ ↓ Enter Esc, aria-activedescendant)
When asked to scale it: prefix results are highly cacheable at the CDN, popular prefixes can be precomputed, and the long tail can fall back to the search service. Mention short TTLs so trending queries stay fresh.
What to say when the design reaches the API layer.
API design and data fetching interview questions.