Fetch API in JavaScript
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.
// 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.
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.awaitpauses that async function until the Promise settles; it does not freeze the browser.
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:
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)
// 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();
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.
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.
| 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. |
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
// 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:
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();
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.
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:
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));
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().
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
// 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);
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.
// 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:
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();
- 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.
- What does the Fetch API do, and how is it different from XMLHttpRequest?
- What does fetch() return?
- Why do you need to call .json() on the response? Why is it not automatic?
- Does fetch() throw an error for a 404 response? How do you handle that?
- What is response.ok and what values does it have?
- How do you send a POST request with fetch?
- What is the role of the Content-Type header in a POST request?
- How would you make two fetch requests at the same time?
- Can you use the response body more than once?
- How do you cancel a fetch request?
Videos for this topic will be added soon.