Unit 2 · Software engineering · Lesson 14 of 15
Frontend state, request races and client-server interfaces
Step 1: Learn the ideas
- Source of truth and derived state
- Server state versus client state; the URL as state
- Caching, staleness and invalidation
- Request races and stale responses
- AbortController, sequence tokens and debouncing
- Optimistic updates, rollback and reconciliation
- fetch error handling, timeouts and response validation
- Pagination: offset versus cursor
- Idempotent retries, backoff and idempotency keys
Why it matters
A UI is a function of state, but that state is split across component memory, the URL, browser storage and a server that other users also modify, connected by requests that can be slow, fail or finish in any order. Interviewers probe this with scenarios: a search box showing results for an old query, a like button that lies after a failed request, a list that duplicates rows while paging. Good answers name the invariant that broke and the mechanism that restores it.
Core ideas
One source of truth, derive the rest. Store each fact once. If a full name follows from first and last name, or a filtered list from the list and the filter, compute it (memoizing if expensive) instead of storing a copy that must be kept in sync. Duplicated state is how interfaces end up disagreeing with themselves.
Server state is a cache. Data fetched from an API is owned by the server: it can change without your knowledge, is shared across screens and may be stale on arrival. Treat it as a cache keyed by everything that determines the response (resource, filters, page, user), with explicit freshness rules (refetch on focus or interval, stale-while-revalidate) and invalidation rules (refetch or patch the affected keys after a mutation). Client state such as open panels, unsaved input or selection is owned by the UI and needs none of that. State that should survive a reload or be shareable, such as filters and the page number, belongs in the URL.
Request races. If a user types "re" and then "rea", two requests are in flight and the first may answer last; applying responses in arrival order shows results for the wrong query. The fixes are often combined:
- Abort the previous request with an
AbortControllerwhen a new one starts;fetchthen rejects with anAbortError, which you ignore rather than display. - Tag each request with an increasing sequence number and apply a response only if its number is still the latest. In React effects, an
ignoreflag set by the cleanup function does the same. - Debounce input to send fewer requests. This reduces load but does not fix ordering, because requests can still overlap.
let latest = 0;
let controller: AbortController | undefined;
async function search(q: string) {
const id = ++latest;
controller?.abort();
controller = new AbortController();
try {
const res = await fetch('/api/search?q=' + encodeURIComponent(q), { signal: controller.signal });
if (!res.ok) throw new Error('HTTP ' + res.status);
const data: unknown = await res.json();
if (id === latest) render(data);
} catch (e) {
if ((e as Error).name !== 'AbortError' && id === latest) showError(e);
}
}
Optimistic updates. Apply the expected result locally, send the request, and on failure restore a snapshot taken before the change and tell the user. On success, replace the optimistic value with the server's response, which may differ (server-assigned IDs, normalized text, counts changed by others). With several mutations in flight, roll back only your own change or refetch, so a failure does not overwrite a later success.
Client-server contracts. fetch resolves for any HTTP status; only network failures and aborts reject, so check response.ok and parse error bodies deliberately, and validate response shapes at runtime. Separate retryable failures (timeouts, 503, 429 with Retry-After) from permanent ones (400, 403, 404, 409). GET, PUT and DELETE are idempotent by HTTP semantics; POST is not, so retry a create only when the API accepts an idempotency key that lets the server deduplicate. Back off exponentially with jitter. Offset pagination is simple but skips or repeats rows when items are inserted or deleted between pages; cursor pagination continues from a stable position.
Common misconceptions
- "Debouncing prevents race conditions." It reduces the number of requests; overlapping requests can still resolve out of order.
- "fetch rejects on HTTP errors." It resolves for 404 and 500; you must check
response.ok. - "Aborting a request undoes it on the server." Abort stops the client from waiting; the server may already have applied a mutation, which is why idempotency matters.
- "Storing derived values is a harmless optimization." Every stored copy is another place to forget an update; compute and memoize instead.
- "Optimistic UI just means updating early." Without a snapshot, a rollback path and reconciliation with the real response, it turns failures into silently wrong screens.
How to approach it
Build the scenarios instead of only reading about them: a search box against a local server with random delays, a like button whose endpoint sometimes fails, and a paginated list with inserts between page loads. Graduate interviews tend to ask about state placement and async basics; mid-level and senior frontend interviews present a race or consistency bug and expect a mechanism, its failure modes and the API support it needs.
Suggested order
- 1Read react.dev "Choosing the State Structure" (redundant, duplicated and contradictory state) and the "Fetching data" part of "You Might Not Need an Effect"; the principles apply in any framework.
- 2Read the TanStack Query guides on replacing global state managers, Important Defaults, Query Invalidation and Optimistic Updates for the server-state-as-cache model; ignore API details.
- 3Read MDN AbortController and the "Canceling a request" and "Checking response status" parts of "Using the Fetch API".
- 4Build a search box against a local endpoint with random delays between 100 and 1500 ms; reproduce stale results, then fix them with AbortController and a sequence token, and add a debounce.
- 5Add a like button with an optimistic update and snapshot rollback, and make the endpoint fail one request in three to test it.
- 6Read the core AWS "Retry with backoff" pattern and revisit the API contracts topic for pagination and error formats; write a retry wrapper that retries only idempotent requests or requests carrying an idempotency key.
Step 2: Check off what you can do
Tap each one you can genuinely do or explain. Each earns 10 XP.
Step 3: Go deeper
React documentation: Managing State and EffectsStart here
React team (react.dev)Section: Choosing the State Structure
Rules against redundant, duplicated and contradictory state; then the Fetching data sections on ignoring stale responses.
Open(opens in a new tab)Link responded, 5 Oct 2026MDN: Using the Fetch API, AbortController and AbortSignalStart here
MDN Web DocsSection: Using the Fetch API: Canceling a request
Aborting requests, checking response status, and AbortSignal helpers for timeouts.
Open(opens in a new tab)Link responded, 5 Oct 2026TanStack Query documentation (React): server-state guides
TanStack (tanstack.com)Section: Optimistic Updates
Server state as a cache: the client-state comparison, important defaults, invalidation and optimistic updates with rollback; transfer the concepts, not the API.
Open(opens in a new tab)Link responded, 5 Oct 2026Retry with backoff pattern (AWS Prescriptive Guidance)
Amazon Web ServicesWhen a retry is safe and how exponential backoff with jitter avoids synchronized retries; apply it to client retry policies.
Open(opens in a new tab)Link responded, 5 Oct 2026
Step 4: Practice
No problem set captures these bugs, so build them in a small page with a local server: (1) search-as-you-type against an endpoint that delays responses randomly between 100 and 1500 ms; observe stale results, fix them with AbortController plus a sequence token, then add a 250 ms debounce and confirm it alone does not fix ordering; (2) an optimistic like button whose endpoint fails one request in three, with snapshot rollback and an error message; (3) a paginated list where you insert rows between page loads, comparing offset and cursor pagination; (4) a retry wrapper that retries only GET requests or requests carrying an idempotency key, with exponential backoff and jitter. For each, write down the invariant the fix restores.
No external problems are linked; follow the guidance above.
Record attempts on each problem’s page (+15 XP, +10 more when solved on your own).
Step 5: Test yourself
Answer each one out loud or on paper before you finish the lesson.
- Question 1In a filterable table, which pieces of state are sources of truth and which are derived?
- Question 2What belongs in the cache key of a search-results query?
- Question 3What happens to the server-side effect of a POST when the client aborts it?
- Question 4How does a sequence token differ from aborting the previous request, and why combine them?
- Question 5Why can offset pagination show the same row twice?
- Question 6What must you snapshot before applying an optimistic update?
You’re done when you can
- Reproduce and fix a stale-response race, and explain why debouncing alone was not enough.
- Implement an optimistic update that rolls back correctly on failure and reconciles with the server response on success.
- Explain server state versus client state and give the invalidation rule you would use after a mutation in a list-and-detail interface.
- Explain why fetch resolves on a 500 response and show the error handling you would write.
- Explain when a retry is safe and how an idempotency key makes a POST retry safe.


