FullStack Course LogoFullStack Course
Module: JavaScript
JavaScript·082·10 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 of this lesson, you should be able to:

  • 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

Answer these from memory before continuing:

  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

These terms are the ones you will use when reading browser documentation and debugging requests:

  • 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

When debugging a fetch, separate three questions. Keeping these layers distinct is more useful than treating the entire operation as one success-or-failure event:

  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 is where people commonly get confused: one catch does not, by itself, mean that every non-200 status was detected. HTTP status handling and body parsing are application decisions made after Fetch has produced a response.

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

The body behind a response is a stream, and normal body readers consume it once. If you genuinely need both JSON and text, clone the response before the first read; calling json() and then text() on the same response is not the normal usage pattern.

Beginner self-study example: load and render todos

Create an HTML file containing this markup and script. JSONPlaceholder is a public testing service, not a production dependency, so the example does not assume that it will always be reachable. The fallback keeps the exercise useful offline and when the 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 loads while the request is in progress, and the live status paragraph exposes progress to assistive technology.
  2. fetchJson awaits the Fetch Promise. The value returned at that point is a Response object, not the decoded JSON payload.
  3. !response.ok turns HTTP statuses outside 200–299 into application-level thrown errors. The status is still available for logging or a more 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. As a result, Application/JSON; charset=utf-8 passes, while text/application/json does not. The optional flag deliberately broadens the contract to application/*+json, such as application/problem+json; the todo endpoint uses the strict default.
  5. response.json() consumes and parses the body. Parsing does not validate the resulting object’s shape, which is why the array check is still necessary.
  6. textContent inserts titles as text. It does not interpret a malicious title as HTML.
  7. Network, HTTP, content-type, parse, and shape failures all lead to deterministic mock data and an honest status message.

Expected output

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

Request and response details

GET is Fetch’s default method. Put query data in the URL, and use the URL APIs to encode it safely rather than assembling query strings by hand:

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

When sending JSON, serialize the value and identify the representation with a request header:

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 the response media types the client would like to receive. JSONPlaceholder simulates writes, so it does not permanently store this POST.

Intermediate example: cancellation and stale UI

Repeated loads and changing search terms create a practical race: an older request may waste work or finish after a newer request, then overwrite the current UI. Abort obsolete work, and guard UI updates with the identity of the request that owns them:

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 the controller’s cancellation state to Fetch. Calling abort() causes the request, or body consumption, to reject, conventionally with an AbortError. Aborting is an expected control path, so it should not be presented to users as a failure. The identity check before each UI mutation prevents an older invocation from overwriting the newer invocation’s status. The check in finally is also necessary: an older call must not clear the controller belonging to a newer call.

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

Optional advanced stable example: test without a network

Because fetchJson accepts ordinary Fetch inputs, larger applications can inject a data URL or a mock fetch. A simple, fully local Response test avoids making the behavior depend on the network:

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);

The expected output is { message: "Local success" }. This helper explicitly passes true, so its contract also accepts an application media type ending in +json; remove that argument when only exact application/json is valid. To test HTTP handling deterministically, construct a 404 response with new Response(JSON.stringify(...), { status: 404, headers: { "Content-Type": "application/json" } }).

Deep Dive: Fetch versus XMLHttpRequest

For new browser JavaScript, fetch is the modern default for HTTP requests:

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. You should be able to read it well enough to maintain it:

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 for maintenance work, but prefer fetch for new code unless a particular environment or feature requirement says otherwise.

Fetch does not reject for every HTTP error

A 404 or 500 normally resolves the Fetch Promise. Network failures reject it. That distinction is why status checking belongs in the request abstraction rather than being left to every caller to rediscover.

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.

When debugging, first identify which boundary failed: producing an accessible response, receiving an HTTP status, or interpreting the body. The Network panel and the raw response are often more informative than the final exception message.

Security and performance

Use HTTPS. Never embed privileged secret API keys in browser code because 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 following snippets are fragments for an async function or a browser-console session. Before running them, define url, isJsonMediaType, fetchJson, list, and posts as indicated by the earlier examples. The solutions are intentionally not standalone programs.

Level 1: status check

Complete the missing status and media-type guards. This endpoint accepts exact application/json, case-insensitively, with optional parameters, but it 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. The point is to preserve the data-as-text boundary even when the API value is untrusted.

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(). An abort should be handled silently; other failures should be reported and remain errors for the caller.

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 a Promise that directly contains application data. Rejection covers failures to produce an accessible response; HTTP errors require an explicit ok or status check. When the contract requires it, verify the representation metadata, await one body reader, validate the parsed data, render it as text, and cancel obsolete work with AbortController.

Official references

Same-origin policy, CORS, and preflight

An origin consists of the scheme, host, and port. The same-origin policy stops a script from freely reading responses belonging to another origin. CORS is a server opt-in: response headers tell the browser that a script may read a cross-origin response. It is neither a frontend switch nor an authentication mechanism.

Some simple cross-origin requests are sent without a preflight, but the browser still checks the response’s CORS headers before exposing that response 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. For cookies, it must also return Access-Control-Allow-Credentials: true and a specific allowed origin rather than *. The browser may send a request and still expose an opaque or blocked result. Server-to-server HTTP clients are not constrained by browser CORS in the same way.

Test this with two local ports and inspect the Network panel for OPTIONS. Distinguish among a server-returned CORS denial, a network failure, and an API that returned an HTTP error. Do not try to fix the problem 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. Registration requires a secure context, such as HTTPS, with localhost treated specially; the worker’s scope and lifecycle determine 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 the first load, a 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.
Reader page: /javascript/lesson/082/http-in-javascript-fetch-abortcontroller-cors-and-xhr