Skip to content

Fetch API and HTTP Requests

beginner15 min read

Talking to Servers

Almost every web app needs to communicate with a server. Load user data. Submit a form. Fetch search results. Save a file. The fetch API is the modern, Promise-based way to make HTTP requests from JavaScript. It replaced XMLHttpRequest (the old AJAX approach) and is now the standard way to do network requests in the browser.

It's clean, it's powerful, and it has one very common footgun that catches almost every developer the first time they use it. We'll cover that too.

Mental Model

Think of fetch as ordering food at a restaurant. You place an order (the request) and get a ticket (the Promise). The kitchen prepares your food (server processes the request) and eventually brings it out (the response). But here's the thing — when the waiter brings your food, it's covered with a lid. You still need to take the lid off to see what's inside (parse the response body with .json(), .text(), etc.). And if the kitchen is overloaded and sends out a "sorry, we're out of that" response (a 404), the waiter still brings you that message — they don't just disappear. fetch only rejects the Promise on network failures, not HTTP errors.

Basic Fetch

// GET request (the default)
const response = await fetch('https://api.example.com/users');
const users = await response.json();
console.log(users);

fetch returns a Promise that resolves to a Response object. The Response contains headers, status codes, and the body — but the body needs to be parsed separately because it might be JSON, text, a blob, or a stream.

Parsing the Response Body

The Response body can only be read once. Choose the right method:

const response = await fetch('/api/data');

// Parse as JSON — most common for APIs
const json = await response.json();

// Parse as plain text
const text = await response.text();

// Parse as binary blob (images, files)
const blob = await response.blob();

// Parse as ArrayBuffer (raw binary)
const buffer = await response.arrayBuffer();

// Parse as FormData (multipart form responses)
const formData = await response.formData();
Common Trap

The response body is a ReadableStream that can only be consumed once. If you call response.json() and then try response.text(), the second call throws because the stream is already read. If you need the body in multiple formats, call response.clone() before reading.

Quiz
You call await response.json() and then await response.text() on the same response. What happens?

The Biggest Fetch Gotcha: HTTP Errors Don't Reject

This is the thing that trips up almost every developer learning fetch. A 404 or 500 response does NOT cause the Promise to reject. Fetch only rejects on actual network failures (DNS failure, server unreachable, CORS blocked).

// This does NOT throw for 404 or 500
const response = await fetch('/api/nonexistent');
console.log(response.status); // 404
console.log(response.ok);     // false

// You MUST check response.ok yourself
if (!response.ok) {
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();

A Proper Fetch Wrapper

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

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

  return response.json();
}

// Usage
try {
  const users = await fetchJSON('/api/users');
} catch (error) {
  console.error('Request failed:', error.message);
}

Making Different Types of Requests

POST with JSON Body

const response = await fetch('/api/users', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    name: 'Ada Lovelace',
    role: 'engineer',
  }),
});

POST with FormData

const formData = new FormData();
formData.append('name', 'Ada');
formData.append('avatar', fileInput.files[0]);

const response = await fetch('/api/profile', {
  method: 'POST',
  body: formData,
  // Don't set Content-Type — the browser sets it automatically
  // with the correct multipart boundary
});
Common Trap

When sending FormData, do not manually set the Content-Type header. The browser automatically sets it to multipart/form-data with the correct boundary string. If you set it yourself, the boundary is missing and the server can't parse the data.

PUT, PATCH, DELETE

// PUT — replace a resource
await fetch('/api/users/42', {
  method: 'PUT',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Ada', role: 'architect' }),
});

// PATCH — partially update a resource
await fetch('/api/users/42', {
  method: 'PATCH',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ role: 'architect' }),
});

// DELETE — remove a resource
await fetch('/api/users/42', {
  method: 'DELETE',
});
Quiz
You call fetch('/api/missing') and the server responds with a 404 status. What happens?

Headers

// Reading response headers
const response = await fetch('/api/data');
response.headers.get('Content-Type');   // "application/json"
response.headers.get('X-Request-Id');   // custom header
response.headers.has('Authorization');  // false

// Setting request headers
const response = await fetch('/api/data', {
  headers: {
    'Authorization': 'Bearer token123',
    'Accept': 'application/json',
    'X-Custom-Header': 'value',
  },
});

// Using the Headers constructor
const headers = new Headers();
headers.append('Content-Type', 'application/json');
headers.append('Authorization', 'Bearer token');

AbortController — Canceling Requests

AbortController lets you cancel in-flight fetch requests. This is essential for search-as-you-type, route changes, and component unmounts.

const controller = new AbortController();

// Pass the signal to fetch
const response = await fetch('/api/search?q=javascript', {
  signal: controller.signal,
});

// Cancel the request from anywhere
controller.abort();

When a request is aborted, the fetch Promise rejects with an AbortError:

try {
  const response = await fetch('/api/data', {
    signal: controller.signal,
  });
  const data = await response.json();
} catch (error) {
  if (error.name === 'AbortError') {
    console.log('Request was canceled');
  } else {
    console.error('Request failed:', error);
  }
}

Search-As-You-Type Pattern

let currentController = null;

async function search(query) {
  // Abort the previous request if it's still in flight
  if (currentController) {
    currentController.abort();
  }

  currentController = new AbortController();

  try {
    const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
      signal: currentController.signal,
    });

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

    return await response.json();
  } catch (error) {
    if (error.name === 'AbortError') return null; // expected, ignore
    throw error;
  }
}

input.addEventListener('input', async (e) => {
  const results = await search(e.target.value);
  if (results) renderResults(results);
});

Timeout with AbortController

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

  try {
    const response = await fetch(url, {
      ...options,
      signal: controller.signal,
    });
    return response;
  } finally {
    clearTimeout(timeoutId);
  }
}
AbortSignal.timeout — the shortcut

Modern browsers support AbortSignal.timeout(ms) which creates a signal that auto-aborts after a timeout, without needing to manually manage an AbortController:

const response = await fetch('/api/data', {
  signal: AbortSignal.timeout(5000),
});

This is cleaner than manually creating an AbortController with setTimeout.

Quiz
When you abort a fetch request using AbortController, what type of error does the Promise reject with?

Request and Response Objects

Fetch uses formal Request and Response objects under the hood. You can create them explicitly:

// Create a reusable request
const request = new Request('/api/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Ada' }),
});

// Use it with fetch
const response = await fetch(request);

// Response properties
response.status;     // 200
response.statusText; // "OK"
response.ok;         // true (status 200-299)
response.url;        // the final URL (after redirects)
response.redirected; // true if the response came from a redirect
response.type;       // "basic", "cors", "opaque", etc.
Key Rules
  1. 1fetch only rejects on network failures — check response.ok for HTTP errors
  2. 2Response body can only be read once — clone first if you need it twice
  3. 3Use AbortController to cancel in-flight requests (search, route changes, unmounts)
  4. 4Don't set Content-Type manually when sending FormData — the browser sets the boundary
  5. 5Always use encodeURIComponent for user input in URLs
What developers doWhat they should do
Assuming fetch throws on 404/500 status codes
fetch only rejects for network failures. A 404 response resolves successfully — you must check response.ok or response.status and handle errors yourself
Checking response.ok and throwing manually for HTTP errors
Setting Content-Type: multipart/form-data when sending FormData
The browser generates a unique boundary string for multipart form data. Setting the header yourself loses that boundary, and the server can't parse the request body
Omitting Content-Type and letting the browser set it automatically
Not aborting previous requests in search-as-you-type
Without aborting, old requests can resolve after newer ones, showing stale results. Each new search should cancel the previous in-flight request
Using AbortController to cancel stale requests before making new ones