fetch() is now built into every modern browser and Node.js 18+. It replaced XMLHttpRequest and eliminated the need for Axios in most projects. But the default usage that every tutorial shows — fetch(url).then(r => r.json()) — skips error handling, has no timeout, and falls over in any real production environment. This guide covers the patterns that actually hold up.

The Basics — GET and POST

fetch() returns a Promise that resolves with a Response object. A GET request is simple:

js
const response = await fetch('https://api.example.com/products');
const products = await response.json();

A POST request with a JSON body needs a bit more setup:

js
const newProduct = {
  name:     'Wireless Keyboard',
  price:    79.99,
  category: 'electronics'
};

const response = await fetch('https://api.example.com/products', {
  method:  'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${authToken}`
  },
  body: JSON.stringify(newProduct)
});

const created = await response.json();
console.log(`Created product with id: ${created.id}`);
Common mistake: forgetting Content-Type: application/json on POST requests. Without it, many server frameworks won't parse the body, and you'll get a 400 Bad Request or an empty request body with no useful error message.

The Two-Step Error Check — response.ok

This is the most important pattern to internalise. fetch() only rejects its Promise on network errors (no connection, DNS failure, CORS block). A 404, 401, or 500 response still resolves the Promise — with response.ok set to false. If you don't check this, you'll silently pass error responses to response.json():

js
// ❌ Broken — 404 and 500 responses appear to succeed
async function getProduct(id) {
  const response = await fetch(`/api/products/${id}`);
  return await response.json(); // parses the error body as if it were data
}

// ✅ Correct — check response.ok before parsing
async function getProduct(id) {
  const response = await fetch(`/api/products/${id}`);

  if (!response.ok) {
    // Try to get the error message from the body if it's JSON
    const errorBody = await response.json().catch(() => null);
    const message   = errorBody?.message ?? `HTTP ${response.status}: ${response.statusText}`;
    throw new Error(message);
  }

  return await response.json();
}

response.ok is true for status codes 200–299. Anything else — 301 redirects (if not auto-followed), 400 errors, 500 errors — sets it to false. Always check it before you parse.

AbortController — Timeouts and Cancellation

fetch() has no built-in timeout. A request can hang indefinitely if the server stops responding mid-transfer. The solution is AbortController:

js
async function fetchWithTimeout(url, options = {}, timeoutMs = 10000) {
  const controller = new AbortController();
  const timeoutId  = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, {
      ...options,
      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(`Request timed out after ${timeoutMs}ms`);
    }
    throw err;
  } finally {
    clearTimeout(timeoutId);
  }
}

// Usage
try {
  const data = await fetchWithTimeout('/api/products', {}, 5000);
} catch (err) {
  console.error(err.message); // "Request timed out after 5000ms"
}

AbortController is also useful for cancelling in-flight requests when a user navigates away or performs a new search before the previous one completes:

js
let searchController = null;

async function searchProducts(query) {
  // Cancel any previous search request
  if (searchController) {
    searchController.abort();
  }

  searchController = new AbortController();

  try {
    const response = await fetch(
      `/api/products/search?q=${encodeURIComponent(query)}`,
      { signal: searchController.signal }
    );
    return await response.json();
  } catch (err) {
    if (err.name !== 'AbortError') throw err;
    return null; // request was cancelled — that's OK
  }
}

Retry with Exponential Backoff

Network requests fail transiently — a 503 on one retry often succeeds on the next. Exponential backoff is the standard strategy: wait progressively longer between retries to avoid hammering an overloaded server:

js
async function fetchWithRetry(url, options = {}, retries = 3, baseDelayMs = 500) {
  for (let attempt = 1; attempt <= retries; attempt++) {
    try {
      const response = await fetch(url, options);

      // Don't retry client errors (4xx) — they won't fix themselves
      if (response.status >= 400 && response.status < 500) {
        throw new Error(`Client error ${response.status} — not retrying`);
      }

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

      return await response.json();

    } catch (err) {
      const isLastAttempt = attempt === retries;

      if (isLastAttempt || err.message.includes('Client error')) {
        throw err;
      }

      // Exponential backoff with jitter: 500ms, 1000ms, 2000ms + random
      const delay = baseDelayMs * 2 ** (attempt - 1) + Math.random() * 100;
      console.warn(`Attempt ${attempt} failed, retrying in ${Math.round(delay)}ms...`);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

// Usage
const products = await fetchWithRetry('/api/products', {}, 3, 500);
The jitter bit matters: Adding Math.random() * 100 to the delay prevents "thundering herd" — where thousands of clients all retry at exactly the same moment after a server hiccup. Small random offset, big reliability benefit.

An Interceptor Pattern — Wrapping fetch()

Axios popularised the interceptor concept: a hook that runs before every request and after every response. You can build the same thing as a thin wrapper around fetch():

js
// api.js — your project's fetch wrapper
const API_BASE = 'https://api.example.com';

function getAuthToken() {
  return localStorage.getItem('authToken');
}

async function apiFetch(path, options = {}) {
  const url = `${API_BASE}${path}`;

  // Request interceptor — add auth header to every request
  const headers = {
    'Content-Type': 'application/json',
    ...options.headers
  };

  const token = getAuthToken();
  if (token) headers['Authorization'] = `Bearer ${token}`;

  const response = await fetch(url, { ...options, headers });

  // Response interceptor — handle auth expiry globally
  if (response.status === 401) {
    logout(); // token expired — redirect to login
    throw new Error('Session expired');
  }

  if (!response.ok) {
    const body = await response.json().catch(() => ({}));
    throw new Error(body.message ?? `API error ${response.status}`);
  }

  // Return null for 204 No Content
  if (response.status === 204) return null;

  return response.json();
}

// Usage — clean, no repeated boilerplate
const products = await apiFetch('/products');
const created  = await apiFetch('/products', {
  method: 'POST',
  body:   JSON.stringify({ name: 'New Product', price: 29.99 })
});

This pattern centralises auth, error handling, and base URL configuration. Every API call in your codebase gets the same behaviour for free. When requirements change — like adding a new header or switching from JWT to session cookies — you update one place.

Handling Different Response Types

Not every API returns JSON. fetch() has methods for all common response types:

js
// JSON (most common)
const data = await response.json();

// Plain text (CSV, HTML, logs)
const csvText = await response.text();

// Binary data — download a file
const blob       = await response.blob();
const blobUrl    = URL.createObjectURL(blob);
const downloadLink = document.createElement('a');
downloadLink.href     = blobUrl;
downloadLink.download = 'export.csv';
downloadLink.click();
URL.revokeObjectURL(blobUrl); // clean up

// ArrayBuffer — for WebAssembly or typed arrays
const buffer     = await response.arrayBuffer();
const byteArray  = new Uint8Array(buffer);

// Form data (multipart responses)
const formData = await response.formData();

CORS — What Developers Need to Know

CORS (Cross-Origin Resource Sharing) is enforced by the browser, not the server. When your frontend at app.example.com fetches from api.example.com, the browser sends an Origin header and checks the response for permission. Here's what you control from the client side:

js
// credentials: 'include' — send cookies/session tokens cross-origin
// The server must also respond with Access-Control-Allow-Credentials: true
const response = await fetch('https://api.example.com/account', {
  credentials: 'include'
});

// credentials: 'same-origin' — default, only sends credentials to same origin
// credentials: 'omit' — never send credentials (useful for public CDN requests)

// mode: 'no-cors' — fire-and-forget for cross-origin requests
// You get a "opaque" response — no status, no body, no error
await fetch('https://analytics.example.com/event', {
  method: 'POST',
  mode:   'no-cors',
  body:   JSON.stringify({ event: 'page_view' })
});
// Use for analytics pings where you don't need the response

If you're getting CORS errors, the fix is almost always on the server: the server needs to add Access-Control-Allow-Origin to its response headers. MDN's CORS guide is the most thorough reference for how to configure it.

Useful Tools

When you're debugging fetch responses, JSON Formatter makes API payloads readable and JSON Validator catches malformed responses. For encoding query params safely, JSON URL Encode has you covered. The MDN Using Fetch guide is the best single reference for all fetch options and response methods.

Wrapping Up

fetch() is a solid foundation for HTTP requests in JavaScript, but the defaults leave out everything you need in production. Always check response.ok before parsing. Add timeouts with AbortController. Build a thin wrapper function to handle auth headers, error normalisation, and 401 redirects in one place. Add retry logic with exponential backoff for requests that can fail transiently. Those four habits are the difference between demo code and production code.