Chapter 11

Async JavaScript, Fetch, REST and JSON

Promises, async/await, HTTP, REST conventions, JSON and error handling.

11. Async JavaScript, Fetch, REST, and JSON

Web applications routinely wait for work that takes longer than a single JavaScript instruction: a network response, a timer, a file, or user input. The browser must remain responsive while that work is pending. JavaScript uses callbacks and promises to represent later results, and async/await provides a readable way to compose promise-based operations. Fetch adds an HTTP interface, while REST conventions describe how clients and servers can organize resources and actions.

This chapter traces the evolution from callbacks to promises, compares promise combinators, builds robust Fetch requests, and covers parsing, cancellation, and bounded retries. The examples use placeholder endpoints; replace them only with services whose API and authorization you are permitted to use.

Overview and Learning Objectives

By the end of this chapter, you will be able to:

  1. Explain asynchronous callbacks, promises, and async/await.
  2. Choose between Promise.all(), allSettled(), race(), and any().
  3. Describe REST resources, HTTP methods, and useful status-code families.
  4. Send Fetch requests with headers and bodies and handle non-2xx responses.
  5. Parse and serialize JSON without confusing valid values and failures.
  6. Cancel obsolete work and implement bounded exponential-backoff retries.

Why Asynchronous JavaScript Exists

JavaScript in a page normally runs on an event loop. A long synchronous function occupies the main thread and prevents input, rendering, and other JavaScript callbacks from running. Network requests are handled by browser facilities; JavaScript starts the operation, continues doing other work, and receives a result later. This model is asynchronous, but it does not imply that all JavaScript runs in parallel.

A synchronous function returns a value before the next statement executes. An asynchronous operation needs a way to deliver a result in the future. The callback style was one of the original approaches:

js
function loadProfile(callback) {
  setTimeout(() => {
    callback(null, { name: "Mina" });
  }, 100);
}

loadProfile((error, profile) => {
  if (error) {
    console.error(error);
    return;
  }
  console.log(profile.name);
});

Callbacks work well for a single event, but nested sequences can become difficult to follow. Each layer must decide how errors are passed, callbacks may accidentally run more than once, and combining independent operations can require extra coordination. A callback passed to an API can also be invoked synchronously or asynchronously depending on that API's contract, making control flow harder to reason about.

From Callbacks to Promises

A promise represents the eventual outcome of one operation. It begins pending and settles exactly once, either fulfilled with a value or rejected with a reason. A settled promise does not change state again. then() registers fulfillment work and returns a new promise, which allows operations to be chained. catch() handles a rejection; finally() runs after either outcome and normally preserves the outcome unless its callback throws or returns a rejected promise.

js
function loadProfile() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const profile = { name: "Mina" };
      if (profile) resolve(profile);
      else reject(new Error("Profile was not found"));
    }, 100);
  });
}

loadProfile()
  .then((profile) => profile.name)
  .then((name) => console.log(`Hello, ${name}`))
  .catch((error) => console.error("Could not load profile:", error))
  .finally(() => console.log("Request finished."));

The value returned by a then() callback becomes the next promise's fulfillment value. If the callback returns another promise, the chain adopts its eventual state. If it throws, the next promise rejects. These rules let errors travel to a nearby catch() instead of requiring an error callback at every step.

Avoid the "floating promise" pattern: start asynchronous work without returning or handling its promise. A failure can then become an unhandled rejection, and a caller cannot know when the work has finished.

js
function saveAndRefresh(data) {
  return saveData(data).then(() => refreshView());
}

Return the promise from reusable functions. At the application boundary, handle the final error and report a useful message to the user. Logging alone is not a user-facing recovery strategy.

async and await

An async function always returns a promise. Returning a plain value fulfills that promise; throwing rejects it. Within an async function, await pauses that function's continuation until the awaited promise settles, without blocking the browser's main thread:

js
async function greet() {
  const profile = await loadProfile();
  return `Hello, ${profile.name}`;
}

greet()
  .then(console.log)
  .catch((error) => console.error(error));

Use try/catch to handle errors around awaited work:

js
async function showProfile() {
  try {
    const profile = await loadProfile();
    renderProfile(profile);
  } catch (error) {
    showError("Your profile could not be loaded.");
    reportError(error);
  }
}

await does not make independent work parallel. In this example, the second request waits for the first:

js
const account = await getAccount();
const preferences = await getPreferences();

If the operations are independent, start both and await them together:

js
const [account, preferences] = await Promise.all([
  getAccount(),
  getPreferences(),
]);

Choose sequential awaits when the second operation needs the first result, or when rate limits and resource constraints make parallel requests undesirable. Parallelize only work that is truly independent and safe to run concurrently.

Promise Combinators

Promise combinators coordinate multiple promises. They do not automatically cancel promises that are no longer useful; cancellation requires an API that supports it, such as Fetch with an AbortSignal.

Promise.all(): all results are required

Promise.all() fulfills with values in input order when all promises fulfill. It rejects as soon as one rejects. Use it when every result is required:

js
const [account, projects] = await Promise.all([
  getAccount(),
  getProjects(),
]);

The returned promise's rejection does not stop the other operations. If cancellation matters, give them a shared controller or cancel outstanding work explicitly.

Promise.allSettled(): inspect every outcome

Promise.allSettled() waits for every input and returns records whose status is "fulfilled" or "rejected". It is useful when partial success should be displayed:

js
const results = await Promise.allSettled([
  loadRecommendations(),
  loadRecentArticles(),
  loadAnnouncements(),
]);

for (const result of results) {
  if (result.status === "fulfilled") {
    displaySection(result.value);
  } else {
    console.warn("Optional section failed:", result.reason);
  }
}

Use partial outcomes intentionally; a page should not quietly present incomplete information as if it were complete.

Promise.race(): first settlement wins

Promise.race() settles with the first input to settle, whether that input fulfills or rejects. It does not cancel the other work. A timeout race can stop waiting, but the underlying request may continue unless it is separately aborted:

js
function timeoutAfter(ms) {
  return new Promise((_, reject) => {
    setTimeout(() => reject(new Error("Timed out")), ms);
  });
}

const result = await Promise.race([somePromise, timeoutAfter(5000)]);

For Fetch, AbortSignal.timeout() is often a better fit where supported; otherwise combine a timer with AbortController and clear that timer when the request completes.

Promise.any(): first fulfillment wins

Promise.any() fulfills as soon as one input fulfills, ignoring earlier rejections. If every input rejects, it rejects with an AggregateError containing the errors. It is useful for alternative mirrors or equivalent sources, not for requests whose outcomes all need to be retained:

js
try {
  const result = await Promise.any([
    fetchFromPrimary().then(readJson),
    fetchFromMirror().then(readJson),
  ]);
  renderResult(result);
} catch (error) {
  if (error instanceof AggregateError) {
    showError("All data sources were unavailable.");
  }
}

Use this only when either successful source is interchangeable. The unsuccessful requests are not automatically canceled.

REST and HTTP Fundamentals

REST (Representational State Transfer) is an architectural style for networked systems, not a special JavaScript protocol. A REST-style API commonly exposes resources at URLs and uses HTTP methods to describe operations. For a task service, a resource collection might be /api/tasks, while one task is /api/tasks/42.

Method Common intent Typical property
GET Read a resource or collection Safe; repeated requests should not change server state
POST Create under a collection or perform an action Usually not idempotent; repeated submission may create duplicates
PUT Replace a resource at a known URL Intended to be idempotent
PATCH Apply a partial update Semantics depend on the API
DELETE Remove a resource Intended to be idempotent in effect

An API's documentation is authoritative. Some services use action endpoints or nonstandard meanings. Do not infer the request contract solely from the URL.

HTTP responses include a status code, headers, and possibly a body. Common status-code groups are:

  • 2xx: success. 200 OK often includes a representation, 201 Created indicates creation, and 204 No Content has no response body.
  • 3xx: redirection or cache validation.
  • 4xx: a problem with the request or the caller's authorization, such as 400 Bad Request, 401 Unauthorized (authentication required or invalid), 403 Forbidden (not permitted), 404 Not Found, 409 Conflict, 422 Unprocessable Content, or 429 Too Many Requests.
  • 5xx: server-side failure, such as 500 Internal Server Error or 503 Service Unavailable.

An HTTP status is not the same as a network failure. A 404 is a completed HTTP exchange; a failed connection, DNS issue, or browser-blocked cross-origin request may reject Fetch without exposing a response. APIs may also encode application-level failures in a successful HTTP response, so inspect the documented body format too.

Fetch Requests and Non-OK Responses

fetch() returns a promise that usually fulfills with a Response once response headers arrive. It rejects for network-level failures, but not merely because the status is 404 or 500. Check response.ok or the status explicitly:

js
async function getTask(taskId, { signal } = {}) {
  const response = await fetch(`/api/tasks/${encodeURIComponent(taskId)}`, {
    method: "GET",
    headers: {
      Accept: "application/json",
    },
    signal,
  });

  if (!response.ok) {
    throw new Error(`Task request failed with HTTP ${response.status}`);
  }

  return response.json();
}

encodeURIComponent() is appropriate for a path segment such as an ID, though IDs should still follow the API's expected format. Do not encode an entire URL with it.

For a JSON request, serialize the object and set the content type:

js
async function createTask(task, { signal } = {}) {
  const response = await fetch("/api/tasks", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
    },
    body: JSON.stringify(task),
    signal,
  });

  if (!response.ok) {
    throw new Error(`Could not create task (HTTP ${response.status})`);
  }

  if (response.status === 204) return null;
  return response.json();
}

Do not assume every successful response contains JSON. 204 No Content explicitly has no body; calling response.json() on it throws. A response can also use a non-JSON media type or contain malformed JSON. A robust client should follow the API contract and produce an understandable error.

Headers carry metadata, not arbitrary application data. Use Accept to indicate desired response formats and Content-Type to describe the request body. For cross-origin requests, custom headers and certain methods can trigger a CORS preflight. CORS is enforced by the browser and must be configured by the server; adding mode: "no-cors" does not grant access to a readable response.

For cookie-based authentication on same-origin requests, Fetch sends same-origin credentials by default. For cross-origin cookie credentials, an application may need credentials: "include" and a server configured with a specific allowed origin and credential support. Never put secrets in a URL, and do not hard-code private API credentials into client JavaScript.

Safe JSON Parsing and Serialization

JSON is a text format, not JavaScript object syntax. JSON.parse() can throw a SyntaxError; JSON.stringify() can fail for cyclic structures and can return undefined for some top-level values such as a function. It also omits unsupported object properties such as undefined and converts some values, including dates, through serialization rules.

Use a small parsing boundary when the source may be malformed:

js
function parseJson(text) {
  try {
    return { ok: true, value: JSON.parse(text) };
  } catch (error) {
    return { ok: false, error };
  }
}

const parsed = parseJson('{"theme":"dark"}');
if (parsed.ok) {
  console.log(parsed.value.theme);
} else {
  console.error("Invalid JSON", parsed.error);
}

For Fetch, catch parse errors separately from HTTP failures if the user needs different recovery guidance. Validate the parsed shape as well: valid JSON can still be the wrong data type or omit required fields. TypeScript types alone do not validate data arriving at runtime.

js
function isTask(value) {
  return (
    typeof value === "object" &&
    value !== null &&
    typeof value.id === "string" &&
    typeof value.title === "string"
  );
}

Serialize deliberately and handle failures at the boundary:

js
function stringifyJson(value) {
  try {
    const text = JSON.stringify(value);
    if (typeof text !== "string") {
      return { ok: false, error: new Error("Value is not JSON-serializable") };
    }
    return { ok: true, text };
  } catch (error) {
    return { ok: false, error };
  }
}

Do not treat JSON as a safe executable format, but do remember that parsed strings and values are still untrusted input. Validate them and render text with textContent rather than inserting them as HTML.

Cancellation with AbortController

An AbortController provides a signal that APIs such as Fetch can observe. Calling abort() rejects a pending Fetch with an abort-related error and stops waiting for the response body:

js
const controller = new AbortController();

try {
  const response = await fetch("/api/search?q=grid", {
    signal: controller.signal,
  });
  // Process response...
} catch (error) {
  if (error.name === "AbortError") {
    console.log("Search was superseded.");
  } else {
    throw error;
  }
}

// For example, when a new query replaces the old one:
controller.abort();

In real code, keep the controller in the scope that owns the request. For search-as-you-type, abort the previous request before starting the next, so a slow old response does not overwrite newer results:

js
let activeSearch;

async function search(query) {
  activeSearch?.abort();
  const controller = new AbortController();
  activeSearch = controller;

  try {
    const response = await fetch(
      `/api/search?q=${encodeURIComponent(query)}`,
      { signal: controller.signal },
    );
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const results = await response.json();

    if (activeSearch === controller) renderResults(results);
  } catch (error) {
    if (error.name !== "AbortError") showError("Search failed.");
  }
}

Aborting a request does not guarantee that the server never received or processed it. For operations that change server state, design for uncertain outcomes and avoid retrying non-idempotent operations without an idempotency strategy.

Exponential Backoff and Safe Retries

Transient failures can sometimes be retried. A basic exponential backoff waits longer after each failed attempt, often with jitter to avoid many clients retrying simultaneously. Retries must be bounded, should respect cancellation, and should apply only to failures where repeating the operation is safe.

js
function wait(ms, signal) {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) {
      reject(new DOMException("Aborted", "AbortError"));
      return;
    }

    let timer;
    const onAbort = () => {
      clearTimeout(timer);
      reject(new DOMException("Aborted", "AbortError"));
    };

    timer = setTimeout(() => {
      signal?.removeEventListener("abort", onAbort);
      resolve();
    }, ms);
    signal?.addEventListener("abort", onAbort, { once: true });
  });
}

async function fetchWithRetry(url, {
  attempts = 3,
  baseDelay = 300,
  signal,
} = {}) {
  let lastError;

  for (let attempt = 0; attempt < attempts; attempt += 1) {
    try {
      const response = await fetch(url, { signal });
      if (!response.ok) {
        const retryable = response.status === 429 || response.status >= 500;
        const error = new Error(`HTTP ${response.status}`);
        error.retryable = retryable;
        throw error;
      }
      return response;
    } catch (error) {
      if (error.name === "AbortError") throw error;
      if (error.retryable === false) throw error;
      lastError = error;
      const finalAttempt = attempt === attempts - 1;
      if (finalAttempt) break;

      const exponential = baseDelay * 2 ** attempt;
      const jitter = Math.random() * baseDelay;
      await wait(exponential + jitter, signal);
    }
  }

  throw lastError ?? new Error("Request failed");
}

This example illustrates a policy; production code should distinguish a network failure from an HTTP response and honor a server's Retry-After header for 429 or 503 where appropriate. Keep a strict maximum attempt count and total time budget. The wait() helper removes its abort listener when the timer resolves, and clears its timer when cancellation occurs.

Most importantly, do not blindly retry every failure. A 400 or 401 will not usually improve by repeating the same request. Retrying a POST can create duplicates unless the server supports an idempotency key or the operation has another safe deduplication guarantee. GET and other safe/idempotent operations are generally better retry candidates, but still need limits to protect both user experience and the service.

Debugging and Best Practices

  • In DevTools Network, inspect request URL, method, request headers, payload, status, response headers, timing, and body. A Fetch promise resolving only means an HTTP response arrived, not that the status is successful.
  • Check Console errors for rejected promises and parsing failures. Give every promise chain an owner that returns or handles it.
  • Distinguish network/CORS errors from HTTP status codes. A browser may intentionally hide cross-origin response details when CORS configuration is wrong.
  • Log useful context such as request ID, endpoint, and status, but never log access tokens, passwords, or personal response data.
  • Validate response shape at runtime, especially when consuming external APIs.
  • Do not concatenate untrusted values into query strings. Use URL and URLSearchParams for query parameters.
  • Set timeouts or cancellation where appropriate, and abort obsolete work. Do not interpret cancellation as proof that server-side processing was undone.
  • Use Promise.all() only when all results are required, allSettled() for intentional partial outcomes, race() for first settlement, and any() for the first successful alternative.
  • Retry only transient failures, with backoff, jitter, a maximum, and an operation known to be safe to repeat.
  • Keep secrets on a server. Any value shipped in browser source or accessible to JavaScript is visible to the user.

Hands-on Exercise 1: Load a Resource Safely

Create a function that fetches a task as JSON, reports HTTP errors distinctly, handles an empty 204 response, and validates the returned task shape.

Steps

  1. Call Fetch with Accept: application/json.
  2. Check response.ok before parsing.
  3. Return null for 204 No Content.
  4. Parse the JSON inside a try block and report malformed JSON as a parse error.
  5. Validate that the result has a string id and title.
  6. Catch errors at the UI boundary and show an actionable, nontechnical message.

Complete solution

js
class HttpError extends Error {
  constructor(status) {
    super(`Request failed with HTTP ${status}`);
    this.name = "HttpError";
    this.status = status;
  }
}

class InvalidDataError extends Error {
  constructor(message) {
    super(message);
    this.name = "InvalidDataError";
  }
}

async function fetchTask(id, { signal } = {}) {
  const response = await fetch(`/api/tasks/${encodeURIComponent(id)}`, {
    headers: { Accept: "application/json" },
    signal,
  });

  if (!response.ok) throw new HttpError(response.status);
  if (response.status === 204) return null;

  let value;
  try {
    value = await response.json();
  } catch (error) {
    throw new InvalidDataError(`Response was not valid JSON: ${error.message}`);
  }

  if (
    typeof value !== "object" ||
    value === null ||
    typeof value.id !== "string" ||
    typeof value.title !== "string"
  ) {
    throw new InvalidDataError("Response did not contain a valid task.");
  }
  return value;
}

async function showTask(id) {
  try {
    const task = await fetchTask(id);
    if (task) {
      document.querySelector("#task-title").textContent = task.title;
    }
  } catch (error) {
    if (error.name === "AbortError") return;
    document.querySelector("#task-error").textContent =
      error instanceof HttpError && error.status === 404
        ? "That task could not be found."
        : "The task could not be loaded. Please try again.";
    console.error("Task load failed:", error);
  }
}

The message shown to users is stable and useful, while diagnostic logging keeps technical context for development. A real app may avoid logging data-bearing error details in production.

Hands-on Exercise 2: Add a Cancellable Search with a Retry Policy

Build a search function that aborts the previous request, encodes the query, retries temporary failures up to three attempts, and does not retry a 400 response.

Steps

  1. Create and retain an AbortController for the active search.
  2. Abort the previous controller before a new search begins.
  3. Encode the query using URLSearchParams.
  4. Retry network failures, 429, and 5xx responses with bounded exponential delay.
  5. Throw immediately for other 4xx statuses and propagate aborts.
  6. Only display results if the response still belongs to the active request.

Complete solution

js
let activeController;

function delay(ms, signal) {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) {
      reject(new DOMException("Aborted", "AbortError"));
      return;
    }

    const onAbort = () => {
      clearTimeout(timer);
      reject(new DOMException("Aborted", "AbortError"));
    };
    const timer = setTimeout(() => {
      signal?.removeEventListener("abort", onAbort);
      resolve();
    }, ms);
    signal?.addEventListener("abort", onAbort, { once: true });
  });
}

async function requestSearch(url, signal) {
  let lastError;

  for (let attempt = 0; attempt < 3; attempt += 1) {
    try {
      const response = await fetch(url, {
        headers: { Accept: "application/json" },
        signal,
      });
      if (!response.ok) {
        const error = new Error(`HTTP ${response.status}`);
        error.status = response.status;
        error.retryable = response.status === 429 || response.status >= 500;
        throw error;
      }
      return await response.json();
    } catch (error) {
      if (error.name === "AbortError") throw error;
      if (error.retryable === false) throw error;
      lastError = error;
      if (attempt === 2) break;
      await delay(250 * 2 ** attempt + Math.random() * 150, signal);
    }
  }
  throw lastError;
}

async function runSearch(query) {
  activeController?.abort();
  const controller = new AbortController();
  activeController = controller;

  const parameters = new URLSearchParams({ q: query });
  try {
    const results = await requestSearch(
      `/api/search?${parameters.toString()}`,
      controller.signal,
    );
    if (activeController === controller) {
      renderResults(results);
    }
  } catch (error) {
    if (error.name !== "AbortError") {
      showSearchError("Search is temporarily unavailable.");
    }
  }
}

The identity check prevents stale results from being rendered even if an operation cannot be canceled promptly. A production retry policy should consider Retry-After and should cap total elapsed time. This search uses GET, which is an appropriate read operation; do not apply the same retry policy to a state-changing request without an idempotency guarantee.

Chapter Summary

Callbacks deliver future results but can become hard to compose. Promises provide a single-settlement contract, and async/await makes promise control flow readable. Promise combinators express different policies rather than interchangeable syntax. REST-style APIs use HTTP methods and status codes to communicate resource operations and outcomes. Fetch must check response.ok, set appropriate headers, and handle body shape and parsing separately. Abort obsolete requests, keep errors useful, and make retries bounded, cancellable, and safe for the operation being repeated.