Module: JavaScript
JavaScript·082·9 MIN READ

082: HTTP in JavaScript: Fetch, AbortController, CORS, and XHR

TOPICS COVERED: HTTP in JavaScript: Fetch, AbortController, CORS, and XHR

Learning outcomes

By the end, you can:

  • make a GET request with fetch and inspect its Response;
  • distinguish a rejected fetch from an HTTP error response;
  • check response.ok and response.status before reading data;
  • verify JSON content type and understand that response.json() is asynchronous;
  • render untrusted API text safely and provide loading, empty, error, and fallback states;
  • cancel obsolete requests with AbortController.

Retrieval warm-up

  1. What does every async function return?
  2. When should independent Promise-returning operations use Promise.all?
  3. If an awaited Promise rejects, where does control move inside a matching try?

Vocabulary

  • Fetch: Promise-based web platform API issuing HTTP requests and resolving with Response objects. — Source: MDN: Using the Fetch API
  • Request: Client message composed of method, URL, headers, optional body, and options like signal. — Source: WHATWG Fetch: Requests
  • Response: Server reply object exposing status, headers, and a one-use body stream. — Source: WHATWG Fetch: Responses
  • HTTP status: Three-digit result code summarizing the response outcome (200, 404, 500…). — Source: MDN: HTTP response status codes
  • ok: Boolean true only when status is 200–299. — Source: MDN: Response.ok
  • Header: Case-insensitive key/value metadata carried by requests and responses. — Source: MDN: Headers
  • Body: Payload stream consumed once via json(), text(), or formData(). — Source: MDN: Using the Fetch API — Body
  • JSON: Text-based data-interchange format parsed via response.json(). — Source: RFC 8259
  • CORS: Cross-Origin Resource Sharing governing which cross-origin responses scripts may read. — Source: MDN: CORS
  • Abort signal: AbortController’s signal wiring cancellation into fetch calls. — Source: MDN: AbortSignal
  • CORS (official): "Cross-Origin Resource Sharing is a mechanism that allows restricted resources to be requested from another origin." — Source: MDN: CORS
  • AbortSignal (official): "AbortSignal is an object that can be used to abort a DOM request." — Source: MDN: AbortSignal

Mental model: transport, HTTP, representation

Treat a fetch as three checks:

  1. Did Fetch produce an accessible response? fetch() can reject for a malformed URL, unsupported scheme, network failure, CORS blocking, or abort.
  2. What did HTTP report? A server response such as 404 Not Found or 500 Internal Server Error normally fulfills the Fetch Promise with a Response. Check ok or status yourself.
  3. Can the representation be interpreted? The response may claim JSON but contain invalid text, or return HTML unexpectedly. Reading with response.json() returns another Promise and can reject.

This prevents the common but wrong assumption that one catch automatically means every non-200 status was detected.

Self-check: in DevTools, compare one valid endpoint with a JSONPlaceholder URL containing a missing resource ID. Record whether Fetch fulfilled, the values of status and ok, and whether body parsing succeeded. If the network is unreliable, construct local Response objects with status 200 and 404 instead — the comparison remains deterministic.

js
const response = await fetch(url); // Resolves once status and headers arrive.
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json(); // Reads and parses the body asynchronously.

A response body is a stream and is normally consumed once. Do not call json() and then text() on the same response unless you cloned it before reading.

Beginner self-study example: load and render todos

Create an HTML file with this markup and script. JSONPlaceholder is a public testing service, not a production dependency. The fallback keeps the lesson useful offline or during service changes.

html
<button id="load" type="button">Load todos</button>
<p id="status" aria-live="polite">Not loaded</p>
<ul id="todos"></ul>

<script>
  const loadButton = document.querySelector("#load");
  const status = document.querySelector("#status");
  const list = document.querySelector("#todos");

  const fallbackTodos = [
    { id: "local-1", title: "Review fetch states", completed: false },
    { id: "local-2", title: "Check response.ok", completed: true },
  ];

  function renderTodos(todos) {
    list.replaceChildren();

    for (const todo of todos) {
      const item = document.createElement("li");
      item.textContent = `${todo.completed ? "Done" : "Open"}: ${todo.title}`;
      list.append(item);
    }
  }

  function isJsonMediaType(value, allowStructuredSuffix = false) {
    if (typeof value !== "string") return false;

    const mediaType = value.split(";", 1)[0].trim().toLowerCase();
    const subtype = mediaType.startsWith("application/")
      ? mediaType.slice("application/".length)
      : "";
    return mediaType === "application/json" ||
      (allowStructuredSuffix &&
        subtype.length > "+json".length &&
        subtype.endsWith("+json"));
  }

  async function fetchJson(url, options = {}) {
    const response = await fetch(url, options);

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

    const contentType = response.headers.get("content-type");
    if (!isJsonMediaType(contentType)) {
      throw new TypeError("Expected a JSON response");
    }

    return response.json();
  }

  async function loadTodos() {
    loadButton.disabled = true;
    status.textContent = "Loading...";

    try {
      const todos = await fetchJson(
        "https://jsonplaceholder.typicode.com/todos?_limit=5",
      );

      if (!Array.isArray(todos)) {
        throw new TypeError("Expected a todo array");
      }

      renderTodos(todos);
      status.textContent = todos.length ? `Loaded ${todos.length} todos` : "No todos";
    } catch (error) {
      console.error("Todo request failed", error);
      renderTodos(fallbackTodos);
      status.textContent = "Network data unavailable; showing sample data";
    } finally {
      loadButton.disabled = false;
    }
  }

  loadButton.addEventListener("click", loadTodos);
</script>

Step-by-step explanation

  1. The click handler disables duplicate loading and exposes progress to assistive technology.
  2. fetchJson awaits the Fetch Promise. A Response object is not the JSON itself.
  3. !response.ok converts HTTP statuses outside 200-299 into application-level thrown errors. The status remains available for logging or specialized UI.
  4. Header names and media types are case-insensitive. isJsonMediaType takes only the value before the first semicolon, trims it, and compares that complete media type with application/json; therefore Application/JSON; charset=utf-8 passes but text/application/json does not. Its optional flag explicitly broadens a contract to application/*+json, such as application/problem+json. The todo endpoint contract uses the strict default.
  5. response.json() consumes and parses the body. It does not validate the resulting object shape, so the array check remains necessary.
  6. textContent treats titles as text. It does not interpret malicious titles as HTML.
  7. Any network, HTTP, content-type, parse, or shape failure shows deterministic mock data and an honest status message.

Expected output

With network access, five todo lines and Loaded 5 todos appear. Without it, two sample lines and Network data unavailable; showing sample data appear. Exact remote titles are outside the lesson's control.

Request and response details

GET is Fetch's default method. Query data belongs in the URL, preferably encoded safely:

js
const url = new URL("https://jsonplaceholder.typicode.com/posts");
url.search = new URLSearchParams({ userId: "1" });
const response = await fetch(url);

For JSON sent in a request, serialize it and label the representation:

js
const response = await fetch("https://jsonplaceholder.typicode.com/posts", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Accept: "application/json",
  },
  body: JSON.stringify({ title: "Practice", body: "Fetch", userId: 1 }),
});

Do not send a body with GET. Content-Type describes the body being sent; Accept describes desired response media types. JSONPlaceholder simulates writes and does not permanently store them.

Intermediate example: cancellation and stale UI

When users load repeatedly or change a search, the old request may be wasted and may update the UI after a newer request. Abort it:

js
let activeController;

async function loadUser(userId) {
  activeController?.abort();
  activeController = new AbortController();
  const controller = activeController;

  try {
    if (activeController !== controller) return;
    status.textContent = `Loading user ${userId}...`;
    const user = await fetchJson(
      `https://jsonplaceholder.typicode.com/users/${encodeURIComponent(userId)}`,
      { signal: controller.signal },
    );
    if (activeController !== controller) return;
    status.textContent = `${user.name} (${user.email})`;
  } catch (error) {
    if (error.name === "AbortError") return;
    console.error("User request failed", error);
    if (activeController !== controller) return;
    status.textContent = "Could not load user";
  } finally {
    if (activeController === controller) activeController = undefined;
  }
}

controller.signal connects cancellation to Fetch. abort() causes the request or body consumption to reject, conventionally with AbortError. Aborting is an expected control path, not an error to show users. Check the controller identity before every UI mutation so an older call cannot overwrite a newer call's status. The identity check in finally also prevents an older call from clearing the newer controller.

For a fixed time budget, current web platforms also define AbortSignal.timeout(ms), but a manually owned controller is clearer when a user action or component lifecycle should cancel work.

Optional advanced stable example: test without a network

Because fetchJson accepts normal Fetch inputs, inject a data URL or a mock fetch in larger applications. A simple fully local Response test is:

js
async function readJsonResponse(response) {
  if (!response.ok) throw new Error(`HTTP ${response.status}`);

  const type = response.headers.get("content-type");
  if (!isJsonMediaType(type, true)) {
    throw new TypeError("Expected JSON");
  }
  return response.json();
}

const mockResponse = Response.json({ message: "Local success" }, { status: 200 });
readJsonResponse(mockResponse).then(console.log);

Expected output is { message: "Local success" }. This local helper's explicit true contract also accepts an application media type ending in +json; remove it when only exact application/json is allowed. Change status to 404 by constructing new Response(JSON.stringify(...), { status: 404, headers: { "Content-Type": "application/json" } }) to test HTTP handling deterministically.

Deep Dive: Fetch versus XMLHttpRequest

fetch is the modern default for HTTP requests in browser JavaScript.

js
const response = await fetch("/api/products");

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

const products = await response.json();

The older XMLHttpRequest API still appears in legacy systems:

js
const request = new XMLHttpRequest();

request.open("GET", "/api/products");

request.addEventListener("load", () => {
  if (request.status >= 200 && request.status < 300) {
    console.log(JSON.parse(request.responseText));
  }
});

request.send();

Learn XHR so you can maintain older code, but prefer fetch for new work unless a specific environment or feature requirement dictates otherwise.

Fetch does not reject for every HTTP error

A 404 or 500 normally resolves the Fetch Promise. Network failures reject it. Therefore status checking belongs in your request abstraction.

Common mistakes and debugging

  • Using data before awaiting: both fetch() and body readers return Promises.
  • Skipping ok: a 404 is still usually a fulfilled fetch.
  • Reading twice: response body streams are one-use; clone before consumption only when genuinely required.
  • Treating CORS as a frontend bug: the target server must authorize browser sharing. mode: "no-cors" gives an opaque response and is not a general fix.
  • Blind JSON parsing: inspect the Network panel's status, response headers, and raw response.
  • Using innerHTML for API strings: create elements and assign textContent.
  • Creating a controller once forever: an aborted signal stays aborted; create a new controller per operation.
  • Hiding all failures behind fallback: log diagnostics safely and clearly tell users when data is sample data.

Security and performance

Use HTTPS. Never embed privileged secret API keys in browser code; users can inspect requests. Restrict credentialed cross-origin requests, understand CSRF protections, and do not use credentials: "include" casually. Validate response shapes and limit rendered data. Encode user-controlled path or query values with encodeURIComponent, URL, or URLSearchParams. Cancel obsolete requests, avoid duplicate loads, paginate large collections, respect 429 and Retry-After, and cache according to server policy. Do not automatically retry unsafe writes.

Exercises

The snippets below are fragments for an async function or browser-console session. Define url, isJsonMediaType, fetchJson, list, and posts as indicated by the earlier examples before running them; the solutions are not standalone programs.

Level 1: status check

Complete the missing status and media-type guards. The endpoint accepts exact application/json, case-insensitively, with optional parameters, but does not opt into +json types:

js
const response = await fetch(url);
// guards here
const data = await response.json();
js
if (!response.ok) {
  throw new Error(`HTTP ${response.status}`);
}
const contentType = response.headers.get("content-type");
if (!isJsonMediaType(contentType)) {
  throw new TypeError("Expected a JSON response");
}

Level 2: render safely

Render each post.title into a new li inside list without using HTML strings.

js
list.replaceChildren();
for (const post of posts) {
  const item = document.createElement("li");
  item.textContent = post.title;
  list.append(item);
}

Level 3: cancel

Write a fetch that can be canceled by calling controller.abort() and silently handles an abort while reporting other errors.

js
const controller = new AbortController();

async function load() {
  try {
    return await fetchJson(url, { signal: controller.signal });
  } catch (error) {
    if (error.name === "AbortError") return null;
    console.error(error);
    throw error;
  }
}

Recap

Fetch returns a Promise for a Response, not directly for data. Rejection covers failures to produce an accessible response; HTTP errors require ok or status checks. Verify representation metadata when the contract matters, await one body reader, validate parsed data, render it as text, and cancel obsolete work with AbortController.

Official references

Same-origin policy, CORS, and preflight

An origin is the scheme, host, and port. The same-origin policy prevents a script from freely reading another origin's responses. CORS is a server opt-in that adds response headers allowing a browser script to read a cross-origin response; it is not a frontend switch and it is not authentication.

Simple cross-origin requests may be sent without a preflight, but the browser still checks the response's CORS headers before exposing it to script. A request commonly triggers an OPTIONS preflight when it uses a non-simple method, non-safelisted request headers, or a non-safelisted content type such as application/json in many cross-origin cases:

http
OPTIONS /api/profile HTTP/1.1
Origin: https://app.example
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: authorization, content-type

The server must answer with an appropriate Access-Control-Allow-Origin, allowed methods, and allowed headers. With cookies, it must also return Access-Control-Allow-Credentials: true and a specific allowed origin, not *. The browser may send a request yet expose an opaque or blocked result; server-to-server HTTP clients are not constrained by browser CORS in the same way.

Test with two local ports, inspect the Network panel for OPTIONS, and distinguish: server returned a CORS denial, the network failed, or the API returned an HTTP error. Do not “fix” it with mode: "no-cors"; that produces an opaque response whose body JavaScript cannot read.

Service worker boundary

A service worker is a separately controlled worker that can intercept requests for its scope, cache responses, and support offline behavior. It does not automatically make an API response fresh or safe. It must be registered from a secure context (HTTPS, with localhost treated specially), and the worker's scope and lifecycle affect which pages it controls.

js
if ("serviceWorker" in navigator) {
  const registration = await navigator.serviceWorker.register("/sw.js");
  console.log("registered", registration.scope);
}

sw.js:

js
self.addEventListener("install", (event) => {
  event.waitUntil(caches.open("demo-v1").then((cache) =>
    cache.addAll(["/", "/offline.html"]),
  ));
});

self.addEventListener("fetch", (event) => {
  if (new URL(event.request.url).origin !== self.location.origin) return;
  event.respondWith(
    fetch(event.request).catch(() => caches.match("/offline.html")),
  );
});

This is a minimal demonstration, not a production cache policy. Test first load, reload after registration, offline navigation, cache version changes, and a worker update. Never cache personalized responses without considering credentials, invalidation, privacy, and cache poisoning.

Interview questions

  1. Who fixes a CORS failure? The server or a trusted proxy must emit the correct policy; browser JavaScript cannot grant itself access.
  2. Does CORS prevent a server from receiving a request? Not necessarily. It primarily controls whether browser script can read the response.
  3. Why might an OPTIONS request appear before a PATCH? The browser is checking whether the cross-origin method and headers are permitted.
  4. Does a service worker run on the main thread? No, it runs in its own worker context and communicates through messaging/events; it has no direct DOM access.