JavaScript Fetch API: Making HTTP Requests

The Fetch API is the modern standard for making HTTP requests in JavaScript. It replaced the clunky XMLHttpRequest with a promise-based interface that's cleaner and more intuitive. Every modern...

Key Insights

  • Fetch API uses promises and a cleaner syntax than XMLHttpRequest, but it doesn’t reject on HTTP error status codes—you must check response.ok manually
  • Always handle both network failures and HTTP errors separately; network errors reject the promise while HTTP errors (404, 500) still resolve it
  • Use AbortController to implement request timeouts and cancellation, which is critical for production applications to prevent hanging requests

Introduction to Fetch API

The Fetch API is the modern standard for making HTTP requests in JavaScript. It replaced the clunky XMLHttpRequest with a promise-based interface that’s cleaner and more intuitive. Every modern browser supports it, and it’s the de facto choice for client-side HTTP communication.

Here’s why Fetch won over XMLHttpRequest:

// Old way with XMLHttpRequest
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.example.com/data');
xhr.onload = function() {
  if (xhr.status === 200) {
    const data = JSON.parse(xhr.responseText);
    console.log(data);
  }
};
xhr.onerror = function() {
  console.error('Request failed');
};
xhr.send();

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

The Fetch version is shorter, more readable, and integrates seamlessly with modern JavaScript patterns like async/await. But it comes with gotchas that you need to understand.

Making GET Requests

GET requests are straightforward with Fetch. The API returns a promise that resolves to a Response object. Here’s the basic pattern:

fetch('https://jsonplaceholder.typicode.com/posts/1')
  .then(response => {
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return response.json();
  })
  .then(data => {
    console.log(data.title);
    console.log(data.body);
  })
  .catch(error => {
    console.error('Fetch error:', error);
  });

Notice the response.ok check. This is crucial because Fetch only rejects on network failures, not HTTP errors. A 404 or 500 status code will still resolve the promise. You must explicitly check the status.

For cleaner error handling, separate your concerns:

async function getPost(id) {
  try {
    const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
    
    if (!response.ok) {
      throw new Error(`Failed to fetch post: ${response.status}`);
    }
    
    const post = await response.json();
    return post;
  } catch (error) {
    console.error('Error fetching post:', error);
    throw error; // Re-throw for caller to handle
  }
}

POST, PUT, and DELETE Requests

Sending data requires configuring the request with an options object. You’ll specify the method, headers, and body:

// POST request to create a resource
async function createPost(postData) {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(postData)
  });
  
  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }
  
  return await response.json();
}

// Usage
createPost({ title: 'New Post', body: 'Content here', userId: 1 })
  .then(data => console.log('Created:', data));

PUT requests follow the same pattern for updates:

async function updatePost(id, updates) {
  const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`, {
    method: 'PUT',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(updates)
  });
  
  if (!response.ok) {
    throw new Error(`Update failed: ${response.status}`);
  }
  
  return await response.json();
}

DELETE requests are simpler since they typically don’t require a body:

async function deletePost(id) {
  const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`, {
    method: 'DELETE'
  });
  
  if (!response.ok) {
    throw new Error(`Delete failed: ${response.status}`);
  }
  
  return true;
}

Always set the Content-Type header when sending JSON. Some APIs are strict about this and will reject requests without it.

Working with Response Objects

The Response object contains more than just your data. Understanding its properties helps you build robust applications:

async function examineResponse() {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts/1');
  
  console.log('Status:', response.status);        // 200
  console.log('OK:', response.ok);                // true (status 200-299)
  console.log('Status Text:', response.statusText); // "OK"
  console.log('URL:', response.url);              // Final URL after redirects
  
  // Access headers
  console.log('Content-Type:', response.headers.get('Content-Type'));
  
  // Different ways to extract data
  const jsonData = await response.json();    // Parse as JSON
  // const textData = await response.text();  // Get as plain text
  // const blobData = await response.blob();  // Get as Blob (for files)
}

Important: You can only read the response body once. Calling response.json() consumes the stream, so you can’t call it again or use response.text() afterward. Clone the response if you need to read it multiple times:

const response = await fetch(url);
const clone = response.clone();

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

Advanced Fetch Patterns

Async/await makes Fetch code significantly cleaner. Here’s a complete example with proper configuration:

async function fetchWithConfig(url, options = {}) {
  const defaultOptions = {
    mode: 'cors',           // cors, no-cors, same-origin
    credentials: 'same-origin', // include, same-origin, omit
    cache: 'default',       // default, no-cache, reload, force-cache
    ...options
  };
  
  const response = await fetch(url, defaultOptions);
  
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
  }
  
  return await response.json();
}

Request timeouts are critical for production. Fetch doesn’t have built-in timeout support, but AbortController provides the mechanism:

async function fetchWithTimeout(url, timeout = 5000) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeout);
  
  try {
    const response = await fetch(url, {
      signal: controller.signal
    });
    
    clearTimeout(timeoutId);
    
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    
    return await response.json();
  } catch (error) {
    if (error.name === 'AbortError') {
      throw new Error(`Request timeout after ${timeout}ms`);
    }
    throw error;
  }
}

You can also use AbortController for manual cancellation, useful in React components that unmount:

let controller;

function searchAPI(query) {
  // Cancel previous request if still pending
  if (controller) {
    controller.abort();
  }
  
  controller = new AbortController();
  
  return fetch(`/api/search?q=${query}`, {
    signal: controller.signal
  });
}

Error Handling Best Practices

Production code needs comprehensive error handling. Here’s a robust wrapper that handles retries:

async function fetchWithRetry(url, options = {}, maxRetries = 3) {
  const { retries = 0, ...fetchOptions } = options;
  
  try {
    const response = await fetch(url, fetchOptions);
    
    // Handle HTTP errors
    if (!response.ok) {
      // Don't retry client errors (4xx), only server errors (5xx)
      if (response.status >= 500 && retries < maxRetries) {
        const delay = Math.pow(2, retries) * 1000; // Exponential backoff
        await new Promise(resolve => setTimeout(resolve, delay));
        return fetchWithRetry(url, { ...options, retries: retries + 1 }, maxRetries);
      }
      
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }
    
    return await response.json();
  } catch (error) {
    // Network errors - retry if we haven't exceeded max retries
    if (retries < maxRetries && error.name !== 'AbortError') {
      const delay = Math.pow(2, retries) * 1000;
      await new Promise(resolve => setTimeout(resolve, delay));
      return fetchWithRetry(url, { ...options, retries: retries + 1 }, maxRetries);
    }
    
    throw error;
  }
}

// Usage
fetchWithRetry('https://api.example.com/data')
  .then(data => console.log(data))
  .catch(error => console.error('Failed after retries:', error));

This pattern implements exponential backoff and distinguishes between client errors (don’t retry) and server/network errors (retry with backoff).

Conclusion

The Fetch API is the right choice for most HTTP requests in modern JavaScript applications. It’s built into browsers, uses promises naturally, and integrates perfectly with async/await. The main gotchas—manual status checking and no built-in timeout—are easily handled with wrapper functions.

Use Fetch for standard REST API calls. Consider libraries like Axios only if you need features like automatic request/response transformation, interceptors, or better default behavior for error handling. For most projects, Fetch with a few utility functions gives you everything you need without additional dependencies.

The patterns shown here—timeout handling, retry logic, and proper error checking—will save you from production headaches. Implement them early, and your API integration code will be robust and maintainable.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.