Making HTTP Requests with Fetch in JavaScript

Making HTTP Requests with Fetch in JavaScript

I still remember the days of XMLHttpRequest — nested callbacks, verbose setup, and a syntax that never quite felt natural. When the Fetch API arrived, it felt like a breath of fresh air: Promise-based, clean, and built into the platform. In this article, I want to share everything I’ve learned about using Fetch effectively, from the basics to the details that trip people up in production.

What Fetch Actually Is

fetch() is a global function available in browsers and in Node.js (natively since Node 18) that initiates an HTTP request and returns a Promise resolving to a Response object. It replaces most use cases for XMLHttpRequest with a cleaner, Promise-based interface.

Basic GET Request

fetch('https://api.example.com/users')
  .then((response) => response.json())
  .then((data) => console.log(data))
  .catch((error) => console.error('Fetch error:', error));

With async/await, which I prefer for readability:

async function getUsers() {
  try {
    const response = await fetch('https://api.example.com/users');
    const data = await response.json();
    console.log(data);
  } catch (error) {
    console.error('Fetch error:', error);
  }
}

The Gotcha Everyone Hits: Fetch Doesn’t Reject on HTTP Errors

This is the single most common mistake I see with Fetch. A fetch() Promise only rejects on network failure (DNS issues, no connectivity, CORS blocks) — not on HTTP error statuses like 404 or 500. You have to check response.ok yourself:

async function getUser(id) {
  const response = await fetch(`https://api.example.com/users/${id}`);

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

  return response.json();
}

Without this check, a 404 response silently “succeeds” as far as your .then() chain is concerned, and you’ll try to parse an error page as JSON, leading to confusing downstream errors.

Making POST Requests

async function createUser(userData) {
  const response = await fetch('https://api.example.com/users', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(userData),
  });

  if (!response.ok) {
    throw new Error(`Failed to create user: ${response.status}`);
  }

  return response.json();
}

createUser({ name: 'Alex', email: 'alex@example.com' })
  .then((user) => console.log('Created:', user));

PUT, PATCH, and DELETE

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

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

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

Handling Different Response Types

Response objects support multiple parsing methods depending on the content:

const jsonData = await response.json();   // parse JSON
const textData = await response.text();   // plain text
const blobData = await response.blob();   // binary data (images, files)
const bufferData = await response.arrayBuffer(); // raw bytes
const formData = await response.formData(); // multipart form data

Note that each of these methods can only be called once per response — the body is a stream that gets consumed. If you need to read it twice, clone the response first:

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

const asJson = await response.json();
const asText = await clone.text();

Aborting Requests with AbortController

I use AbortController constantly, especially for search-as-you-type features where a new request should cancel the previous, now-irrelevant one:

let controller;

async function search(query) {
  if (controller) controller.abort(); // cancel the previous request
  controller = new AbortController();

  try {
    const response = await fetch(`/api/search?q=${query}`, {
      signal: controller.signal,
    });
    return response.json();
  } catch (error) {
    if (error.name === 'AbortError') {
      console.log('Request was cancelled');
    } else {
      throw error;
    }
  }
}

Without this, rapid typing can trigger a race condition where an older, slower response arrives after a newer one and overwrites it with stale data.

Setting Timeouts

Fetch has no built-in timeout option, so I combine it 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);
  }
}

Handling Headers

async function fetchWithAuth(url) {
  const response = await fetch(url, {
    headers: {
      'Authorization': `Bearer ${getToken()}`,
      'Accept': 'application/json',
    },
  });

  // Reading response headers
  console.log(response.headers.get('Content-Type'));
  console.log(response.headers.get('X-RateLimit-Remaining'));

  return response.json();
}

Credentials and Cookies

By default, fetch() does not send cookies for cross-origin requests. If your API relies on cookie-based sessions, you need to opt in explicitly:

fetch('https://api.example.com/me', {
  credentials: 'include', // sends cookies even cross-origin
});

For same-origin requests, credentials: 'same-origin' (the default in most browsers today) is usually sufficient.

Understanding CORS Errors

One of the most common frustrations I’ve helped debug is a fetch request failing with a vague “Failed to fetch” or CORS-related console error. This happens when the server doesn’t include the appropriate Access-Control-Allow-Origin header for cross-origin requests. It’s important to understand this is a server-side configuration issue — no amount of client-side JavaScript can bypass CORS restrictions, by design, since they exist to protect users, not developers.

Uploading Files

async function uploadFile(file) {
  const formData = new FormData();
  formData.append('file', file);

  const response = await fetch('/api/upload', {
    method: 'POST',
    body: formData,
    // Do NOT set Content-Type manually here —
    // the browser sets the correct multipart boundary automatically
  });

  return response.json();
}

A mistake I made early on: manually setting Content-Type: multipart/form-data when using FormData. This breaks the upload because the browser needs to append its own boundary string to that header — always let it set this automatically.

Retrying Failed Requests

For flaky networks, I implement retry logic with exponential backoff:

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

Fetch in Node.js

Since Node.js 18, fetch is available globally without any extra dependency, using the same API surface as the browser:

async function getRepoInfo() {
  const response = await fetch('https://api.github.com/repos/nodejs/node');
  const data = await response.json();
  console.log(data.stargazers_count);
}

This has largely replaced libraries like node-fetch and axios for simple use cases in newer Node.js projects, though axios remains popular for its built-in interceptors and automatic JSON handling.

Security Best Practices

  • Never expose API secrets or tokens in client-side fetch calls that are visible in browser DevTools; route sensitive calls through your own backend.
  • Validate and sanitize any data received before rendering it into the DOM, to avoid XSS via API responses.
  • Always use HTTPS endpoints in production.

Common Mistakes

MistakeConsequenceFix
Assuming fetch rejects on 404/500Errors silently ignoredCheck response.ok explicitly
Reading response body twiceTypeError: body stream already readClone the response first
Manually setting Content-Type with FormDataBroken multipart uploadsLet the browser set it automatically
No timeout on requestsRequests hang indefinitely on slow networksCombine with AbortController for timeouts
Not aborting stale requestsRace conditions with out-of-order responsesUse AbortController to cancel outdated requests

FAQs

Is Fetch better than Axios? Fetch is built into the platform and requires no dependency, but Axios offers conveniences like automatic JSON parsing, request/response interceptors, and built-in timeout support. I choose based on project needs — Fetch for simplicity, Axios for larger apps needing those extras.

Does Fetch support progress events for uploads/downloads? Not natively for uploads (unlike XMLHttpRequest). For download progress, you can use the ReadableStream from response.body to track bytes received manually.

Why does my fetch request fail only in the browser but work in Postman? This is almost always a CORS issue — Postman doesn’t enforce CORS the way browsers do, since CORS is a browser security mechanism, not a server-side restriction that all clients obey.

Summary and Key Takeaways

Fetch modernized how I make HTTP requests in JavaScript, but it has sharp edges worth knowing:

  • Fetch only rejects on network failure — always check response.ok for HTTP errors.
  • Use AbortController for both timeouts and canceling stale requests.
  • Let the browser manage Content-Type for FormData uploads.
  • Fetch is now available natively in both browsers and Node.js, reducing the need for extra HTTP client libraries in many cases.

References

Total
0
Shares

Leave a Reply

Previous Post
Working with Web APIs in JavaScript

Working with Web APIs in JavaScript

Next Post
Monitoring and Performance Optimization in JavaScript

Monitoring and Performance Optimization in JavaScript: A Practical Deep Dive

Related Posts