Toolmingo
Guides9 min read

JavaScript Fetch API: Complete Guide with Examples

Master the Fetch API — GET, POST, PUT, DELETE, headers, JSON, FormData, AbortController, error handling, and real-world patterns with TypeScript types.

The Fetch API is the modern, promise-based way to make HTTP requests in the browser and Node.js (v18+). It replaces XMLHttpRequest and eliminates most Axios boilerplate. This guide covers every pattern you'll need — from basic GET to streaming responses.


Quick-reference table

Task Code
GET JSON const data = await res.json()
POST JSON fetch(url, { method: 'POST', body: JSON.stringify(d), headers: {'Content-Type':'application/json'} })
Check success if (!res.ok) throw new Error(res.statusText)
Send form fetch(url, { method: 'POST', body: new FormData(form) })
Set auth header headers: { Authorization: 'Bearer ' + token }
Cancel request signal: controller.signal
Timeout (5s) AbortSignal.timeout(5000)
Read text await res.text()
Read blob await res.blob()
Read headers res.headers.get('Content-Type')
Credentials (cookies) credentials: 'include'
No cache cache: 'no-store'

Basic GET request

const res = await fetch('https://api.example.com/users');

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

const users = await res.json();
console.log(users);

Always check res.ok — fetch only rejects on network failure. A 404 or 500 still resolves; you must handle it manually.


POST — sending JSON

const res = await fetch('https://api.example.com/users', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
  },
  body: JSON.stringify({ name: 'Alice', email: 'alice@example.com' }),
});

if (!res.ok) {
  const errorBody = await res.json().catch(() => null);
  throw new Error(`POST failed: ${res.status} ${JSON.stringify(errorBody)}`);
}

const created = await res.json();

PUT and PATCH — updating resources

// Full replace
await fetch(`/api/users/${id}`, {
  method: 'PUT',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(fullUser),
});

// Partial update
await fetch(`/api/users/${id}`, {
  method: 'PATCH',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email: 'new@example.com' }),
});

DELETE

const res = await fetch(`/api/users/${id}`, { method: 'DELETE' });

if (!res.ok) throw new Error(`Delete failed: ${res.status}`);

// Many APIs return 204 No Content — don't try to .json() it
if (res.status !== 204) {
  const body = await res.json();
}

Error handling

The two-layer error model is the most important thing to understand:

async function apiFetch(url, options = {}) {
  let res;

  // Layer 1 — network error (no internet, DNS failure, CORS block)
  try {
    res = await fetch(url, options);
  } catch (networkError) {
    throw new Error(`Network error: ${networkError.message}`);
  }

  // Layer 2 — HTTP error (4xx, 5xx)
  if (!res.ok) {
    let detail = '';
    try {
      const body = await res.json();
      detail = body.message ?? JSON.stringify(body);
    } catch {
      detail = await res.text().catch(() => '');
    }
    throw new Error(`HTTP ${res.status}: ${detail}`);
  }

  return res;
}

// Usage
const res = await apiFetch('/api/users');
const users = await res.json();

Authentication headers

// Bearer token (JWT)
const res = await fetch('/api/profile', {
  headers: {
    Authorization: `Bearer ${localStorage.getItem('token')}`,
  },
});

// Basic auth
const credentials = btoa(`${username}:${password}`);
const res2 = await fetch('/api/admin', {
  headers: { Authorization: `Basic ${credentials}` },
});

// API key
const res3 = await fetch('/api/data', {
  headers: { 'X-API-Key': apiKey },
});

Sending cookies (credentials)

By default fetch does not send cookies cross-origin. Use credentials:

// Same-origin: cookies sent automatically
const res = await fetch('/api/me');

// Cross-origin: must opt in (server must allow with Access-Control-Allow-Credentials)
const res2 = await fetch('https://api.example.com/me', {
  credentials: 'include',
});
credentials value Cookies sent
'omit' Never
'same-origin' Same origin only (default)
'include' Always (cross-origin too)

FormData — file uploads and HTML forms

// Submit an HTML form
const form = document.querySelector('#upload-form');
form.addEventListener('submit', async (e) => {
  e.preventDefault();

  const res = await fetch('/api/upload', {
    method: 'POST',
    body: new FormData(form), // No Content-Type header — browser sets multipart boundary
  });

  const result = await res.json();
});

// Manual FormData with a file input
const formData = new FormData();
formData.append('avatar', fileInput.files[0]);
formData.append('userId', '42');

const res = await fetch('/api/avatar', {
  method: 'POST',
  body: formData,
});

Do not set Content-Type: multipart/form-data manually — the browser must set it with the correct boundary string.


AbortController — cancelling requests

const controller = new AbortController();

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

try {
  const res = await fetch('/api/slow-endpoint', {
    signal: controller.signal,
  });
  clearTimeout(timer);
  const data = await res.json();
} catch (err) {
  if (err.name === 'AbortError') {
    console.log('Request was cancelled');
  } else {
    throw err;
  }
}

Timeout shorthand (Node.js 18+ / modern browsers)

// Cleaner one-liner — no controller needed
const res = await fetch('/api/data', {
  signal: AbortSignal.timeout(5000), // throws TimeoutError after 5s
});

Cancel on component unmount (React)

useEffect(() => {
  const controller = new AbortController();

  fetch('/api/data', { signal: controller.signal })
    .then(res => res.json())
    .then(setData)
    .catch(err => {
      if (err.name !== 'AbortError') console.error(err);
    });

  return () => controller.abort(); // cleanup
}, []);

Reading different response types

const res = await fetch('/api/resource');

// JSON
const json = await res.json();

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

// Binary file (image, PDF)
const blob = await res.blob();
const url = URL.createObjectURL(blob);

// ArrayBuffer (WebAssembly, audio, crypto)
const buffer = await res.arrayBuffer();

// FormData (multipart response — rare)
const form = await res.formData();

You can only read the body once. Clone the response if you need it twice:

const res = await fetch('/api/data');
const resClone = res.clone();

const json = await res.json();
const text = await resClone.text(); // reads from clone

Request and Response objects

// Inspect the request before sending
const request = new Request('/api/users', {
  method: 'GET',
  headers: { Authorization: 'Bearer token' },
});

console.log(request.url);     // 'https://example.com/api/users'
console.log(request.method);  // 'GET'

const res = await fetch(request);

// Inspect the response
console.log(res.status);      // 200
console.log(res.statusText);  // 'OK'
console.log(res.ok);          // true (200-299)
console.log(res.redirected);  // true if followed a redirect
console.log(res.url);         // final URL after redirects
console.log(res.type);        // 'basic' | 'cors' | 'opaque'

// Response headers
res.headers.forEach((value, name) => {
  console.log(`${name}: ${value}`);
});
console.log(res.headers.get('content-type'));

Cache control

// Always fetch fresh — never use cache
const res = await fetch('/api/data', { cache: 'no-store' });

// Use cache, revalidate in background
const res2 = await fetch('/api/config', { cache: 'stale-while-revalidate' });

// Force reload from server, update cache
const res3 = await fetch('/api/data', { cache: 'reload' });
cache value Behaviour
'default' Normal browser cache logic
'no-store' Never cache, always fetch fresh
'no-cache' Revalidate with server even if cached
'reload' Bypass cache for request, update cache with response
'force-cache' Use cache even if stale
'only-if-cached' Return cached or fail

Parallel requests

// Run 3 requests at the same time
const [users, posts, comments] = await Promise.all([
  fetch('/api/users').then(r => r.json()),
  fetch('/api/posts').then(r => r.json()),
  fetch('/api/comments').then(r => r.json()),
]);

// Race — use whichever resolves first
const fastest = await Promise.race([
  fetch('/api/primary').then(r => r.json()),
  fetch('/api/backup').then(r => r.json()),
]);

Retry with exponential backoff

async function fetchWithRetry(url, options = {}, maxRetries = 3) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const res = await fetch(url, options);
      if (res.ok) return res;

      // Don't retry 4xx client errors
      if (res.status >= 400 && res.status < 500) throw new Error(`Client error: ${res.status}`);

      if (attempt === maxRetries) throw new Error(`Max retries exceeded: ${res.status}`);
    } catch (err) {
      if (attempt === maxRetries) throw err;
    }

    // Wait 2^attempt * 100ms (100ms, 200ms, 400ms)
    await new Promise(resolve => setTimeout(resolve, 2 ** attempt * 100));
  }
}

const res = await fetchWithRetry('/api/flaky-endpoint');
const data = await res.json();

TypeScript types

interface User {
  id: number;
  name: string;
  email: string;
}

async function getUser(id: number): Promise<User> {
  const res = await fetch(`/api/users/${id}`);

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

  return res.json() as Promise<User>;
}

async function createUser(data: Omit<User, 'id'>): Promise<User> {
  const res = await fetch('/api/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });

  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json() as Promise<User>;
}

Fetch vs Axios comparison

Feature Fetch Axios
Built-in ✅ (browser + Node 18+) ❌ (npm install)
Auto JSON parse ❌ (call .json())
Auto JSON body ❌ (JSON.stringify + header)
HTTP error throw ❌ (check res.ok)
Interceptors ❌ (wrap manually)
Progress events ❌ (use ReadableStream)
Request cancellation ✅ (AbortController)
Timeout ✅ (AbortSignal.timeout)
Older browser support ✅ (polyfill needed for IE)

Use fetch when: you want zero dependencies, simple requests, or you're in Node.js 18+.
Use Axios when: you need interceptors, automatic retries, upload progress, or a consistent API across old and new browsers.


Streaming responses (Server-Sent Events / large files)

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

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

  const chunk = decoder.decode(value, { stream: true });
  process(chunk); // handle each chunk as it arrives
}

This pattern is essential for AI streaming APIs, large file downloads, or real-time data feeds.


6 common mistakes

Mistake Problem Fix
Not checking res.ok 404/500 silently "succeeds" Always if (!res.ok) throw
Setting Content-Type on FormData Breaks multipart boundary Let browser set it automatically
Calling .json() on 204 No Content Throws SyntaxError Check res.status !== 204 first
Missing await on res.json() Gets a Promise, not data const data = await res.json()
Reading body twice Second read returns empty Use res.clone()
No AbortController on slow requests Requests hang indefinitely Use AbortSignal.timeout()

6 FAQ

Q: Why does fetch not throw on 404?
A: Fetch considers any completed HTTP exchange a success. Network-level failures (no connection, DNS error, CORS block) cause the promise to reject. HTTP error codes (4xx, 5xx) resolve the promise — you must check res.ok or res.status yourself.

Q: How do I send cookies cross-origin?
A: Set credentials: 'include'. The server must also respond with Access-Control-Allow-Credentials: true and specify an exact origin in Access-Control-Allow-Origin (not *).

Q: Can I use fetch in Node.js?
A: Yes, natively in Node.js 18+. For older versions, use node-fetch (npm install node-fetch) or undici.

Q: How do I upload a file with a progress bar?
A: Fetch doesn't support upload progress events. Use XMLHttpRequest with xhr.upload.onprogress, or Axios which wraps XHR. Download progress works via response.body.getReader().

Q: What's the difference between no-cache and no-store?
A: no-cache allows caching but requires revalidation with the server before using the cached copy. no-store prevents caching entirely — nothing is written to disk or memory.

Q: How do I handle redirects?
A: Fetch follows redirects automatically by default. Use redirect: 'manual' to intercept them, or redirect: 'error' to throw on any redirect. Check res.redirected and res.url to see if a redirect occurred.

Related tools

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools