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

  1. The seven failures of a request
  2. An error of your own: ApiError
  3. fetchJson: the definitive wrapper
  4. AbortController in depth
  5. Canceling the previous request: the search box
  6. Timeouts: Promise.race versus AbortSignal.timeout()
  7. AbortSignal.any(): combining signals
  8. Retries with exponential backoff and jitter
  9. Idempotence: what can be retried
  10. Parallel without fragility: allSettled versus all
  11. The four states of the interface
  12. Accessible notices with aria-live
  13. Optimistic UI: apply before confirming
  14. Nómada Tasks: js/data/http.js and the view
  15. Common Mistakes and Tips
  16. Exercises
  17. Conclusion

  1. 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 fetch promise 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 400 guarantees failing again; insisting in the face of a 503 usually 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.

  1. An error of your own: ApiError

In 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.

  1. fetchJson: the definitive wrapper

Now 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 a TypeError, a SyntaxError or a Response object.
  • The clone() in readDetail is essential: if json() fails halfway through, the body stream is already consumed and text() could not read it.
  • The content-type is checked. This is the one that saves you from impossible diagnoses: a corporate proxy that returns its sign-in page with status 200, and a json() that blows up with a SyntaxError: Unexpected token '<'. With this check, the message says exactly what happened.
  • status: 0 means "there was no HTTP response". It is the convention that tells a transport failure apart from an application one.

  1. AbortController in depth

In 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 in signal.reason and it helps a great deal when debugging.
  • One signal can cancel several things. One fetch, three addEventListeners and a setInterval of your own, all with the same signal: a single abort() 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

  1. 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.

  1. Timeouts: 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.

  1. AbortSignal.any(): combining signals

Sometimes 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;
}

  1. 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).

  1. 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.

  1. Parallel without fragility: allSettled versus all

Marta'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;
}

  1. 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.

  1. Accessible notices with aria-live

In 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 hidden instead of creating and destroying.
  • Do not overuse assertive. Interrupting mid-sentence is aggressive; save it for errors.
  • Damp repetitive notices. An aria-live that changes with every key turns the reader into a machine gun: it is the same argument that justified the debounce in 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.

  1. 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.

  1. Nómada Tasks: js/data/http.js and the view

All 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 AbortError as a failure. Showing "Failed to load" when you are the one who canceled is a classic bug. Always filter by error.name === 'AbortError' or, with fetchJson, by code === 'canceled'.
  • Reusing an AbortController that has already been aborted. Its signal stays aborted forever and the next request dies instantly. Create a new one per operation.
  • Retrying a POST without an idempotency key. It creates invisible duplicates: the user sees one task, the database has three.
  • Retrying a 400 or a 403. 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.race as 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…". fetch does not do it for you.
  • Using Promise.all for a panel of independent parts. A secondary failure leaves the screen empty. Use allSettled.
  • Forgetting the empty state. A list with no items and no message looks broken.
  • Showing the technical message to the user. TypeError: Failed to fetch means 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/503 returns 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

Module 2: Control Structures

Module 3: Functions

Module 4: Objects and Arrays

Module 5: Advanced Objects and Functions

Module 6: The Document Object Model (DOM)

Module 7: Browser APIs and Advanced Topics

Module 8: Testing and Debugging

Module 9: Performance and Optimization

Module 10: JavaScript Frameworks and Libraries

Module 11: Final Project

© Copyright 2026. All rights reserved