Today’s goal
Build a client that treats the network as an unreliable, stateful boundary.
We will connect:
- HTTP methods, headers, status codes, and request helpers;
- runtime validation and transport/domain separation;
- cancellation, timeouts, retries, and backoff;
- REST, GraphQL, pagination, and API compatibility;
- loading, empty, stale, error, and recovery states;
- query keys, deduplication, freshness, and invalidation;
- pessimistic and optimistic mutations;
- server validation, authentication failures, and partial data.
By the end of today you can
- implement a fetch boundary that handles HTTP errors explicitly;
- distinguish network, transport, schema, domain, and authentication failures;
- choose safe retry behavior;
- model remote UI states without conflating loading and empty;
- design stable cache keys and freshness policies;
- deduplicate requests and cancel stale work;
- invalidate or update caches after mutations;
- use optimistic updates with rollback only when justified;
- preserve useful partial data during dashboard failures;
- draw the full client-server data architecture.
Browser and server have different responsibilities
browser: interaction, rendering, local drafts, navigation
server: authority, persistence, authorization, business rules
network: latency, loss, duplication, reordering, failureThe client cannot assume that the server is fast, available, current, or correct for every request.
A small fetch helper
async function request<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
const response = await fetch(input, init);
if (!response.ok) throw new HttpError(response.status);
const payload: unknown = await response.json();
return parseResponse<T>(payload);
}The helper centralizes transport behavior, but it must not hide meaningful errors or bypass runtime validation.
API design affects front-end architecture
The API determines:
- what can be fetched independently;
- how mutations are represented;
- which fields are stable;
- how pagination works;
- which errors can be recovered;
- which cache entries need invalidation.
Client architecture cannot fully compensate for an ambiguous server contract.
Stale does not mean wrong
Stale data can still be useful while a background request checks for newer data.
The UI should communicate that it is refreshing when the distinction matters.
Discarding useful data on every refresh failure can create a worse experience than showing stale data with a clear warning.
Invalidation versus direct cache update
Use invalidation when:
- many related queries may change;
- server logic computes fields the client cannot reproduce;
- correctness is more important than avoiding a request.
Directly update one entry when:
- the mutation response is authoritative;
- the affected cache shape is known;
- the update is easy to verify.
Browser HTTP cache versus application cache
| Browser HTTP cache | Application/query cache |
|---|---|
| controlled by HTTP semantics | controlled by application policy |
| stores representations | stores parsed/query-aware data |
| keyed by request semantics | keyed by query and domain inputs |
| works below application code | exposes freshness and invalidation |
They can cooperate, but they are not the same layer.
JSON form submission is an explicit conversion
const command = parseProductDraft(readDraft(formData));
await fetch("/api/products", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(command),
});The parser is where unfinished input becomes a validated transport model.
Authentication failures are not ordinary validation errors
401 → establish or refresh identity
403 → identity exists but action is forbidden
422 → submitted data violates application rulesThe correct response may be sign-in, permission explanation, or field correction - not a red error beneath an input.
Search and cancellation
let activeController: AbortController | undefined;
async function search(query: string) {
activeController?.abort();
activeController = new AbortController();
return loadProducts(query, activeController.signal);
}The cache and UI must also ensure that an older result cannot win after a newer query.
Troubleshooting guide (Part 1)
| Symptom | Likely cause |
|---|---|
| 500 response enters success code | response.ok was not checked |
| Old search result replaces new result | missing cancellation or request identity |
| Different filters show the same data | cache key omits an input |
| Every refresh blanks the screen | stale data is discarded unnecessarily |
Troubleshooting guide (Part 2)
| Symptom | Likely cause |
|---|---|
| Failed save loses the draft | mutation lifecycle owns the form incorrectly |
| Retry duplicates an operation | non-idempotent request has no safety policy |
| One panel breaks the dashboard | independent queries were coupled with Promise.all() |
| Cache never updates after save | invalidation or direct update is undefined |
Completion checklist
- HTTP errors and network errors are distinct;
- responses are validated before entering domain code;
- request cancellation prevents stale work;
- retries are limited and operation-aware;
- remote UI states distinguish loading, empty, stale, and error;
- cache keys include all relevant inputs;
- freshness and invalidation policies are explicit;
- mutations preserve drafts on failure;
- optimistic updates have rollback and revalidation rules;
- partial failures preserve independent useful data.
Misconceptions to leave behind (Part 1)
| Misconception | Better mental model |
|---|---|
fetch() rejects for every 4xx or 5xx | Check response.ok explicitly |
| TypeScript proves the server response | Runtime validation proves delivered data |
| Every request should be retried | Retry only safe, useful operations |
| Loading and empty are the same | One is pending; one is a successful zero result |
| Cache means correct forever | Cache means reusable knowledge under a policy |
Misconceptions to leave behind (Part 2)
| Misconception | Better mental model |
|---|---|
| Stale means unusable | Stale data may be useful while revalidating |
| Every mutation should be optimistic | Choose based on reversibility and conflict risk |
| Browser cache and query cache are identical | They are different layers with different owners |
| A server-state library replaces API design | It provides mechanisms, not semantics |
| Successful save fixes every cache | Related entries still need reconciliation |