Lesson 23 of 30

Fetch API

What fetch Does

fetch makes an HTTP request from JavaScript without reloading the page. It is how a page talks to a server after it has loaded — to read a list of records, to save a form, to check whether a username is already taken. Everything that feels "live" on a modern website is built on it.

It replaced XMLHttpRequest, which you will still meet in older code and which did the same job with a great deal more ceremony. fetch is built into every modern browser and into Node 18 and later, so there is nothing to install and nothing to import.

The simplest call takes a URL and returns a promise. That promise fulfils with a Response object — not with your data. Getting the data is a second step, because the body arrives as a stream that has to be read, and reading it is itself asynchronous. That two-step shape is the first thing to internalise, and it is why almost every example you see has two awaits in it.

Everything from the previous two lessons applies unchanged. fetch returns an ordinary promise, so it works with .then, with await, with Promise.all and with try/catch. Nothing new is required beyond the specific behaviours described below — and those behaviours are where all the surprises live.

Example
// With await, inside an async function
async function loadStudents() {
  const response = await fetch('/api/students');
  const data = await response.json();     // the second step
  console.log(data);
}
loadStudents();

// The same thing written with .then
fetch('/api/students')
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(err => console.error(err));

// What the Response object itself carries
async function inspect() {
  const response = await fetch('/api/students');
  console.log(response.status);      // 200, 404, 500 ...
  console.log(response.ok);          // true only for 200-299
  console.log(response.headers.get('content-type'));
}
Notes
  • The body can be read as .json(), .text(), .blob() for binary data, or .formData(). Each returns a promise, and each consumes the stream — so you can only call one of them, once, per response.

The response.ok Trap

This is the single most important thing to know about fetch, and it catches nearly everybody the first time. A 404 or a 500 does not reject the promise. As far as fetch is concerned the request succeeded — the server was reached and it replied. The fact that the reply was an error is information contained in the response, not a failure of the request.

So a .catch or a try/catch wrapped around a fetch does not catch server errors at all. It catches only network-level failures: no connection, a DNS failure, a blocked or aborted request. Your code will carry on quite happily and attempt to read JSON out of a 404 page.

The fix is one line. Check response.ok, which is true for any status from 200 to 299, and throw your own error when it is false. From then on a single catch handles both kinds of failure and the rest of the function can safely assume success — which is exactly the guard-clause pattern from Lesson 7 applied to network code.

The second half of the trap is response.json() itself. It parses the body as JSON and rejects if the body is not valid JSON — which is precisely what happens when a server returns an HTML error page. The resulting message, Unexpected token '<', is one of the most-searched errors in web development, and it nearly always means you parsed an error page as JSON because the ok check was missing.

Example
// Wrong: a 404 sails straight through
async function broken() {
  try {
    const response = await fetch('/api/does-not-exist');
    const data = await response.json();   // throws "Unexpected token '<'"
    return data;
  } catch (err) {
    console.error(err.message);           // a confusing parse error
  }
}

// Right: check ok, and throw something that says what happened
async function loadStudents() {
  const response = await fetch('/api/students');

  if (!response.ok) {
    throw new Error(`Request failed: ${response.status} ${response.statusText}`);
  }

  return response.json();
}

loadStudents()
  .then(data => console.log(data))
  .catch(err => console.error(err.message));   // 'Request failed: 404 Not Found'

// The body can only be read once
async function twice() {
  const response = await fetch('/api/students');
  await response.json();
  // await response.json();   // TypeError: body stream already read
}
Notes
  • If you need the body twice — once to parse and once to log — call response.clone() before reading it. Cloning must happen before the first read, not after.

Sending Data

A second argument to fetch configures the request: the method, the headers, and the body. Anything other than a plain GET needs it, and the shape is the same every time.

For JSON there are two things you must do together, and forgetting either one is a classic bug. Set Content-Type: application/json in the headers, and convert your object to a string with JSON.stringify for the body. fetch will not serialise an object for you — pass one directly and it sends the text [object Object], after which the server reports a parse error that looks entirely like the server's own fault.

For file uploads use FormData, and in that case do not set Content-Type yourself. The browser has to set it, because the header must include a boundary marker that you have no way of generating. Setting it by hand is exactly why uploads arrive at the server with an empty body — a bug that costs people entire afternoons.

The methods follow ordinary HTTP conventions: GET to read, POST to create, PUT or PATCH to update, DELETE to remove. A GET request must not carry a body. And cookies are not sent on cross-origin requests unless you ask for them with credentials: 'include', and the server agrees.

Example
// POST with a JSON body
async function createStudent(student) {
  const response = await fetch('/api/students', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(student)      // stringify - fetch will not do it for you
  });

  if (!response.ok) {
    throw new Error(`Create failed: ${response.status}`);
  }
  return response.json();
}

createStudent({ name: 'Ananya', marks: 82 })
  .then(saved => console.log('created', saved))
  .catch(err => console.error(err.message));

// A whole form, including files: let the browser set Content-Type
async function upload(form) {
  const response = await fetch('/api/upload', {
    method: 'POST',
    body: new FormData(form)      // deliberately no headers object at all
  });
  if (!response.ok) throw new Error('Upload failed');
  return response.json();
}

// Deleting
async function removeStudent(id) {
  const response = await fetch(`/api/students/${id}`, { method: 'DELETE' });
  if (!response.ok) throw new Error('Delete failed');
}
Notes
  • A successful DELETE often replies 204 No Content, which has no body at all. Calling response.json() on it rejects, so check the status before parsing — the helper in the last section of this lesson does exactly that.

Timeouts and Cancelling with AbortController

fetch has no timeout. A request to a server that never answers stays pending indefinitely, and your loading spinner spins forever. That is not something you can configure away with an option — you have to add the timeout yourself, and a great many production pages are missing one.

AbortController is the mechanism. You create one, pass its signal into the fetch options, and call abort() when you want to stop. The fetch promise then rejects with an error whose name is 'AbortError', and you should treat that differently from a real failure — a request you cancelled on purpose is usually not something to show the user as an error message.

Aborting is not only for timeouts. It is what you use when a user types in a search box and a newer request should supersede an older one, and when they navigate away while a request is still running. Keeping the controller in a variable and aborting the previous request before starting a new one prevents the classic race where a slow earlier response arrives last and overwrites a newer, correct result on screen.

For failures genuinely worth retrying, a small loop with an increasing delay covers most needs. Retry only on network errors and on 5xx server responses. Retrying a 400 or a 404 simply wastes time and load, because the answer will be identical however many times you ask.

Example
// A timeout you add yourself
async function fetchWithTimeout(url, ms) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), ms);

  try {
    const response = await fetch(url, { signal: controller.signal });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return await response.json();
  } catch (err) {
    if (err.name === 'AbortError') {
      throw new Error(`Timed out after ${ms}ms`);
    }
    throw err;
  } finally {
    clearTimeout(timer);      // never leave the timer running
  }
}

// Cancel the previous search whenever a new one starts
let searchController = null;

async function search(query) {
  if (searchController) searchController.abort();
  searchController = new AbortController();

  try {
    const url = `/api/search?q=${encodeURIComponent(query)}`;
    const response = await fetch(url, { signal: searchController.signal });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return await response.json();
  } catch (err) {
    if (err.name === 'AbortError') return null;   // superseded, not failed
    throw err;
  }
}
Notes
  • Note the await in front of response.json() inside the try. Without it the function returns a promise and leaves the try block immediately, so a JSON parse failure would escape the catch entirely.

CORS, and Why Your Request Was Blocked

At some point you will see this in the console: Access to fetch at '...' has been blocked by CORS policy. It is worth understanding properly, because the first instinct — that you have made a mistake in your JavaScript — is wrong, and hours get lost acting on it.

Browsers enforce the same-origin policy. By default, a page may only make requests to the same origin it was loaded from, where an origin means the combination of protocol, host and port. That restriction is what stops a malicious page from quietly reading your bank's API using the session cookie already sitting in your browser.

Cross-Origin Resource Sharing is the server's way of granting an exception. The server sends an Access-Control-Allow-Origin header naming which origins are permitted. If it does not send one, the browser blocks the response before your code ever sees it. The crucial consequence is that CORS is fixed on the server, and nothing you write in the browser can work around it. Snippets that add that header to your request accomplish nothing whatsoever — it is a response header, and only the server can send it.

Two things follow. During development, the normal solution is a proxy in your dev server, or serving the page from the same origin as the API — not a browser extension that switches the check off, which only hides the problem until your users meet it. And a request that works in Postman or curl while failing in the browser is almost certainly CORS, because those tools are not browsers and do not enforce the policy at all.

Example
// Access-Control-Allow-Origin is a SERVER response header.
// Setting it on your request achieves nothing:
//   headers: { 'Access-Control-Allow-Origin': '*' }   <- pointless

// Same origin - always allowed
fetch('/api/students');

// Cross origin - allowed only if that server permits your origin
fetch('https://another-site.example/api/students')
  .then(r => r.json())
  .catch(err => {
    // A CORS block surfaces here as a generic network failure.
    // The real explanation is printed in the console, not in err.message.
    console.error('Request failed:', err.message);
  });

// Cookies are not sent cross-origin unless you ask and the server agrees
fetch('https://api.example.com/me', { credentials: 'include' });
Notes
  • For anything other than a simple GET or POST, the browser first sends an OPTIONS request — the preflight — to ask permission. If you see an OPTIONS request failing in the network tab and your real request never appearing, that is what you are looking at.

A Request Helper Worth Writing

Once you have written the ok check three times, move it into one function. A small wrapper is worth more than it looks: every call site inherits the error handling, and changing the base URL or adding an authentication header later happens in exactly one place instead of forty.

Keep it small. A helper that takes a path and some options, prefixes the base URL, sets the JSON headers, checks ok, and returns the parsed body already covers nearly everything you will do. Resist adding features to it until a real call site needs them — a request helper that grows into a framework is a well-trodden path to code nobody wants to touch.

Two details are worth including from the start. Build query strings with URLSearchParams rather than by joining strings, so that values containing spaces, ampersands or Hindi characters are encoded correctly. And handle the case where a successful response has no body, because a 204 No Content will otherwise make response.json() reject on what was a perfectly successful request.

One rule to finish with, and it is not optional: never put a secret API key in browser JavaScript. Anything your page can read, any user can read — the network tab shows every request in full, and the bundle is served to whoever asks for it. Keys belong on a server that makes the call on the page's behalf. This is the mistake that turns a student project into a leaked-credentials incident.

Example
const BASE_URL = '/api';

async function request(path, options = {}) {
  const response = await fetch(BASE_URL + path, options);

  if (!response.ok) {
    let detail = response.statusText;
    try {
      const body = await response.json();
      if (body && body.message) detail = body.message;
    } catch {
      // the error body was not JSON - keep statusText
    }
    throw new Error(`${response.status}: ${detail}`);
  }

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

function get(path, params) {
  const query = params ? '?' + new URLSearchParams(params) : '';
  return request(path + query);
}

function post(path, body) {
  return request(path, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body)
  });
}

// Every call site now gets the error handling for free
get('/students', { class: '12 A', term: '1' })
  .then(list => console.log(list.length))
  .catch(err => console.error(err.message));
  • fetch returns a promise for a Response, not for your data
  • Reading the body is a second await: .json(), .text() or .blob()
  • A 404 or 500 does NOT reject — check response.ok and throw yourself
  • catch on its own only catches network failures, never HTTP error statuses
  • JSON body: set Content-Type and call JSON.stringify
  • FormData body: set no Content-Type at all
  • The response body can only be read once
  • There is no built-in timeout — add one with AbortController
  • CORS is fixed on the server; nothing in the browser can bypass it
  • Never put a secret key in browser code
Notes
  • The network tab of the developer tools is the right place to debug any of this. It shows the exact URL, method, request headers, request body, status and response body — which answers most "why did that fail" questions before you add a single console.log.
Ask AI