The previous lesson ended with an uncomfortable list: the workshop wifi dropping in the middle of a POST, the API taking fifteen seconds, the 503 from a deployment, the eight requests Iván fires while typing in the search box that come back out of order. js/data/tasks-api.js works perfectly while everything goes well, and on a network nothing goes well all the time. This lesson is the one that turns a demo into an application: you will learn to classify failures and decide what to do with each one, to encapsulate them in an ApiError of your own, to cancel requests with AbortController —settling the debt you left pending in 06-04—, to impose timeouts, to retry only what is safe to retry, to fire requests in parallel without one single outage bringing everything down, and to model the four states that every screen talking to a network must know how to show. You will finish with js/data/http.js and a BoardView that knows how to say "loading", "this failed, retry" and "there is nothing here".
Contents
- The seven failures of a request
- An error of your own:
ApiError fetchJson: the definitive wrapperAbortControllerin depth- Canceling the previous request: the search box
- Timeouts:
Promise.raceversusAbortSignal.timeout() AbortSignal.any(): combining signals- Retries with exponential backoff and jitter
- Idempotence: what can be retried
- Parallel without fragility:
allSettledversusall - The four states of the interface
- Accessible notices with
aria-live - Optimistic UI: apply before confirming
- Nómada Tasks:
js/data/http.jsand the view - Common Mistakes and Tips
- Exercises
- Conclusion
- The seven failures of a request
Before writing a line, it is worth having the complete map. Not every failure deserves the same treatment, and treating them all alike produces interfaces that lie ("Unknown error") or that keep insisting when they should not.
| Failure | What happened | How it is detected | Retry? | What to tell the user |
|---|---|---|---|---|
| Network down | No connection, wifi lost | fetch rejects with TypeError |
Yes, with backoff | "No connection. Retrying…" |
| DNS / unreachable host | The domain does not resolve | fetch rejects with TypeError |
Yes, rarely | "The server cannot be contacted" |
| Blocked by CORS | The server does not authorize your origin | fetch rejects with TypeError; the reason, console only |
No | "Configuration error" + notify the dev team |
| Client 4xx | Bad data, no permission, does not exist | response.ok === false, status 400-499 |
No (except 408 and 429) |
The specific message: "Hours are missing", "Session expired" |
| Server 5xx | The server failed | response.ok === false, status 500-599 |
Yes | "The server is not responding. Retrying…" |
| Non-JSON response | HTML arrived from a proxy or a sign-in page | json() throws SyntaxError |
No | "Unexpected response from the server" |
| Slow response | The server is taking too long | Nothing… until you add a timeout | Yes, once | "This is taking longer than usual" |
Three readings of this table:
- Only two rows make the
fetchpromise reject: transport failures (network, DNS, CORS, cancellation). Everything else arrives as a "normal" response and has to be inspected, exactly as you learned in 07-02. - The 4xx / 5xx boundary decides whether to retry. Insisting in the face of a
400guarantees failing again; insisting in the face of a503usually works. - A slow response is not detected on its own. It is the only failure you have to provoke, by setting a limit. Without a timeout, a request can hang indefinitely and your interface will show "Loading…" forever.
There are two 4xx cases that do get retried, and they are worth remembering: 408 Request Timeout and 429 Too Many Requests. The 429 also usually carries a Retry-After header that says how many seconds to wait; respecting it is good manners and keeps you from being blocked.
- An error of your own:
ApiError
ApiErrorIn 05-02 you created ValidationError extends Error with a field property, and in 06-07 that property let the view know which <input> to move focus to. Here exactly the same move is repeated: an error that carries the information the view will need in order to decide what to do.
// js/model/errors.js — added to the ones that already existed
export class ApiError extends Error {
/**
* @param {string} message Readable text, fit to display
* @param {object} data { status, code, url, detail, cause }
*/
constructor(message, { status = 0, code = 'unknown', url = '', detail = null, cause = null } = {}) {
super(message);
this.name = 'ApiError';
this.status = status; // 0 if there was not even an HTTP response
this.code = code; // 'network' | 'timeout' | 'canceled' | 'client' | 'server' | 'format'
this.url = url;
this.detail = detail; // the error body, if the server sent one
this.cause = cause; // the original error, for debugging
}
/** Is it worth trying again? */
get retryable() {
if (this.code === 'network' || this.code === 'timeout' || this.code === 'server') return true;
return this.status === 408 || this.status === 429;
}
/** Does the user have to sign in again? */
get needsSignIn() {
return this.status === 401;
}
describe() {
return `[${this.name} ${this.status} ${this.code}] ${this.message}`;
}
}What makes this error valuable is not that it is a class: it is that the knowledge about retries lives in one single place. The view does not have to know that a 429 is retried and a 403 is not; it asks error.retryable and that is that. It is the same principle as 06-07: rules live where they belong, and whoever consumes them just asks.
An ES2022 note that fits here: Error accepts a standard cause option, new Error('…', { cause: original }). We use our own cause field for consistency with DataError, but knowing the standard form will save you confusion when reading other people's code.
fetchJson: the definitive wrapper
fetchJson: the definitive wrapperNow the central function of the whole network layer. Written once, used by every method of the API.
// js/data/http.js
import { ApiError } from '../model/errors.js';
/**
* Makes a request and returns the JSON, or ALWAYS throws a classified ApiError.
* It never lets a TypeError or a SyntaxError escape untranslated.
*/
export async function fetchJson(url, options = {}) {
let response;
// ── 1 · Transport failures: the only ones that make fetch reject ──
try {
response = await fetch(url, options);
} catch (error) {
if (error.name === 'AbortError') {
throw new ApiError('Request canceled.', { code: 'canceled', url, cause: error });
}
if (error.name === 'TimeoutError') {
throw new ApiError('The server took too long.', { code: 'timeout', url, cause: error });
}
// TypeError: network down, DNS, CORS. The browser does not distinguish which, so as not to leak information.
throw new ApiError('The server could not be contacted.', { code: 'network', url, cause: error });
}
// ── 2 · Error responses: 4xx and 5xx ──
if (!response.ok) {
const detail = await readDetail(response);
const code = response.status >= 500 ? 'server' : 'client';
const message = detail?.message ?? detail?.error ?? `Server error ${response.status}.`;
throw new ApiError(message, { status: response.status, code, url: String(url), detail });
}
// ── 3 · Success with no body ──
if (response.status === 204 || response.headers.get('content-length') === '0') return null;
// ── 4 · Success with a body: is it really JSON? ──
const type = response.headers.get('content-type') ?? '';
if (!type.includes('json')) {
const text = await response.text().catch(() => '');
throw new ApiError(`Expected JSON and got "${type}".`, {
status: response.status, code: 'format', url: String(url), detail: text.slice(0, 200)
});
}
try {
return await response.json();
} catch (error) {
throw new ApiError('The response is not valid JSON.', {
status: response.status, code: 'format', url: String(url), cause: error
});
}
}
/** Tries to read the error body as JSON; if it cannot, as text; failing that, null. */
async function readDetail(response) {
const copy = response.clone(); // ← clone BEFORE reading (07-02)
try {
return await response.json();
} catch {
return await copy.text().catch(() => null);
}
}Stop at four points:
- The function has a single kind of error output. Whoever calls it only needs to know
ApiError. No checking whether it is aTypeError, aSyntaxErroror aResponseobject. - The
clone()inreadDetailis essential: ifjson()fails halfway through, the body stream is already consumed andtext()could not read it. - The
content-typeis checked. This is the one that saves you from impossible diagnoses: a corporate proxy that returns its sign-in page with status 200, and ajson()that blows up with aSyntaxError: Unexpected token '<'. With this check, the message says exactly what happened. status: 0means "there was no HTTP response". It is the convention that tells a transport failure apart from an application one.
AbortController in depth
AbortController in depthIn 06-04 you used { signal } to disconnect event listeners in one go, and it was said we would go deeper here. AbortController is a general cancellation mechanism: an object with two pieces.
const controller = new AbortController();
console.log(controller.signal); // AbortSignal { aborted: false, reason: undefined }
console.log(controller.signal.aborted); // false
controller.abort(); // ← the cancellation fires
console.log(controller.signal.aborted); // true
console.log(controller.signal.reason); // DOMException: signal is aborted without reason| Piece | Who holds it | What for |
|---|---|---|
controller |
Whoever decides to cancel | Calls .abort(reason?) |
controller.signal |
Whoever runs the operation | It is passed to fetch, to addEventListener… |
signal.aborted |
— | true if it has already been canceled |
signal.reason |
— | The reason passed to abort(), or an AbortError by default |
signal.throwIfAborted() |
— | Throws if it is already canceled; useful in long loops |
abort event on signal |
— | To clean up resources of your own |
Applied to fetch:
const controller = new AbortController();
// Cancel after 3 seconds, whatever happens
setTimeout(() => controller.abort(), 3000);
try {
const response = await fetch(url, { signal: controller.signal });
const data = await response.json();
} catch (error) {
if (error.name === 'AbortError') {
console.log('Canceled on purpose, not a failure'); // ← NOT an error to display
} else {
throw error;
}
}Telling AbortError apart from a real error is compulsory. When you cancel a request because you are no longer interested in it, showing the user an error message is a bug: nothing failed, it was you. That if (error.name === 'AbortError') appears in every serious application, and that is why fetchJson translates it to code: 'canceled', which the view knows to ignore.
Three details worth being clear about:
- A controller is single-use. Once aborted, its signal stays aborted forever. For the next operation, a new controller.
- You can pass a reason:
controller.abort(new Error('The user changed page')). It will appear insignal.reasonand it helps a great deal when debugging. - One signal can cancel several things. One
fetch, threeaddEventListeners and asetIntervalof your own, all with the samesignal: a singleabort()disconnects them all. It is the cleanup pattern for a view being destroyed.
/** Everything this view registers dies with a single abort(). */
function mountPanel(container, { signal }) {
container.addEventListener('click', onClick, { signal });
window.addEventListener('resize', onResize, { signal });
const id = setInterval(refresh, 30000);
signal.addEventListener('abort', () => clearInterval(id)); // cleanup for what does not accept signal
}
const controller = new AbortController();
mountPanel($('#panel'), { signal: controller.signal });
// …later on…
controller.abort(); // the three listeners and the interval all go, in one go
- Canceling the previous request: the search box
This is the case where cancellation stops being decoration. Iván types "screen printing" in the board's search box. Without care, that is ten keystrokes and ten requests, and the responses do not come back in order: the one for "scree" can arrive after the one for "screen printing" and overwrite the screen with the wrong results. It is the classic interface race condition.
sequenceDiagram
participant U as Iván
participant V as View
participant A as API
U->>V: types "scree"
V->>A: GET ?text=scree
U->>V: types "screen printing"
V->>A: GET ?text=screen printing
A-->>V: response for "screen printing" (fast)
V->>V: paints 3 results ✅
A-->>V: response for "scree" (slow)
V->>V: paints 11 results ❌ stale!
The solution combines two tools you already know: the debounce from 03-04 so as not to fire on every key, and AbortController so that the previous request never gets to return anything.
// js/data/tasks-api.js
let searchController = null;
export async function searchTasks(text) {
searchController?.abort(); // ← kills the previous one, if there was one
searchController = new AbortController();
try {
const url = buildUrl('/tasks', { q: text });
const plain = await fetchJson(url, { signal: searchController.signal });
return plain.map((d) => Task.fromJSON(d));
} catch (error) {
if (error.code === 'canceled') return null; // ← null means "ignore me"
throw error;
}
}// js/view/controller.js
import { debounce } from '../util/time.js';
const search = debounce(async (text) => {
const result = await searchTasks(text);
if (result === null) return; // it was canceled: a newer one replaces it
view.update({ board: new Board('Taller Nómada', result) });
}, 300);
$('#search').addEventListener('input', (event) => search(event.target.value));The debounce cuts ten requests down to one or two; the abort guarantees that, of the ones that do get fired, only the last one paints. The two pieces together, not just one.
- Timeouts:
Promise.race versus AbortSignal.timeout()
Promise.race versus AbortSignal.timeout()fetch has no timeout. A request can wait for minutes if the server accepts the connection and never answers. There are two ways of imposing a limit.
The classic one, with Promise.race (05-06):
/** Races the promise against a clock: the first one to settle wins. */
function withTimeout(promise, ms) {
const clock = new Promise((_, reject) => {
setTimeout(() => reject(new Error(`Timed out after ${ms} ms`)), ms);
});
return Promise.race([promise, clock]);
}
const data = await withTimeout(fetch(url).then((r) => r.json()), 5000);It works, but it has a serious flaw: the request keeps going. Promise.race decides who wins the race, it does not stop the loser. The server keeps processing, the bytes keep downloading and the connection stays occupied. With many timeouts in a row, you accumulate zombie requests.
The modern one, with AbortSignal.timeout(), which really cancels:
const data = await fetchJson(url, { signal: AbortSignal.timeout(5000) });
// If 5 s go by, the request is ABORTED and the error has name 'TimeoutError'One line, and the request is cut off at the root. The comparison:
Promise.race |
AbortSignal.timeout(ms) |
|
|---|---|---|
| Really cuts the request off | No, it keeps going | Yes |
| Frees the connection | No | Yes |
| Code needed | A helper function | None, it is native |
| Name of the error | Whatever you set | TimeoutError |
| Works for any promise | Yes (computations, other APIs) | Only for things that accept signal |
| Availability | Universal | Modern browsers |
The practical conclusion: use AbortSignal.timeout() for network requests, and keep Promise.race for putting a limit on promises that do not accept signals. Knowing both matters because Promise.race is still the general-purpose tool.
And a warning about the value: a timeout that is too short turns a slow network into a failure. Reasonable figures to start with: 5 s for reads that block the screen, 10-15 s for writes the user has already confirmed, and more headroom for file uploads.
AbortSignal.any(): combining signals
AbortSignal.any(): combining signalsSometimes a request has to be canceled for two different reasons: because time ran out, or because the user left the screen. AbortSignal.any() builds a signal that aborts as soon as any of the ones you pass it aborts.
const viewController = new AbortController(); // aborted when the view unmounts
const signal = AbortSignal.any([
viewController.signal, // the user left
AbortSignal.timeout(8000) // or it took too long
]);
const tasks = await fetchJson(url, { signal });It is the counterpart of Promise.any from 05-06, but for cancellations: the first one to fire wins. Applied in the project:
// js/data/http.js
/** Combined signal: our own timeout + external cancellation (for example, on unmount). */
export function signalWith(ms, external) {
const signals = [AbortSignal.timeout(ms)];
if (external) signals.push(external);
return AbortSignal.any(signals);
}If your target browser does not have AbortSignal.any, the manual equivalent is short and worth understanding:
function combine(signals) {
const controller = new AbortController();
for (const signal of signals) {
if (signal.aborted) { controller.abort(signal.reason); break; }
signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true });
}
return controller.signal;
}
- Retries with exponential backoff and jitter
A 503 during a deployment lasts seconds. Retrying makes sense… but not in any old way.
Retrying immediately makes things worse: if the server is saturated, a thousand clients retrying at once finish it off. The correct technique is called exponential backoff: waiting longer and longer between attempts.
attempt 1 → fails → wait 300 ms attempt 2 → fails → wait 600 ms attempt 3 → fails → wait 1200 ms attempt 4 → fails → gives up
And one ingredient is missing. If a thousand clients fail at once and they all wait exactly 300 ms, they all come back at once 300 ms later. That synchronized spike is called the thundering herd. The solution is jitter: adding a random component to the wait so the retries are spread out.
// js/data/http.js
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Runs `operation()`, retrying retryable failures.
* @param {Function} operation Asynchronous function with NO arguments (close over them in a closure)
*/
export async function withRetries(operation, {
attempts = 3,
baseMs = 300,
maxMs = 5000,
signal = null
} = {}) {
let lastError;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return await operation();
} catch (error) {
lastError = error;
// Do not retry what is hopeless: 400, 401, 403, 404, cancellations, format
if (!(error instanceof ApiError) || !error.retryable) throw error;
if (attempt === attempts) break;
if (signal?.aborted) throw error;
// The server can say how long to wait (429 with Retry-After); otherwise, exponential
const suggested = Number(error.detail?.retryAfter) * 1000;
const exponential = Math.min(baseMs * 2 ** (attempt - 1), maxMs);
const jitter = Math.random() * exponential * 0.3; // ±30 % of spread
const delay = Number.isFinite(suggested) && suggested > 0 ? suggested : exponential + jitter;
console.warn(`[nomada] Attempt ${attempt}/${attempts} failed (${error.code}). Retrying in ${Math.round(delay)} ms.`);
await sleep(delay);
}
}
throw lastError;
}// Usage: the operation is wrapped in an arrow function so it can be repeated
const tasks = await withRetries(
() => fetchJson(buildUrl('/tasks'), { signal: AbortSignal.timeout(5000) }),
{ attempts: 3 }
);Notice that the operation is a function, not a promise. A promise that has already been fired cannot be "fired again": you have to be able to create a new one on every attempt. It is the same reason why withTimeout received a promise (it only observes it) and withRetries receives a function (it runs it several times).
- Idempotence: what can be retried
Before retrying anything, the decisive question: what happens if the request did arrive and only the response was lost?
sequenceDiagram
participant C as Client
participant S as Server
C->>S: POST /v1/tasks {"title":"Service press"}
S->>S: Creates task 7 ✅
S--xC: The response is lost (network down)
Note over C: The client thinks it failed
C->>S: POST /v1/tasks {"title":"Service press"}
S->>S: Creates task 8 ❌ DUPLICATE!
That is why the idempotence from 07-02 stops being theory:
| Verb | Idempotent? | Retry just like that? |
|---|---|---|
GET |
Yes | Yes, always |
HEAD |
Yes | Yes |
PUT /tasks/6 |
Yes (replaces with the same content) | Yes |
DELETE /tasks/6 |
Yes (deleting what is already deleted changes nothing) | Yes, tolerating the 404 of the second attempt |
PATCH /tasks/6 {status:'done'} |
It depends: yes if it assigns, no if it increments | With care |
POST /tasks |
No | No, not without an idempotency key |
The standard solution for making a POST retryable is the idempotency key: the client generates a unique identifier for the operation and sends it in a header. The server remembers the keys it has already processed and, if it sees a repeated one, returns the previous result instead of creating another resource.
export async function createTask(data) {
const key = crypto.randomUUID(); // ← the SAME one on every retry
return withRetries(() => fetchJson(buildUrl('/tasks'), {
method: 'POST',
headers: { ...JSON_HEADERS, 'Idempotency-Key': key },
body: JSON.stringify(data),
signal: AbortSignal.timeout(10000)
}), { attempts: 3 });
}The crucial thing is that crypto.randomUUID() is called outside the closure that gets retried: if it were inside, every attempt would generate a different key and you would be back to the duplicates problem. And this only works if the server implements the header; if it does not, the rule is simple: do not retry POSTs automatically. Offer a "Retry" button and let the person decide.
- Parallel without fragility:
allSettled versus all
allSettled versus allMarta's panel needs three things: the tasks, the team hours and the notices. Requesting them in sequence adds up the latencies; doing it in parallel overlaps them. But the choice of combinator matters a lot, and it picks up directly from 05-06.
// ✗ Fragile: if the notices fail, there is no board
const [tasks, hours, notices] = await Promise.all([
listTasks(), listTeamHours(), listNotices()
]);Promise.all rejects as soon as one rejects. A 500 on the least important endpoint leaves the screen blank. For a panel made of independent parts, that is a bad distribution of consequences.
// ✓ Robust: each part paints if it could, and the ones that failed are flagged
const results = await Promise.allSettled([
listTasks(), listTeamHours(), listNotices()
]);
const [tasks, hours, notices] = results.map((r) =>
r.status === 'fulfilled' ? r.value : null
);
if (tasks === null) {
view.showError('The board could not be loaded.', { retry: () => start() });
} else {
view.update({ board: new Board('Taller Nómada', tasks) });
if (hours === null) view.markSectionDown('#team-hours');
if (notices === null) view.markSectionDown('#notices');
}The reminder of the four combinators, now with the criterion for when to use each one:
| Combinator | Settles when… | Use it when… |
|---|---|---|
Promise.all |
all fulfill, or one fails | The parts are indispensable to each other (you cannot paint anything without all of them) |
Promise.allSettled |
all finish, whatever happens | Independent parts of a panel. The usual case in interfaces |
Promise.race |
the first one settles, fulfilled or rejected | Timeouts, "whichever arrives first" |
Promise.any |
the first one that fulfills | Several mirrors of the same data, any of them will do |
And a warning about parallelism: firing fifty requests at once is not faster, it is a way of saturating the server and the browser (which limits simultaneous connections per origin). When you have many, split them into batches.
/** Runs the operations in batches of `size`, instead of all at once. */
export async function inBatches(operations, size = 5) {
const output = [];
for (let i = 0; i < operations.length; i += size) {
const batch = operations.slice(i, i + size);
output.push(...await Promise.allSettled(batch.map((op) => op())));
}
return output;
}
- The four states of the interface
A screen that talks to a network never has two states, it has four. Programming only "with data" and "without data" is the cause of eternal blank screens and of "Loading…" that never ends.
stateDiagram-v2
[*] --> Initial
Initial --> Loading: start()
Loading --> Success: data arrives
Loading --> Empty: 0 tasks arrive
Loading --> Error: ApiError
Error --> Loading: retry()
Success --> Loading: refresh()
Empty --> Loading: refresh()
Success --> Success: local change (optimistic)
| State | What is shown | What NOT to do |
|---|---|---|
| Loading | Indicator or content skeleton | A blank screen with no explanation |
| Success | The data | — |
| Empty | "No tasks yet" + an action to create one | Confusing it with an error, or with "loading" |
| Error | What happened, in plain language, + a Retry button | "Error: TypeError: Failed to fetch" |
The empty state is the one most often forgotten, and the most disconcerting: an empty list with no message is indistinguishable from a list that has not loaded. And the error state must always come with a way out; a message with no button leaves the user with reloading by hand as the only option.
Implemented with what you already have from 06-06:
// js/view/board-view.js (extension)
const UI_STATES = Object.freeze({ INITIAL: 'initial', LOADING: 'loading',
SUCCESS: 'success', EMPTY: 'empty', ERROR: 'error' });
export class BoardView {
// …everything that was already there…
#ui = { phase: UI_STATES.INITIAL, error: null };
/** The single point where the phase changes: impossible to leave the screen inconsistent. */
#changePhase(phase, { error = null } = {}) {
this.#ui = { phase, error };
this.#paintPhase();
}
#paintPhase() {
const { phase, error } = this.#ui;
$('#loading').hidden = phase !== UI_STATES.LOADING;
$('#empty').hidden = phase !== UI_STATES.EMPTY;
$('#error').hidden = phase !== UI_STATES.ERROR;
this.#container.hidden = phase !== UI_STATES.SUCCESS;
if (phase === UI_STATES.ERROR) {
$('#error-message').textContent = error.message; // textContent, not innerHTML (06-02)
$('#error-retry').hidden = !error.retryable;
}
}
async loadFromApi(api) {
this.#changePhase(UI_STATES.LOADING);
try {
const tasks = await withRetries(() => api.listTasks(), { attempts: 3 });
this.#state.board = new Board('Taller Nómada', tasks);
this.#changePhase(tasks.length === 0 ? UI_STATES.EMPTY : UI_STATES.SUCCESS);
this.render();
} catch (error) {
if (error.code === 'canceled') return; // not a failure
this.#changePhase(UI_STATES.ERROR, { error });
}
}
}The design key is #changePhase: a single point through which every transition passes. Without it, you end up with six places that set and unset hidden and, sooner or later, with the loading indicator and the error message visible at the same time.
- Accessible notices with
aria-live
aria-liveIn 06-07 you learned that a message that can only be seen in red does not exist for someone who cannot see the screen. With the network, the problem gets worse: the changes happen on their own, without the user having done anything, and a screen reader does not announce them unless you ask it to.
<!-- Loading state and errors, announced automatically -->
<p id="loading" class="notice" role="status" aria-live="polite" hidden>Loading tasks…</p>
<div id="error" class="notice notice--error" role="alert" hidden>
<p id="error-message"></p>
<button type="button" id="error-retry">Retry</button>
</div>
<p id="empty" class="notice" hidden>No tasks yet. <button type="button" id="create-first">Create the first one</button></p>| Attribute | When it announces | What for |
|---|---|---|
aria-live="polite" |
When the reader finishes what it is saying | State changes, "Loading", "3 tasks" |
aria-live="assertive" |
Interrupts immediately | Real emergencies only |
role="status" |
Equivalent to polite |
Informational messages |
role="alert" |
Equivalent to assertive |
Errors that demand attention |
Four practical rules:
- The container must exist in the DOM beforehand if you are going to put text into it. If you create it and set the message at the same instant, many readers do not announce it. Hence
hiddeninstead of creating and destroying. - Do not overuse
assertive. Interrupting mid-sentence is aggressive; save it for errors. - Damp repetitive notices. An
aria-livethat changes with every key turns the reader into a machine gun: it is the same argument that justified thedebouncein 06-07. - Move focus to the "Retry" button when an error appears after a user action. A message that is announced but unreachable by keyboard is not much use.
- Optimistic UI: apply before confirming
Marta marks a task as done. If you wait for the server's response before moving the card, the interface feels slow even if it takes 200 ms. The optimistic technique consists of applying the change immediately, sending the request in the background and reverting if it fails.
sequenceDiagram
participant M as Marta
participant V as View
participant A as API
M->>V: clicks "mark done"
V->>V: 1 · saves a copy of the previous state
V->>V: 2 · applies the change and repaints ⚡ (instant)
V->>A: 3 · PATCH /tasks/6 {"status":"done"}
alt Success
A-->>V: 200 OK
V->>V: confirms (removes the "pending sync" marker)
else Failure
A-->>V: 500 / network down
V->>V: 4 · REVERTS to the saved state
V->>M: "It could not be saved. Retry"
end
This is where the immutable updates from 04-07 pay off: because you did not mutate the previous state, reverting is simply going back to using it.
// js/view/controller.js
async function optimisticStatusChange(id, newStatus) {
const previous = board.findById(id).toJSON(); // 1 · snapshot of the previous state
board.changeStatus(id, newStatus); // 2 · immediate local change
view.render();
view.markPending(id, true); // subtle visual signal of "unconfirmed"
try {
await withRetries(
() => api.updateTask(id, { status: newStatus }),
{ attempts: 2 }
);
view.markPending(id, false); // 3 · confirmed
repository.save(board);
} catch (error) {
board.replace(Task.fromJSON(previous)); // 4 · revert
view.render();
view.notify(`"${previous.title}" could not be saved. ${error.message}`, {
action: { text: 'Retry', onPress: () => optimisticStatusChange(id, newStatus) }
});
}
}When to use it and when not to:
| Use optimism when… | Avoid it when… |
|---|---|
| The change almost always succeeds | The server may reject it often |
| It is easy to revert (a status, a piece of text) | There are irreversible effects (charges, emails sent) |
| The user notices the latency | The operation takes just as long anyway (uploading a file) |
| The data belongs to the user themselves | Others may have changed it at the same time |
And a rule of honesty: if you apply an optimistic change, mark it. A dot, a slight opacity, a "syncing" icon. The user closing their laptop believing something was saved when it was not is worse than making them wait 200 ms.
- Nómada Tasks:
js/data/http.js and the view
js/data/http.js and the viewAll the pieces together. The http.js module stays as a generic, reusable layer, and tasks-api.js builds on it:
// js/data/http.js — summary of what it exports
export { fetchJson }; // fetch + error classification into ApiError
export { withRetries }; // exponential backoff with jitter, retryable errors only
export { signalWith }; // AbortSignal.any([timeout, external])
export { inBatches }; // limited parallelism with allSettled// js/data/tasks-api.js — rewritten on top of http.js
import { fetchJson, withRetries, signalWith } from './http.js';
import { Task } from '../model/task.js';
const TIMEOUTS = Object.freeze({ read: 5000, write: 10000 });
export function createTasksApi({ base = 'http://localhost:3000', signal = null } = {}) {
const url = (path, params = {}) => {
const u = new URL(base + path);
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null && v !== '') u.searchParams.set(k, v);
}
return u;
};
return {
/** Read: idempotent, retried without fear. */
async listTasks(filters = {}) {
const plain = await withRetries(
() => fetchJson(url('/tasks', filters), { signal: signalWith(TIMEOUTS.read, signal) }),
{ attempts: 3, signal }
);
return plain.map((d) => Task.fromJSON(d));
},
/** Creation: NOT idempotent. A single attempt + a key, and let the user decide about retrying. */
async createTask(data) {
const plain = await fetchJson(url('/tasks'), {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID() },
body: JSON.stringify(data),
signal: signalWith(TIMEOUTS.write, signal)
});
return Task.fromJSON(plain);
},
/** A PATCH that assigns a fixed value: idempotent, retryable. */
async updateTask(id, changes) {
const plain = await withRetries(
() => fetchJson(url(`/tasks/${id}`), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changes),
signal: signalWith(TIMEOUTS.write, signal)
}),
{ attempts: 2 }
);
return Task.fromJSON(plain);
},
/** DELETE: idempotent. A 404 on the second attempt means the first one worked. */
async deleteTask(id) {
try {
await withRetries(
() => fetchJson(url(`/tasks/${id}`), { method: 'DELETE', signal: signalWith(TIMEOUTS.write, signal) }),
{ attempts: 2 }
);
} catch (error) {
if (error.status !== 404) throw error; // it was already deleted: that is the desired result
}
return true;
}
};
}And the complete application startup, with local first, network afterwards, and every state covered:
// js/app.js
const appController = new AbortController();
const api = createTasksApi({ base: 'http://localhost:3000', signal: appController.signal });
const repository = new LocalRepository();
async function start() {
// 1 · Local data is painted instantly: never a blank screen if there is data
const local = repository.load();
if (local !== null) { view.update({ board: local }); }
// 2 · The truth from the server, with every state handled
await view.loadFromApi(api);
repository.save(view.board);
}
$('#error-retry').addEventListener('click', start, { signal: appController.signal });
window.addEventListener('online', () => start(), { signal: appController.signal });
window.addEventListener('offline', () => view.notify('No connection. Changes are saved locally.'),
{ signal: appController.signal });
start();That single appController cancels the in-flight requests and all the listeners in one go when the application unmounts. One AbortController for the whole life of the application, and other short-lived ones for specific operations such as the search: that is the usual split.
Common Mistakes and Tips
- Treating
AbortErroras a failure. Showing "Failed to load" when you are the one who canceled is a classic bug. Always filter byerror.name === 'AbortError'or, withfetchJson, bycode === 'canceled'. - Reusing an
AbortControllerthat has already been aborted. Its signal stays aborted forever and the next request dies instantly. Create a new one per operation. - Retrying a
POSTwithout an idempotency key. It creates invisible duplicates: the user sees one task, the database has three. - Retrying a
400or a403. It will never work; it only adds latency and noise to the logs. - Retrying without jitter. It synchronizes every client and hits the server in waves exactly when it is at its worst.
- Trusting
Promise.raceas if it canceled. It does not cancel: it only ignores the loser, which carries on consuming resources. - Setting no timeout at all. An eternal "Loading…".
fetchdoes not do it for you. - Using
Promise.allfor a panel of independent parts. A secondary failure leaves the screen empty. UseallSettled. - Forgetting the empty state. A list with no items and no message looks broken.
- Showing the technical message to the user.
TypeError: Failed to fetchmeans nothing to Marta. Translate it, and keep the technical detail in the console. - Applying an optimistic change without being able to revert it. If you mutated the previous state, there is no way back. The immutable updates from 04-07 are the prerequisite, not decoration.
- Tip: test failures on purpose. In DevTools → Network you can simulate Offline and Slow 3G, and
https://httpstat.us/503returns whatever code you want. Error handling that has never been run is not tested. - Tip: log the full technical error, show the human one.
console.error(error.describe(), error.cause)in the console; on screen, the short sentence. - Tip: one timeout per kind of operation. Reading and writing do not deserve the same headroom.
- Tip: do not retry in an infinite loop. Three attempts and a clear way out. Endless insistence drains battery and patience.
Exercises
Exercise 1 — Retries with Retry-After.
Extend withRetries so that, when the error is a 429, it reads the Retry-After header of the response and waits exactly that long instead of applying exponential backoff. You will need fetchJson to store that header in the ApiError. The header can come in seconds (Retry-After: 30) or as an HTTP date (Retry-After: Wed, 20 Sep 2026 10:00:00 GMT): handle both formats and cap the wait at a maximum of 60 seconds.
Exercise 2 — Cancelable search box with states.
Write createSearcher({ field, onChange, onError, delay = 300 }) that returns a search(text) function and a cancel() function. It must damp with debounce, cancel the previous request on every new search, ignore AbortErrors, impose a 4-second timeout and call onChange with { phase: 'loading' | 'success' | 'empty' | 'error', tasks, error }. Empty text should cancel whatever is in flight and return the 'initial' phase.
Exercise 3 — Optimistic status change with revert.
Write createOptimisticAction({ board, view, api, repository }) that returns changeStatus(id, newStatus) with the full cycle: snapshot of the previous state with toJSON(), local change, render, pending marker, request with two retries, and on failure a revert with Task.fromJSON, a render and a notice with a retry action. Add a safeguard: if there is already an operation in flight for that same task, ignore the new press instead of chaining two optimistic changes.
Solutions
Solution 1
// In fetchJson, when building the response error:
throw new ApiError(message, {
status: response.status,
code,
url: String(url),
detail,
retryAfter: response.headers.get('retry-after') // ← stored exactly as it arrived
});/** Turns the Retry-After header (seconds or HTTP date) into milliseconds of waiting. */
function suggestedDelay(header, maxMs = 60000) {
if (!header) return null;
const seconds = Number(header);
if (Number.isFinite(seconds)) return Math.min(seconds * 1000, maxMs);
const date = Date.parse(header); // HTTP date format
if (Number.isNaN(date)) return null;
return Math.min(Math.max(date - Date.now(), 0), maxMs);
}// Inside the catch of withRetries, replacing the wait calculation:
const suggested = suggestedDelay(error.retryAfter);
const exponential = Math.min(baseMs * 2 ** (attempt - 1), maxMs);
const delay = suggested ?? (exponential + Math.random() * exponential * 0.3);The Number(header) tells the two formats apart without regular expressions: Number('30') gives 30, whereas Number('Wed, 20 Sep…') gives NaN and falls through to Date.parse. And the 60 s cap protects you from a server that asks you to wait an hour: past a certain point, it is better to give up and let the user decide.
Solution 2
import { debounce } from '../util/time.js';
import { fetchJson } from './http.js';
import { Task } from '../model/task.js';
export function createSearcher({ base, onChange, delay = 300 }) {
let controller = null;
function cancel() {
controller?.abort();
controller = null;
}
const run = debounce(async (text) => {
cancel(); // 1 · kills the previous one
controller = new AbortController();
onChange({ phase: 'loading', tasks: [], error: null });
try {
const url = new URL(`${base}/tasks`);
url.searchParams.set('q', text);
const plain = await fetchJson(url, {
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(4000)])
});
const tasks = plain.map((d) => Task.fromJSON(d));
onChange({ phase: tasks.length === 0 ? 'empty' : 'success', tasks, error: null });
} catch (error) {
if (error.code === 'canceled') return; // 2 · a newer search replaces it
onChange({ phase: 'error', tasks: [], error });
}
}, delay);
function search(text) {
const trimmed = text.trim();
if (trimmed === '') {
cancel();
onChange({ phase: 'initial', tasks: [], error: null });
return;
}
run(trimmed);
}
return { search, cancel };
}Notice the order inside run: first the previous one is canceled, then the new controller is created and only then is "loading" announced. The other way round, canceling the previous one could end up overwriting the phase you have just set. And AbortSignal.any combines the two reasons to cancel —a newer search, or the timeout— into a single signal.
Solution 3
export function createOptimisticAction({ board, view, api, repository }) {
const inFlight = new Set(); // ids with a pending operation
async function changeStatus(id, newStatus) {
if (inFlight.has(id)) return false; // safeguard: one at a time per task
inFlight.add(id);
const previous = board.findById(id).toJSON(); // immutable snapshot of the previous state
try {
board.changeStatus(id, newStatus); // may throw ValidationError because of R6
} catch (error) {
inFlight.delete(id);
view.notify(error.message);
return false;
}
view.render();
view.markPending(id, true);
try {
await withRetries(() => api.updateTask(id, { status: newStatus }), { attempts: 2 });
view.markPending(id, false);
repository.save(board);
return true;
} catch (error) {
if (error.code === 'canceled') return false;
board.replace(Task.fromJSON(previous)); // revert
view.render();
view.notify(`"${previous.title}" could not be saved.`, {
action: { text: 'Retry', onPress: () => changeStatus(id, newStatus) }
});
return false;
} finally {
inFlight.delete(id); // whatever happens, it is released
}
}
return { changeStatus, hasPending: () => inFlight.size > 0 };
}Three important details. The Set of in-flight ids stops two quick presses from chaining two optimistic changes with crossed reverts —a common source of impossible states. The finally releases the id always, including the error paths and the early return. And the local validation (the board's changeStatus, which applies rule R6) is done before anything is painted: there is no point being optimistic about a change your own model rejects.
Conclusion
This lesson is the boundary between an exercise and a product. You have the complete map of the seven failures of a request —network, DNS, CORS, 4xx, 5xx, non-JSON response and slow response— and, for each one, how to detect it, whether it deserves a retry and what to tell the user; along with the two ideas that govern everything else: only transport failures make fetch reject, and a slow response is not detected on its own, you have to provoke it yourself with a timeout. That knowledge lives encapsulated in ApiError extends Error, with its status, its code and its retryable and needsSignIn getters, exactly the same pattern that ValidationError gave you with its field property in 05-02.
You know how to write fetchJson, which translates any failure into a classified ApiError, checks the content-type to catch HTML disguised as JSON, respects the bodyless 204 and clones the response before reading the error detail. You have mastered AbortController: the controller/signal pair, aborted and reason, using a single signal to cancel requests and listeners at the same time, the rule that a controller is single-use, and the obligation to tell AbortError apart from a real error —canceling on purpose is not failing. You have applied it to the case that justifies it: the search box where debounce cuts down the requests and abort guarantees that only the last one paints, closing the race condition of responses coming back out of order.
You know how to impose timeouts and why AbortSignal.timeout() is superior to Promise.race —the first cuts the request off, the second only ignores the loser while it carries on consuming the connection— and how to combine cancellation reasons with AbortSignal.any(). You know how to retry with exponential backoff and jitter, you understand why jitter exists (the thundering herd) and, above all, you know what can be retried: GET, PUT and DELETE without a problem, POST only with an idempotency key generated outside the closure that gets repeated. You know how to choose a combinator for parallelism, with Promise.allSettled as the default option in interfaces because a secondary failure must not empty the screen, and to split into batches when there are many requests.
And you know that a screen connected to a network has four states, not two: loading, success, empty and error with a retry; that transitions must go through a single point so as not to leave the interface in impossible states; that notices need aria-live, role="status" and role="alert" in order to exist for someone who cannot see the screen too; and that optimistic UI —apply the change, send, revert if it fails— is only possible because the immutable updates from 04-07 keep the previous state intact, and is only honest if you visually mark what is not yet confirmed.
With js/data/http.js and js/data/tasks-api.js rewritten, Nómada Tasks is now a connected application that stands up to the real world. But it has one limit left, conceptual rather than technical: the browser only finds things out when it asks. If Iván moves a task from his laptop, Marta's board will keep showing the old state until she reloads or presses refresh. You could ask every five seconds, but that means spending battery and bandwidth so that the answer is almost always "nothing new". What is needed is for the server to be able to speak first: a permanent, bidirectional connection through which changes arrive on their own, the instant they happen. That is WebSockets, where the Taller Nómada board will stop being one copy per person and become a single, shared, live one.
JavaScript Course: From Beginner to Advanced
Module 1: Introduction to JavaScript
- What Is JavaScript?
- Setting Up Your Development Environment
- Your First JavaScript Program
- JavaScript Syntax and Basic Concepts
- Variables and Data Types
- Basic Operators
- Type Conversion and Comparisons
- The Course Project: Nómada Tasks
Module 2: Control Structures
- Conditional Statements
- Loops: for, while, do-while
- Switch Statements
- Flow Control: break, continue and Nested Loops
- Error Handling with try-catch
Module 3: Functions
- Defining and Calling Functions
- Function Expressions and Arrow Functions
- Parameters and Return Values
- Scope and Closures
- Hoisting and the Execution Context
- Higher-Order Functions
- Recursion
Module 4: Objects and Arrays
- Introduction to Objects
- Object Methods and the
thisKeyword - Arrays: Basics and Methods
- Iterating over Arrays
- Searching, Sorting and Aggregating Data: find, sort and reduce
- Array Destructuring
- Object Destructuring, Spread and Rest
- JSON and Copying Objects
Module 5: Advanced Objects and Functions
- Prototypes and Inheritance
- Classes and Object-Oriented Programming
- Encapsulation: Getters, Setters and Private Fields
- Modules: Import and Export
- Asynchronous JavaScript: Callbacks
- Promises and Async/Await
- The Event Loop and the Microtask Queue
- Iterators and Generators
Module 6: The Document Object Model (DOM)
- Introduction to the DOM
- Selecting and Manipulating DOM Elements
- Handling Events
- Propagation, Delegation and Custom Events
- Creating and Removing DOM Elements
- Rendering Lists and HTML Templates
- Handling and Validating Forms
Module 7: Browser APIs and Advanced Topics
- Local and Session Storage
- The Fetch API and AJAX
- Robust Requests: Errors, Timeouts and AbortController
- WebSockets
- Service Workers and Progressive Web Apps (PWAs)
- Essential Browser APIs
- Introduction to WebAssembly
Module 8: Testing and Debugging
- Debugging JavaScript
- Code Quality: ESLint, Prettier and Conventions
- Unit Testing with Jest
- Test Doubles: Mocks, Stubs and Spies
- Integration Testing
- End-to-End Testing with Cypress
Module 9: Performance and Optimization
- Measure Before You Optimize: DevTools and Web Vitals
- Optimizing JavaScript Performance
- Memory Management
- Efficient DOM Manipulation
- Lazy Loading and Code Splitting
Module 10: JavaScript Frameworks and Libraries
- Why Frameworks Exist
- Introduction to React
- State Management with Redux
- Vue.js Basics
- Angular Basics
- Choosing the Right Framework
