JavaScript Fetch API and HTTP Requests Guide 2026

JavaScript Fetch API: Making HTTP Requests the Modern Way

The JavaScript Fetch API is the modern, built-in way to make HTTP requests from the browser and Node.js. It replaces the older XMLHttpRequest with a cleaner, promise-based interface that works seamlessly with async/await. Whether you are calling REST APIs, submitting forms, or loading data dynamically, the Fetch API is the tool you will use daily as a JavaScript developer.

In this lesson, you will learn how to make GET, POST, PUT, and DELETE requests, handle response data and errors, work with headers and authentication, and build real-world API integration patterns.

What Is the Fetch API?

The Fetch API provides a global fetch() function that returns a Promise resolving to the server’s Response. It is available in all modern browsers and in Node.js 18+.

// Simplest possible fetch
const response = await fetch("https://api.example.com/data");
const data = await response.json();
console.log(data);

That is a complete HTTP GET request in two lines. Compare this with the older XMLHttpRequest approach, which required event listeners, manual state tracking, and callback management. The MDN Fetch API documentation is the definitive reference.

Basic GET Request

GET requests are the default — just pass a URL to fetch():

// Fetching a list of users
async function getUsers() {
  const response = await fetch("https://jsonplaceholder.typicode.com/users");
  const users = await response.json();
  return users;
}

// Fetching with query parameters
async function searchProducts(query, page = 1) {
  const params = new URLSearchParams({
    q: query,
    page: page,
    limit: 20,
  });

  const response = await fetch(`/api/products?${params}`);
  return response.json();
}

// Usage
const results = await searchProducts("laptop", 2);
// Fetches: /api/products?q=laptop&page=2&limit=20

Use URLSearchParams to safely build query strings — it automatically handles encoding special characters. This is much safer than manual string concatenation.

The Response Object

The fetch() promise resolves to a Response object with several useful properties and methods:

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

// Properties
console.log(response.ok);          // true if status is 200-299
console.log(response.status);      // HTTP status code (200, 404, 500, etc.)
console.log(response.statusText);  // "OK", "Not Found", etc.
console.log(response.url);         // final URL (after redirects)
console.log(response.redirected);  // true if request was redirected
console.log(response.type);        // "basic", "cors", "opaque"

// Headers
console.log(response.headers.get("Content-Type"));
console.log(response.headers.get("X-Total-Count"));

// Iterate all headers
for (const [key, value] of response.headers) {
  console.log(`${key}: ${value}`);
}

Parsing Response Data

The Response object provides several methods to parse the body. Each method returns a Promise and can only be called once (the body is a stream that can only be consumed once):

// JSON (most common for APIs)
const data = await response.json();

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

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

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

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

Working with JSON is the most common use case. Here is a complete example that fetches and renders data:

async function loadArticles() {
  const response = await fetch("/api/articles");

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

  const articles = await response.json();

  // Render articles to the DOM
  const container = document.getElementById("articles");
  container.innerHTML = articles
    .map(article => `
      <article>
        <h2>${article.title}</h2>
        <p>${article.excerpt}</p>
        <a href="${article.url}">Read more</a>
      </article>
    `)
    .join("");
}

Making POST Requests

To send data to a server, use POST with a request body:

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

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

  return response.json();
}

// Usage
const newUser = await createUser({
  name: "Jane Doe",
  email: "jane@example.com",
  role: "developer",
});
console.log("Created user:", newUser.id);

Sending Form Data

// FormData for file uploads and multipart forms
async function uploadAvatar(file) {
  const formData = new FormData();
  formData.append("avatar", file);
  formData.append("userId", "123");

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

  return response.json();
}

// From an HTML form
const form = document.querySelector("#signup-form");
form.addEventListener("submit", async (e) => {
  e.preventDefault();
  const formData = new FormData(form);

  const response = await fetch("/api/signup", {
    method: "POST",
    body: formData,
  });

  const result = await response.json();
  console.log(result);
});

Notice that with FormData, you should NOT set the Content-Type header manually. The browser automatically sets it to multipart/form-data with the correct boundary string. Understanding objects and how data is serialized is important for API communication.

PUT, PATCH, and DELETE Requests

// PUT — replace entire resource
async function updateUser(userId, userData) {
  const response = await fetch(`/api/users/${userId}`, {
    method: "PUT",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(userData),
  });
  return response.json();
}

// PATCH — partial update
async function updateEmail(userId, newEmail) {
  const response = await fetch(`/api/users/${userId}`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: newEmail }),
  });
  return response.json();
}

// DELETE
async function deleteUser(userId) {
  const response = await fetch(`/api/users/${userId}`, {
    method: "DELETE",
  });

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

  // DELETE often returns 204 No Content — no body to parse
  return response.status === 204 ? null : response.json();
}

These HTTP methods form the foundation of REST API communication. GET reads, POST creates, PUT/PATCH updates, and DELETE removes resources. The MDN HTTP methods guide explains the semantics of each method.

Headers and Authentication

HTTP headers control request behavior, authentication, content negotiation, and more:

// Using the Headers object
const headers = new Headers();
headers.append("Content-Type", "application/json");
headers.append("Authorization", "Bearer eyJhbGciOiJIUzI1NiIs...");
headers.append("Accept-Language", "en-US");

const response = await fetch("/api/data", { headers });

// Or use a plain object (more common)
const response2 = await fetch("/api/protected", {
  headers: {
    "Content-Type": "application/json",
    "Authorization": "Bearer " + accessToken,
    "X-Request-ID": crypto.randomUUID(),
  },
});

Token-Based Authentication

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.status === 401) {
      // Token expired — could refresh here
      throw new Error("Authentication expired. Please log in again.");
    }

    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),
    });
  }
}

// Usage
const api = new ApiClient("https://api.example.com", userToken);
const users = await api.get("/users");
const newPost = await api.post("/posts", { title: "Hello", body: "World" });

Error Handling with Fetch

Critical point: fetch() only rejects on network errors (DNS failure, no internet, etc.). It does NOT reject on HTTP error statuses like 404 or 500. You must check response.ok yourself:

// WRONG: this won't catch 404 or 500 errors
try {
  const data = await fetch("/api/missing-endpoint");
  // data is a Response with status 404 — no error thrown!
} catch (error) {
  // Only catches network failures
}

// RIGHT: check response.ok
async function safeFetch(url, options = {}) {
  try {
    const response = await fetch(url, options);

    if (!response.ok) {
      // Parse error body if available
      let errorMessage;
      try {
        const errorData = await response.json();
        errorMessage = errorData.message || errorData.error;
      } catch {
        errorMessage = response.statusText;
      }

      throw new Error(`HTTP ${response.status}: ${errorMessage}`);
    }

    return response;
  } catch (error) {
    if (error.name === "TypeError") {
      // Network error — no connection, DNS failure, CORS blocked
      throw new Error("Network error: please check your connection");
    }
    throw error; // re-throw HTTP errors
  }
}

This is one of the most common sources of bugs with the Fetch API. Always check response.ok or response.status. Our lesson on error handling covers general error handling patterns in more depth. See also the javascript.info fetch tutorial for more examples.

Canceling Requests with AbortController

AbortController lets you cancel in-flight fetch requests — essential for search-as-you-type, component cleanup, and timeout implementations:

// Basic cancellation
const controller = new AbortController();

fetch("/api/large-dataset", { signal: controller.signal })
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => {
    if (error.name === "AbortError") {
      console.log("Request was canceled");
    } else {
      console.error("Fetch failed:", error);
    }
  });

// Cancel the request
controller.abort();

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

  try {
    const response = await fetch(url, { signal: controller.signal });
    return response;
  } finally {
    clearTimeout(timeoutId);
  }
}

Search-as-You-Type with Cancellation

let currentController = null;

async function search(query) {
  // Cancel the previous request if still pending
  if (currentController) {
    currentController.abort();
  }

  currentController = new AbortController();

  try {
    const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
      signal: currentController.signal,
    });
    const results = await response.json();
    displayResults(results);
  } catch (error) {
    if (error.name !== "AbortError") {
      console.error("Search failed:", error);
    }
    // Silently ignore AbortError — it means a newer search replaced this one
  }
}

const searchInput = document.querySelector("#search");
searchInput.addEventListener("input", (e) => {
  search(e.target.value);
});

The MDN AbortController documentation covers additional use cases including canceling multiple requests with a single controller.

Real-World API Patterns

CRUD Operations Helper

const api = {
  baseUrl: "/api",

  async get(endpoint) {
    const response = await fetch(`${this.baseUrl}${endpoint}`);
    if (!response.ok) throw new Error(`GET ${endpoint}: ${response.status}`);
    return response.json();
  },

  async post(endpoint, data) {
    const response = await fetch(`${this.baseUrl}${endpoint}`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(data),
    });
    if (!response.ok) throw new Error(`POST ${endpoint}: ${response.status}`);
    return response.json();
  },

  async put(endpoint, data) {
    const response = await fetch(`${this.baseUrl}${endpoint}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(data),
    });
    if (!response.ok) throw new Error(`PUT ${endpoint}: ${response.status}`);
    return response.json();
  },

  async delete(endpoint) {
    const response = await fetch(`${this.baseUrl}${endpoint}`, {
      method: "DELETE",
    });
    if (!response.ok) throw new Error(`DELETE ${endpoint}: ${response.status}`);
    return response.status === 204 ? null : response.json();
  },
};

// Usage
const users = await api.get("/users");
const newUser = await api.post("/users", { name: "Jane" });
await api.put(`/users/${newUser.id}`, { name: "Jane Doe" });
await api.delete(`/users/${newUser.id}`);

Paginated Data Loading

async function fetchAllPages(endpoint, pageSize = 50) {
  const allData = [];
  let page = 1;
  let hasMore = true;

  while (hasMore) {
    const response = await fetch(
      `${endpoint}?page=${page}&limit=${pageSize}`
    );
    const data = await response.json();

    allData.push(...data.items);

    hasMore = data.items.length === pageSize;
    page++;
  }

  return allData;
}

const allProducts = await fetchAllPages("/api/products", 100);

This pagination pattern uses loops to iterate through pages. For large datasets, consider using array methods to process items as they arrive.

Common Mistakes and Pitfalls

1. Not Checking response.ok

// BUG: fetch doesn't throw on HTTP errors
const data = await fetch("/api/missing").then(r => r.json());
// Might parse a 404 error page as JSON and crash!

// FIX: always check status
const response = await fetch("/api/missing");
if (!response.ok) throw new Error(`HTTP ${response.status}`);

2. Setting Content-Type with FormData

// BUG: manually setting Content-Type breaks FormData boundary
const formData = new FormData();
formData.append("file", fileInput.files[0]);

await fetch("/api/upload", {
  method: "POST",
  headers: { "Content-Type": "multipart/form-data" }, // WRONG!
  body: formData,
});

// FIX: let the browser set Content-Type automatically
await fetch("/api/upload", {
  method: "POST",
  body: formData, // browser adds correct Content-Type with boundary
});

3. Reading the Body Twice

// BUG: body can only be consumed once
const response = await fetch("/api/data");
const text = await response.text();
const json = await response.json(); // TypeError: body stream already read

// FIX: clone the response if you need to read it multiple ways
const response2 = await fetch("/api/data");
const text2 = await response2.clone().text();
const json2 = await response2.json();

4. Ignoring CORS Issues

// Browser blocks cross-origin requests unless the server allows it
// The error message is usually: "Access to fetch has been blocked by CORS policy"

// Solutions:
// 1. Server adds CORS headers: Access-Control-Allow-Origin: *
// 2. Use a proxy server
// 3. Use mode: "no-cors" (but you can't read the response)
await fetch("https://other-domain.com/api", {
  mode: "cors", // default — requires server CORS headers
});

Summary and Key Takeaways

  • The Fetch API provides a clean, promise-based interface for HTTP requests
  • Use await fetch(url) for GET requests and add method, headers, and body options for others
  • fetch() does NOT reject on HTTP errors — always check response.ok
  • Use response.json(), .text(), or .blob() to parse the response body
  • URLSearchParams safely builds query strings
  • AbortController cancels in-flight requests for timeouts and cleanup
  • Do NOT set Content-Type when using FormData — let the browser handle it
  • The response body is a stream — it can only be consumed once (use .clone() if needed)
  • Build reusable API client classes for production applications

The Fetch API is your gateway to interacting with any backend service. Combined with async/await, it makes network communication in JavaScript straightforward and maintainable. In the next lesson on JavaScript modules, you will learn how to organize your code into reusable, importable modules. For the complete Fetch specification, see the WHATWG Fetch Standard.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *