Fetch API in JavaScript

Why should you care about Fetch API?

Fetch is how modern apps talk to APIs, load live data, and send user actions to servers. If you build real projects, you will use it all the time.

The Fetch API is a built-in web API used to make HTTP requests and work with responses asynchronously. You can use it to get data, send data, update data, delete data, and communicate with web APIs without reloading the page.

Before Fetch, developers used something called XMLHttpRequest - a much older and clunkier way to do the same thing. Fetch is simpler, cleaner, and Promise-based, which means it works perfectly with async/await.

javascript

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

The fetch() function returns a Promise. That Promise resolves to a Response object - not the actual data yet. You still need to read the response body, which we will cover next.

fetch() returns a Promise, so understand this small model before reading the request examples: a Promise represents work that may finish later. It can be pending, fulfilled with a value, or rejected with an error.

javascript

const request = Promise.resolve("server reply");

request
  .then((value) => console.log(value))
  .catch((error) => console.error(error));

// async/await is another way to consume the same Promise
async function readReply() {
  const value = await request;
  console.log(value);
}

readReply();
  • .then() runs when the Promise fulfills.
  • .catch() handles rejection.
  • await pauses that async function until the Promise settles; it does not freeze the browser.
Fetch connection: the first Promise from fetch() resolves to a Response object. The response body is not parsed automatically; calling response.json() returns another Promise for the parsed data.

A GET request is used to fetch data from a server. It is the most common type of HTTP request. When you navigate to a URL in a browser, the browser normally sends an HTTP GET request to retrieve the resource.

By default, fetch() makes a GET request - you do not need to specify the method. Just pass the URL:

javascript

fetch("https://jsonplaceholder.typicode.com/users/1")
  .then(response => {
    if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
    return response.json();
  })
  .then(user => {
    console.log(user.name);    // Leanne Graham
    console.log(user.email);   // Sincere@april.biz
  })
  .catch(error => console.error("Request failed:", error));

The fetch call happens in two steps:

  • Step 1: The Promise resolves with a Response object when the server replies
  • Step 2: You call .json() on the response to read the actual data (this also returns a Promise)

javascript

// Cleaner with async/await
async function getUser() {
  const response = await fetch("https://jsonplaceholder.typicode.com/users/1");
  if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
  const user = await response.json();
  console.log(user.name);  // Leanne Graham
}

getUser();
Note: fetch() only rejects when there is a network problem (like no internet). A 404 or 500 response does NOT automatically throw an error. You need to check response.ok yourself.

Cancel with AbortController

Use AbortController when a request is no longer useful, such as when a newer search replaces an older search, a request is no longer needed, or a page or component is removed. Pass its signal to fetch(), then call abort() to cancel the request.

javascript

const controller = new AbortController();

async function searchUsers(query) {
  try {
    const response = await fetch(`/api/users?q=${encodeURIComponent(query)}`, {
      signal: controller.signal
    });

    if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
    return await response.json();
  } catch (error) {
    if (error.name === "AbortError") {
      console.log("Search was cancelled");
      return;
    }
    throw error;
  }
}

searchUsers("sam");
// Call this when a newer search replaces the old one:
// controller.abort();

For repeated searches, create a new controller for each request and abort the previous controller before starting the next request. Cancellation is not the same as an HTTP error: it is an intentional stop.

Fetch versus XMLHttpRequest

Both APIs can make HTTP requests. Fetch is the modern default for most new code, while XMLHttpRequest still appears in older applications and supports some legacy workflows.

Practical differences between Fetch and XMLHttpRequest.
Concern Fetch XMLHttpRequest
Style Promise-based; works naturally with async/await. Event and callback based with onload, onerror, and onreadystatechange.
HTTP errors Must check response.ok; a 404 does not reject automatically. Must inspect status in the load handler.
Cancellation Use AbortController. Call xhr.abort().
Progress and legacy support Readable modern API; download progress needs streams. Built-in upload/download progress events and broad legacy code support.
Recommendation: Fetch is generally the modern choice for new code, while XMLHttpRequest remains relevant in existing applications and cases where its built-in progress events are useful.

When fetch() gets a reply from the server, it gives you a Response object first. This object has metadata like the status code and headers. The actual data is inside the response body, and you need to read it separately.

The response body is exposed as a stream. Body-reading methods such as .json() and .text() consume that body, so choose the method that matches the data you expect:

  • response.json() - use when the server returns JSON data (most common)
  • response.text() - use when the server returns plain text or HTML
  • response.blob() - use for binary data like images or files

javascript

// Reading JSON response
async function getPosts() {
  const response = await fetch("https://jsonplaceholder.typicode.com/posts/1");
  if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
  const data = await response.json();
  console.log(data.title); // sunt aut facere repellat...
  console.log(data.body);  // the full body text
}

// Reading a text response
async function getHTML() {
  const response = await fetch("/about.html");
  if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
  const html = await response.text();
  console.log(html.substring(0, 100)); // first 100 chars of HTML
}

getPosts();

The Response object also has useful properties you can inspect:

javascript

async function inspect() {
  const response = await fetch("https://jsonplaceholder.typicode.com/posts/1");

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

  console.log(response.status);     // 200
  console.log(response.ok);         // true (status 200-299)
  console.log(response.statusText); // "OK"
  console.log(response.url);        // the request URL

  const data = await response.json();
  console.log(data.id); // 1
}

inspect();
Important: You can only read the response body once. If you call .json() on a response, you cannot call .text() on it afterward. It will throw an error saying the body is already used.

One key Fetch behavior to remember is this: it does not throw an error for HTTP status errors like 404 or 500. It only rejects when a network failure happens, such as no internet.

So a 404 response will go into your .then() block, not your .catch() block. You have to check response.ok manually.

javascript

async function getPost(id) {
  try {
    const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);

    // Check if the request was successful
    if (!response.ok) {
      throw new Error(`Request failed: ${response.status}`);
    }

    const data = await response.json();
    console.log(data.title);
  } catch (error) {
    console.error("Error:", error.message);
  }
}

getPost(1);    // works fine
getPost(9999); // Error: Request failed: 404

A reusable pattern for this is to create a helper function that handles the check every time:

javascript

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

// Now use it anywhere
safeFetch("https://jsonplaceholder.typicode.com/posts/1")
  .then(data => console.log(data.title))
  .catch(err => console.error(err.message));
Rule of thumb: Always check response.ok before reading the body. It is true for status codes 200–299 and false for anything else.

A POST request is used to send data to a server - like submitting a form, creating a new user, or saving a blog post. Unlike a GET request, you need to configure the request manually by passing an options object as the second argument to fetch().

javascript

async function createPost() {
  const newPost = {
    title: "My First Post",
    body: "This is the content of my post.",
    userId: 1
  };

  const response = await fetch("https://jsonplaceholder.typicode.com/posts", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify(newPost)
  });

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

  const data = await response.json();
  console.log(data.id);    // 101 (the new post ID)
  console.log(data.title); // My First Post
}

createPost();

Three things to remember when making a POST request:

  • Set method: "POST" - fetch defaults to GET
  • Set Content-Type: "application/json" in headers so the server knows what format the data is in
  • Use JSON.stringify() to convert your object to a JSON string for the body

javascript

// PUT updates an existing resource
async function updatePost(id) {
  const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`, {
    method: "PUT",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      id,
      title: "Updated Post",
      body: "Updated content",
      userId: 1
    })
  });

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

// DELETE removes a resource
async function deletePost(id) {
  const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`, {
    method: "DELETE"
  });

  if (response.ok) {
    console.log(`Post ${id} deleted successfully`);
  }
}

deletePost(1);
Remember: When sending a JSON request body, set the Content-Type: application/json header so the server knows how to interpret the body.

Fetch and async/await are a perfect combination. Fetch gives you the ability to talk to servers. Async/await makes that code clean and easy to follow. Together, they cover most of what you need for working with APIs in a real project.

javascript

// A complete real-world pattern
async function getUserPosts(userId) {
  try {
    // Step 1: Get the user
    const userRes = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`);
    if (!userRes.ok) throw new Error("User not found");
    const user = await userRes.json();

    // Step 2: Get their posts
    const postsRes = await fetch(`https://jsonplaceholder.typicode.com/posts?userId=${userId}`);
    if (!postsRes.ok) throw new Error("Posts not found");
    const posts = await postsRes.json();

    console.log(`${user.name} has ${posts.length} posts`);
    // Leanne Graham has 10 posts
  } catch (error) {
    console.error("Failed:", error.message);
  }
}

getUserPosts(1);

When multiple requests do not depend on each other, run them in parallel using Promise.all() to save time:

javascript

async function loadDashboard() {
  try {
    const [usersRes, postsRes] = await Promise.all([
      fetch("https://jsonplaceholder.typicode.com/users"),
      fetch("https://jsonplaceholder.typicode.com/posts")
    ]);

    if (!usersRes.ok || !postsRes.ok) {
      throw new Error("One of the requests failed");
    }

    const users = await usersRes.json();
    const posts = await postsRes.json();

    console.log("Users:", users.length);  // 10
    console.log("Posts:", posts.length);  // 100
  } catch (error) {
    console.error("Failed to load:", error.message);
  }
}

loadDashboard();
Tip: In a real app, you would show a loading state while fetch runs, then show the data when it arrives, and show an error message if something goes wrong. This pattern covers all three cases.
  • fetch() is a built-in browser function to make HTTP requests
  • It returns a Promise that resolves to a Response object - not the data directly
  • Call response.json() or response.text() to read the response body
  • fetch() only rejects on network failure - always check response.ok for HTTP errors
  • For GET requests, just pass a URL. No extra configuration needed.
  • For POST requests, provide method, headers, and a JSON-stringified body
  • Fetch works great with async/await for clean, readable code
  • Use Promise.all() to run multiple fetch calls in parallel
  • Use AbortController to cancel requests that are no longer needed
  • Fetch is Promise-based and modern; XMLHttpRequest remains useful for legacy code and built-in progress events

What's next? Now that you know how to fetch data, let's organize JavaScript into reusable files in the next tutorial.

Videos for this topic will be added soon.

Reviewed by

SimplyJavaScript Editorial Team

Technical editors and JavaScript educators with hands-on experience building frontend projects, writing learning material, and reviewing tutorials for clarity, accuracy, and beginner-friendly guidance.