The Fetch API

The fetch function is the modern, Promise-based way to make HTTP requests. It is available in browsers and in Node.js (v18+). It uses Promises, async/await, and error handling, so it pulls together everything we have covered in this chapter.

const response = await fetch("https://api.example.com/users/1");
const user = await response.json();
console.log(user.name); // "Alice"

Notice that fetch returns a Promise that resolves to a Response object. You then call a method on the response (like .json()) to read the body, and that returns another Promise. So one request takes two awaits. You will see this pattern in every fetch call.

Making GET Requests

A basic fetch call takes a URL and makes a GET request:

fetch("https://api.example.com/posts")
  .then((response) => response.json())
  .then((posts) => console.log(posts))
  .catch((error) => console.error("Request failed:", error.message));

Or with async/await, which is how you will write most fetch code in practice:

async function loadPosts() {
  const response = await fetch("https://api.example.com/posts");
  const posts = await response.json();
  console.log(posts);
}

The Response Object

The fetch Promise resolves to a Response object with several useful properties:

const response = await fetch("https://api.example.com/users/1");

console.log(response.status); // 200
console.log(response.statusText); // "OK"
console.log(response.ok); // true (status is 200-299)
console.log(response.headers.get("Content-Type")); // "application/json; charset=utf-8"
console.log(response.url); // "https://api.example.com/users/1"

The ok property is a boolean. It is true when status is in the 200–299 range. You will use it constantly for error checking.

Reading the Response Body

The body can only be read once, using one of these methods (each returns a Promise):

Method Returns Use case
.json() Parsed JavaScript object JSON APIs
.text() String Plain text, HTML, XML
.blob() Blob Images, files
.arrayBuffer() ArrayBuffer Binary data
.formData() FormData Form submissions
// Parse JSON
const data = await response.json();

// Read as plain text
const html = await response.text();

Fetch Does Not Reject on HTTP Errors

fetch only rejects on network failures (DNS errors, no internet, CORS issues). An HTTP error like 404 or 500 is still a successful network response, and the Promise fulfills with that error status.

// This does NOT throw or reject!
const response = await fetch("https://api.example.com/nonexistent");
console.log(response.status); // 404
console.log(response.ok); // false

You must check response.ok yourself:

async function fetchJSON(url) {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
  }
  return response.json();
}

Sending Data with POST, PUT, and DELETE

To make requests other than GET, pass an options object as the second argument to fetch:

const response = await fetch("https://api.example.com/posts", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    title: "New Post",
    content: "Hello, world!",
  }),
});

The key options:

  • method: The HTTP method ("GET", "POST", "PUT", "PATCH", "DELETE")
  • headers: An object (or Headers instance) with request headers
  • body: The request body — a string, FormData, Blob, etc. (not allowed for GET)

Updating a Resource with PUT

async function updatePost(id, data) {
  const response = await fetch(`https://api.example.com/posts/${id}`, {
    method: "PUT",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(data),
  });
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }
  return response.json();
}

const updated = await updatePost(1, { title: "Updated Title" });

Deleting a Resource

async function deletePost(id) {
  const response = await fetch(`https://api.example.com/posts/${id}`, {
    method: "DELETE",
  });
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }
}

Keep in mind that DELETE responses often have no body, so do not call .json() unless you know the API returns one.

Setting Request Headers

Use the headers option to send metadata like authentication tokens or content types:

const response = await fetch("https://api.example.com/profile", {
  headers: {
    Authorization: "Bearer eyJhbGciOiJIUzI1NiIs...",
    Accept: "application/json",
  },
});

You can also use the Headers constructor, which provides methods like .append() and .has():

const headers = new Headers();
headers.append("Content-Type", "application/json");
headers.append("X-Custom-Header", "my-value");

const response = await fetch("https://api.example.com/data", { headers });

Canceling Requests with AbortController

Sometimes you need to cancel a request — a user navigates away, a timeout expires, or a new request supersedes the old one. The AbortController API handles this:

const controller = new AbortController();

fetch("https://api.example.com/large-data", {
  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("Request failed:", error.message);
    }
  });

// Cancel the request
controller.abort();

The fetch Promise rejects with an AbortError.

Request Timeout with AbortSignal.timeout()

A common use case is canceling requests that take too long. AbortSignal.timeout() does that for you:

async function fetchWithTimeout(url, ms = 5000) {
  const response = await fetch(url, {
    signal: AbortSignal.timeout(ms),
  });
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }
  return response.json();
}

try {
  const data = await fetchWithTimeout("https://api.example.com/slow", 3000);
} catch (error) {
  if (error.name === "TimeoutError") {
    console.error("Request timed out");
  }
}

Putting It All Together

Here is an example that uses fetch with async/await, error handling, and parallel requests, all with the patterns from this chapter:

class ApiClient {
  constructor(baseUrl) {
    this.baseUrl = baseUrl;
  }

  async request(endpoint, options = {}) {
    const url = `${this.baseUrl}${endpoint}`;
    const response = await fetch(url, {
      ...options,
      headers: {
        "Content-Type": "application/json",
        ...options.headers,
      },
      body: options.body ? JSON.stringify(options.body) : undefined,
    });

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

    const contentType = response.headers.get("Content-Type");
    if (contentType?.includes("application/json")) {
      return response.json();
    }
    return response.text();
  }

  get(endpoint) {
    return this.request(endpoint);
  }

  post(endpoint, body) {
    return this.request(endpoint, { method: "POST", body });
  }

  put(endpoint, body) {
    return this.request(endpoint, { method: "PUT", body });
  }

  delete(endpoint) {
    return this.request(endpoint, { method: "DELETE" });
  }
}

// Usage with async/await and error handling
const api = new ApiClient("https://api.example.com");

try {
  // Parallel requests with Promise.all
  const [user, posts] = await Promise.all([
    api.get("/users/1"),
    api.get("/users/1/posts"),
  ]);

  console.log(`${user.name} has ${posts.length} posts`);

  // Create a new post
  const newPost = await api.post("/posts", {
    title: "Async JavaScript",
    userId: user.id,
  });
  console.log("Created post:", newPost.id);
} catch (error) {
  console.error("API error:", error.message);
}

This example uses Promises, Promise combinators, async/await, and error handling with fetch.