The Fetch API replaced XMLHttpRequest (XHR) as the standard way to make HTTP requests in JavaScript. It's built into every modern browser and Node.js 18+, returns Promises, and has a clean, intuitive API. If you're still using jQuery.ajax() or Axios for simple requests, you might not need them.

That said, fetch has real sharp edges — an error model that surprises almost everyone the first time, a one-shot response body, and no built-in timeout. This guide covers the everyday patterns (GET, POST, file uploads, cancellation) and, just as importantly, the behaviors you have to know about to use fetch safely in production. Every example uses async/await, which has been the idiomatic way to consume Promises since ES2017.

Basic GET Request

A fetch call is a two-step process, and understanding why explains half the API. The first await resolves as soon as the response headers arrive — the body may still be streaming in over the network. The second await (response.json()) reads the body to completion and parses it. Splitting these steps lets you inspect the status code and headers before deciding how, or whether, to consume a potentially large body.

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

// With error handling
async function getUsers() {
  const response = await fetch('https://api.example.com/users');

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

  return response.json();
}

Important: fetch() only rejects on network errors (server unreachable). HTTP errors (404, 500) are considered successful responses — you must check response.ok manually.

This design is deliberate — from the network's perspective, a 404 is a successful round trip — but it means a bare try/catch around a fetch call catches far less than most developers assume. If you skip the response.ok check and the server returns an HTML error page, the failure surfaces later as a baffling "Unexpected token < in JSON" from response.json(). Check the status first, and your errors will point at the actual problem.

POST, PUT, DELETE Requests

Anything beyond a simple GET goes through the options object: method chooses the HTTP verb, headers carries metadata like content type and authorization, and body holds the payload. Two details are easy to forget. The body must be a string (or a FormData/Blob) — passing a plain object silently serializes to the useless string [object Object], so always run objects through JSON.stringify. And the Content-Type: application/json header is not optional decoration; most server frameworks use it to decide how to parse the request, and omitting it is a classic cause of mysteriously empty request bodies.

POST — Create a Resource
const response = await fetch('https://api.example.com/users', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer eyJhbGci...',
  },
  body: JSON.stringify({
    name: 'Alice',
    email: '[email protected]',
  }),
});

const newUser = await response.json();
PUT — Update a Resource
await fetch('https://api.example.com/users/123', {
  method: 'PUT',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Alice Updated' }),
});
DELETE — Remove a Resource
await fetch('https://api.example.com/users/123', {
  method: 'DELETE',
  headers: { 'Authorization': 'Bearer token123' },
});

The conventional semantics: POST creates a resource (the server assigns the ID and typically returns 201 with the created object), PUT replaces an existing resource at a known URL, and DELETE removes it — often answering with 204 No Content. That last case is worth flagging: calling response.json() on an empty 204 body throws, so for delete requests check the status and skip body parsing. If you only want to modify some fields rather than replace the whole resource, PATCH is the more precise verb and works with fetch exactly like PUT.

Response Types

The Response object offers a parser for every common payload — JSON, plain text, binary blobs, raw buffers, and form data. Each returns a Promise because the body streams in from the network. The critical constraint: a body can be read exactly once. The data arrives as a stream, and once consumed it's gone; a second response.json() call rejects with "body stream already read". If you genuinely need to read a body twice (say, to log the raw text and also parse it), call response.clone() before the first read.

Parsing Different Response Types
const response = await fetch(url);

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

// Plain text
const text = await response.text();

// Binary data (images, files)
const blob = await response.blob();

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

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

// Response metadata
console.log(response.status);      // 200
console.log(response.statusText);  // "OK"
console.log(response.ok);          // true (200-299)
console.log(response.headers.get('Content-Type'));

AbortController — Cancel Requests

Fetch has no timeout option, and an abandoned request keeps consuming resources until the server responds or the connection dies. AbortController fills both gaps: pass its signal to fetch, and calling abort() tears the request down immediately, rejecting the promise with an AbortError. The pattern below implements a five-second timeout, but the same mechanism powers two other everyday needs: cancelling an in-flight search request when the user types another character (so a slow old response can't overwrite a newer one), and cancelling requests when a component unmounts in React or Vue to avoid state updates on dead components.

Abort a Fetch Request
const controller = new AbortController();

// Cancel after 5 seconds
const timeoutId = setTimeout(() => controller.abort(), 5000);

try {
  const response = await fetch('https://api.example.com/data', {
    signal: controller.signal,
  });
  clearTimeout(timeoutId);
  const data = await response.json();
} catch (err) {
  if (err.name === 'AbortError') {
    console.log('Request timed out');
  } else {
    throw err;
  }
}

Modern runtimes also ship AbortSignal.timeout(5000), which replaces the manual controller-plus-setTimeout dance for the simple timeout case — pass it directly as the signal option. Whichever you use, always branch on err.name === 'AbortError': a deliberate cancellation usually shouldn't be reported to the user the way a genuine network failure should.

File Upload

Uploading files means sending multipart/form-data, and FormData builds that payload for you — append files from an <input type="file"> alongside ordinary text fields. The counterintuitive rule, called out in the comment below, is to not set the Content-Type header yourself. Multipart bodies are divided by a randomly generated boundary string, and only the browser knows what it generated. Set the header manually and the boundary parameter is missing, leaving the server unable to split the parts — the upload arrives but parses as empty. This is easily the most common file-upload bug with fetch.

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

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

Reusable Fetch Wrapper

Real applications quickly accumulate repeated boilerplate: the same base URL, the same auth header, the same response.ok check, the same JSON parsing. A thin wrapper class centralizes all of it, so calling code shrinks to api.get('/users') and error handling lives in exactly one place. This is essentially what Axios gives you — which is why many projects no longer need Axios at all. Note the defensive .catch(() => ({})) when parsing an error response: failure bodies aren't guaranteed to be JSON, and an error handler that itself throws while parsing is a miserable thing to debug.

API Client
class ApiClient {
  constructor(baseUrl, token) {
    this.baseUrl = baseUrl;
    this.token = token;
  }

  async request(path, options = {}) {
    const response = await fetch(`${this.baseUrl}${path}`, {
      ...options,
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${this.token}`,
        ...options.headers,
      },
    });

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

    return response.json();
  }

  get(path) { return this.request(path); }
  post(path, data) { return this.request(path, { method: 'POST', body: JSON.stringify(data) }); }
  put(path, data) { return this.request(path, { method: 'PUT', body: JSON.stringify(data) }); }
  delete(path) { return this.request(path, { method: 'DELETE' }); }
}

// Usage
const api = new ApiClient('https://api.example.com', 'token123');
const users = await api.get('/users');
const newUser = await api.post('/users', { name: 'Alice' });

Common Mistakes

  • Not checking response.ok: 404 and 500 errors don't throw — you must check manually.
  • Setting Content-Type for FormData: The browser needs to set the boundary parameter automatically.
  • Calling response.json() twice: Response body can only be consumed once. Store the result in a variable.
  • Not handling AbortError: When using AbortController, the rejected promise has name === 'AbortError'.
  • Forgetting credentials for cookie-based auth: By default fetch only sends cookies on same-origin requests. For a cross-origin API that relies on session cookies, pass credentials: 'include' — and make sure the server's CORS headers allow it.
  • Blaming fetch for CORS errors: "Blocked by CORS policy" is not a fetch bug and cannot be fixed from the browser side. The server must send the appropriate Access-Control-Allow-Origin header; during development, a local proxy is the usual workaround.

Where to Go from Here

Fetch rewards a small amount of upfront discipline: always check response.ok, always give long-running requests an abort signal, and wrap the boilerplate once instead of copy-pasting it. With those habits in place, build the wrapper class above into your next project and extend it as needs appear — automatic retry with exponential backoff for transient 5xx errors, a 401 handler that refreshes tokens, and request de-duplication are all natural additions. When you outgrow hand-rolled data fetching in a component-based app, libraries like TanStack Query layer caching and revalidation on top of the same fetch fundamentals — everything in this guide still applies underneath.

Try Our Free Developer Tools

Format API responses and encode request parameters.