The JavaScript Fetch API and Network Requests: Modern HTTP from the Browser

Link copied
The JavaScript Fetch API and Network Requests: Modern HTTP from the Browser

The JavaScript Fetch API and Network Requests: Modern HTTP from the Browser

fetch is JavaScript's modern way to make HTTP requests. It replaced XMLHttpRequest (the old XHR from the jQuery $.ajax era) and shipped a cleaner, promise-based API that works the same in browsers, Node, Deno, Bun, and edge runtimes. This lesson covers fetch end to end: making requests, handling responses, error patterns, JSON and FormData bodies, AbortController for cancellation, and the modern idioms for timeouts and retries.

The basics #

const res = await fetch('/api/users');
const users = await res.json();
console.log(users);

Three things to notice:

  1. fetch returns a Promiseawait works directly.
  2. The response is a Response object, not the data itself. You parse the body separately.
  3. The body parser (res.json()) is also async — parsing happens as the body streams in.

The Response object #

A few useful properties and methods:

res.ok          // true if status is 200–299
res.status      // 200, 404, 500, ...
res.statusText  // 'OK', 'Not Found', ...
res.headers     // Headers object — res.headers.get('content-type')
res.url         // final URL after redirects
res.redirected  // true if any redirect happened
res.type        // 'basic', 'cors', 'opaque', ...

Body parsers (each can only be called once per response):

res.json();         // parse as JSON
res.text();         // get as string
res.blob();         // get as a Blob (binary)
res.arrayBuffer();  // get as raw bytes
res.formData();     // parse multipart/form-data

The body is a stream — you can also read it manually with res.body.getReader() for advanced cases.

The big gotcha: fetch doesn't reject on HTTP errors #

The one thing that catches everyone the first time: fetch only rejects on network failures (DNS, offline, CORS). A 404 or 500 response is still a resolved promise.

const res = await fetch('/api/nonexistent');
console.log(res.ok);     // false
console.log(res.status); // 404
// No error thrown — you have to check res.ok yourself

The idiomatic fix:

async function fetchJson(url) {
  const res = await fetch(url);
  if (!res.ok) {
    throw new Error(`${res.status} ${res.statusText}: ${url}`);
  }
  return res.json();
}

Wrap fetch in a thin helper like this in every codebase. Skipping the res.ok check is the most common fetch bug in production.

Making non-GET requests #

Pass a second argument with options:

const res = await fetch('/api/users', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${token}`,
  },
  body: JSON.stringify({ name: 'Ada' }),
});

Key points:

  • method'POST', 'PUT', 'PATCH', 'DELETE', etc. Defaults to 'GET'.
  • headers — a plain object or a Headers instance.
  • body — string, FormData, Blob, URLSearchParams, ReadableStream, or ArrayBuffer.

JSON bodies #

fetch('/api/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(payload),
});

You must set Content-Type explicitly. fetch doesn't know to do it for plain string bodies.

FormData bodies #

fetch('/upload', {
  method: 'POST',
  body: formData,           // do NOT set Content-Type yourself
});

With FormData, fetch sets Content-Type: multipart/form-data; boundary=... for you. Setting it manually breaks the boundary.

URL-encoded bodies #

const params = new URLSearchParams({ q: 'JavaScript', page: 2 });
fetch('/api/search', {
  method: 'POST',
  body: params,             // Content-Type set to application/x-www-form-urlencoded
});

Good for traditional form-submission-style endpoints.

Query parameters: URLSearchParams #

For GET requests with query strings, build them with URLSearchParams:

const params = new URLSearchParams({ q: 'react', page: 1, per_page: 10 });
const res = await fetch(`/api/search?${params}`);

Or use the URL API directly:

const url = new URL('/api/search', window.location.origin);
url.searchParams.set('q', 'react');
url.searchParams.set('page', '1');
const res = await fetch(url);

Both handle URL-encoding correctly. Manual string concatenation (?q= + encodeURIComponent(q) + …) is error-prone — use the helpers.

Cancellation: AbortController #

Fetches don't time out by default — they hang as long as the connection is alive. Use AbortController to cancel:

const controller = new AbortController();

fetch('/api/users', { signal: controller.signal })
  .then(res => res.json())
  .then(users => render(users))
  .catch(err => {
    if (err.name === 'AbortError') {
      console.log('Cancelled');
    } else {
      throw err;
    }
  });

// Later — cancel
controller.abort();

The canonical use cases:

  • Component unmount — the user navigated away; abort the in-flight request.
  • Search-as-you-type — abort the previous request when a new keystroke fires a new one.
  • Timeouts — abort after N milliseconds (shown below).

Timeout helper #

There's no built-in timeout option on fetch. The standard pattern uses AbortSignal.timeout (a modern shorthand):

fetch('/api/slow', { signal: AbortSignal.timeout(5000) })
  .catch(err => {
    if (err.name === 'TimeoutError') console.log('Slow API');
  });

Or roll your own:

const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
  const res = await fetch(url, { signal: controller.signal });
  return res.json();
} finally {
  clearTimeout(timer);
}

CORS in 60 seconds #

Browsers enforce a same-origin policy. If your page is on https://app.example.com and you fetch('https://api.example.com/users'), the browser sends the request, then checks the response headers before letting your JS read it.

If the server doesn't include Access-Control-Allow-Origin permitting your origin, the fetch rejects with a CORS error. The request DID go through — the server saw it — but the response is hidden from your code.

Solutions:

  • Server adds CORS headers for your origin (the right answer for APIs you control).
  • Proxy through your own server (the right answer for third-party APIs).
  • Use a server-side fetch (Next.js API routes, Node.js, etc.) instead of calling the third-party API from the browser.

Note that CORS does not apply on the server side — Node, Deno, Bun, and edge runtimes don't enforce CORS. Only browsers.

Streaming responses #

For large responses, read the body as a stream instead of waiting for it all:

const res = await fetch('/api/large-export');
const reader = res.body.getReader();
const decoder = new TextDecoder();

while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  console.log(decoder.decode(value));
}

Useful for log streams, server-sent events on top of fetch, AI completion streams, etc.

For JSON lines (NDJSON, JSONL), parse each line as it arrives.

Retries with exponential backoff #

A common pattern, in 15 lines:

async function fetchWithRetry(url, options = {}, retries = 3) {
  for (let attempt = 0; attempt < retries; attempt++) {
    try {
      const res = await fetch(url, options);
      if (res.ok) return res;
      if (res.status < 500) return res; // don't retry client errors
    } catch (err) {
      if (attempt === retries - 1) throw err;
    }
    await new Promise(r => setTimeout(r, 2 ** attempt * 500));
  }
}

500ms, 1s, 2s backoffs. Doesn't retry 4xx (which would never succeed). For production, add jitter (random offset to avoid thundering herd) and a max delay.

Authorization #

Three common patterns:

1. Bearer token in header #

fetch('/api/me', {
  headers: { 'Authorization': `Bearer ${token}` },
});

Standard for OAuth, JWT, API keys. Stored typically in memory or localStorage (with the XSS caveats — see Lesson 7.5).

2. Cookies (session auth) #

For cross-origin requests, cookies aren't sent by default:

fetch('/api/me', { credentials: 'include' });

For same-origin, cookies are sent automatically. credentials: 'include' lets you opt into sending them cross-origin (also requires the server to send Access-Control-Allow-Credentials: true).

3. Per-request API key #

fetch(`/api/data?key=${apiKey}`); // worst — leaks via logs and referrer headers
fetch('/api/data', { headers: { 'X-API-Key': apiKey } }); // better

A complete request helper #

Real codebases wrap fetch in a tiny helper that handles the boilerplate:

async function api(path, { method = 'GET', body, headers = {}, signal } = {}) {
  const res = await fetch(`/api${path}`, {
    method,
    headers: {
      'Accept': 'application/json',
      ...(body ? { 'Content-Type': 'application/json' } : {}),
      ...headers,
    },
    body: body ? JSON.stringify(body) : undefined,
    signal,
  });
  if (!res.ok) {
    const err = new Error(`${res.status} ${res.statusText}`);
    err.status = res.status;
    try { err.body = await res.json(); } catch {}
    throw err;
  }
  return res.status === 204 ? null : res.json();
}

// Usage
const users = await api('/users');
const me = await api('/users', { method: 'POST', body: { name: 'Ada' } });

One file like this typically replaces what used to be axios in many codebases.

A summary #

  • fetch is promise-based. await it.
  • fetch does not reject on HTTP errors. Always check res.ok.
  • Parse the body once with .json(), .text(), .blob(), etc.
  • Use URLSearchParams for query strings, FormData for multipart, JSON.stringify for JSON.
  • AbortController for cancellation, timeouts, and search-as-you-type.
  • CORS is browser-enforced; servers need Access-Control-* headers.
  • Wrap fetch in a helper that handles errors, JSON, and the standard headers in one place.

What's next #

Lesson 7.5 covers localStorage and sessionStorage — the browser's built-in key/value stores, their limits, the persistence model, and when to reach for IndexedDB instead. That completes Module 7 and the entire JavaScript Tutorial path.

Try it yourself #

The res.ok check is the single most missed step in fetch code. See how many people get this wrong:

YouWhat does this print when the server returns 404?
try {
const res = await fetch('/api/nope');
console.log('success');
} catch (e) {
console.log('error:', e.message);
}
Claude · used browser_sandboxIt prints success.

fetch does not reject on HTTP errors — only on network failures. A 404 is a successful HTTP transaction (the server responded), so the promise resolves. To make this catch 404s, you have to throw when !res.ok:

if (!res.ok) throw new Error(res.statusText);

Forgetting this turns server errors into silent success — one of the most common bugs in fetch code.

Know this pattern and you avoid the most common production bug in fetch-based code.

Up next in JavaScript

More from this topic

View all JavaScript articles →

Enjoyed this article?

Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email address will not be published. Required fields are marked *