Skip to main content

How to Use the Fetch API for Network Requests in JavaScript

Introduction

Almost every modern web application needs to communicate with servers. Whether you are loading user data, submitting a form, fetching images, or interacting with a third-party API, you need a way to make HTTP requests from JavaScript. The Fetch API is the modern, built-in solution for this.

fetch() is a global function available in all modern browsers and in Node.js (since version 18). It replaces the older XMLHttpRequest with a cleaner, Promise-based interface that works naturally with async/await. Instead of dealing with callbacks, event listeners, and complex state management, you write straightforward asynchronous code that reads almost like English.

In this guide, you will learn how to make GET and POST requests, inspect response objects, read response bodies in different formats, set custom headers, send JSON data to servers, and handle the common pitfalls that trip up developers working with fetch() for the first time.

Basic fetch() Syntax

At its simplest, fetch() takes a URL and returns a Promise that resolves to a Response object:

const response = await fetch('https://api.example.com/data');

Or using .then() syntax:

fetch('https://api.example.com/data')
.then(response => {
// work with the response
});

A Complete First Example

Let us fetch a user from the JSONPlaceholder API (a free test API):

async function getUser() {
const response = await fetch('https://jsonplaceholder.typicode.com/users/1');
const user = await response.json();
console.log(user);
}

getUser();

Output:

{
id: 1,
name: "Leanne Graham",
username: "Bret",
email: "Sincere@april.biz",
// ... more fields
}

Notice that fetching data is a two-step process:

  1. fetch(url) sends the request and resolves when the server responds with headers (the body may still be downloading)
  2. response.json() reads and parses the body of the response (this is also asynchronous and returns a Promise)

This two-step process is intentional. It allows you to inspect the response status and headers before committing to downloading and parsing the entire body, which could be very large.

async function getUser() {
// Step 1: Get the response (headers are available)
const response = await fetch('https://jsonplaceholder.typicode.com/users/1');

// You can check status before reading the body
console.log(response.status); // 200
console.log(response.ok); // true

// Step 2: Read and parse the body
const user = await response.json();
console.log(user.name); // "Leanne Graham"
}

The fetch() Function Signature

fetch(resource)
fetch(resource, options)
  • resource: A URL string or a Request object
  • options: An optional object with settings like method, headers, body, and more
// Simple GET request (options not needed)
fetch('https://api.example.com/posts');

// POST request with options
fetch('https://api.example.com/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Hello' })
});

The Response Object: status, ok, headers

When fetch() resolves, it returns a Response object packed with information about the server's reply.

Response Status Properties

const response = await fetch('https://jsonplaceholder.typicode.com/users/1');

console.log(response.status); // 200 (HTTP status code)
console.log(response.statusText); // "OK" (HTTP status message)
console.log(response.ok); // true (status is in the 200-299 range)
console.log(response.type); // "cors", "basic", "opaque", etc.
console.log(response.url); // The final URL after redirects
console.log(response.redirected); // true if the request was redirected

The most important properties are:

PropertyTypeDescription
statusNumberHTTP status code (200, 404, 500, etc.)
statusTextStringHTTP status message ("OK", "Not Found", etc.)
okBooleantrue if status is between 200 and 299
headersHeadersThe response headers object
urlStringThe final URL of the response
redirectedBooleanWhether the response is the result of a redirect

A Critical Concept: fetch() Does Not Reject on HTTP Errors

This is the single most common source of bugs when working with fetch(). Unlike libraries such as Axios, fetch() only rejects the Promise on network failures (no internet, DNS resolution failed, server unreachable). HTTP error responses like 404 or 500 are considered successful from the network perspective and resolve the Promise normally.

// WRONG: Assumes fetch rejects on 404
async function getUserBroken(id) {
try {
const response = await fetch(`https://api.example.com/users/${id}`);
const user = await response.json(); // This runs even on 404!
return user;
} catch (error) {
// This only catches NETWORK errors, NOT 404 or 500
console.log('Error:', error);
}
}
// CORRECT: Check response.ok before reading the body
async function getUser(id) {
const response = await fetch(`https://api.example.com/users/${id}`);

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

const user = await response.json();
return user;
}

// Usage with error handling
try {
const user = await getUser(999); // Non-existent user
} catch (error) {
console.log(error.message); // "HTTP error! Status: 404"
}
caution

Always check response.ok (or response.status) before reading the body. A fetch() Promise that resolves does not mean the request was successful. It only means the network round-trip completed. A 404, 403, or 500 response still resolves the Promise.

Response Headers

The response.headers property is a Headers object. You can read individual headers or iterate over all of them:

const response = await fetch('https://jsonplaceholder.typicode.com/users');

// Read a specific header
console.log(response.headers.get('Content-Type'));
// "application/json; charset=utf-8"

console.log(response.headers.get('Content-Length'));
// null or a number string (depends on the server)

// Check if a header exists
console.log(response.headers.has('Content-Type')); // true

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

Output (example):

cache-control: max-age=43200
content-type: application/json; charset=utf-8
expires: -1
pragma: no-cache
note

Due to CORS restrictions, JavaScript can only access a limited set of response headers by default. These "safe" headers are: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, and Pragma. To expose additional headers, the server must send an Access-Control-Expose-Headers header listing them.

Reading the Body: json(), text(), blob(), arrayBuffer(), formData()

The Response object provides several methods to read the body in different formats. Each method returns a Promise, and you can only read the body once. After calling any body-reading method, the body stream is consumed and cannot be read again.

response.json(): Parse as JSON

The most commonly used method. Parses the body as JSON and returns the resulting JavaScript object or array:

const response = await fetch('https://jsonplaceholder.typicode.com/posts/1');
const post = await response.json();

console.log(post.title); // "sunt aut facere repellat provident..."
console.log(typeof post); // "object"

If the response body is not valid JSON, response.json() throws a SyntaxError:

try {
const response = await fetch('https://example.com/not-json-endpoint');
const data = await response.json();
} catch (error) {
console.log(error); // SyntaxError: Unexpected token < in JSON at position 0
// This typically means the server returned HTML instead of JSON
}

response.text(): Read as Plain Text

Returns the body as a plain string. Useful for HTML, plain text, CSV, XML, or when you need the raw content:

const response = await fetch('https://example.com');
const html = await response.text();

console.log(html); // "<!doctype html><html>..."
console.log(typeof html); // "string"

A practical use case: fetching and inserting HTML content:

async function loadPartial(url, container) {
const response = await fetch(url);

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

const html = await response.text();
container.innerHTML = html;
}

const sidebar = document.getElementById('sidebar');
await loadPartial('/partials/sidebar.html', sidebar);

response.blob(): Read as Binary Blob

Returns the body as a Blob object. Essential for handling binary data like images, PDFs, audio, or video:

async function loadImage(url) {
const response = await fetch(url);

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

const blob = await response.blob();

// Create a local URL for the blob
const objectURL = URL.createObjectURL(blob);

// Use it as an image source
const img = document.createElement('img');
img.src = objectURL;
document.body.appendChild(img);

// Clean up the URL when the image loads
img.onload = () => URL.revokeObjectURL(objectURL);
}

await loadImage('https://picsum.photos/300/200');

Download a file programmatically:

async function downloadFile(url, filename) {
const response = await fetch(url);
const blob = await response.blob();

const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();

URL.revokeObjectURL(link.href);
}

await downloadFile('/reports/monthly.pdf', 'report.pdf');

response.arrayBuffer(): Read as Raw Binary

Returns the body as an ArrayBuffer, which gives you low-level access to the binary data. Useful for processing binary formats, audio decoding, or working with WebAssembly:

async function loadAudio(url) {
const response = await fetch(url);
const buffer = await response.arrayBuffer();

const audioContext = new AudioContext();
const audioBuffer = await audioContext.decodeAudioData(buffer);

const source = audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioContext.destination);
source.start();
}

Working with binary data using TypedArrays:

const response = await fetch('/data/binary-file.bin');
const buffer = await response.arrayBuffer();
const bytes = new Uint8Array(buffer);

console.log(`File size: ${bytes.length} bytes`);
console.log(`First byte: ${bytes[0]}`);
console.log(`Last byte: ${bytes[bytes.length - 1]}`);

response.formData(): Read as FormData

Parses the body as FormData. This is primarily useful for Service Workers intercepting form submissions:

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

console.log(formData.get('username'));
console.log(formData.get('email'));

The Body Can Only Be Read Once

A critical rule: once you call any body-reading method, the body stream is consumed. Calling another method will throw an error:

const response = await fetch('https://jsonplaceholder.typicode.com/posts/1');

const text = await response.text(); // Works fine
const json = await response.json(); // TypeError: body stream already read

If you need to read the body in multiple formats, clone the response first:

const response = await fetch('https://jsonplaceholder.typicode.com/posts/1');

// Clone before reading
const clone = response.clone();

const text = await response.text(); // Read original as text
const json = await clone.json(); // Read clone as JSON

console.log(typeof text); // "string"
console.log(typeof json); // "object"

Checking the Content-Type Before Reading

In real applications, you might not always know what format the server will return. Check the Content-Type header to choose the right reading method:

async function fetchData(url) {
const response = await fetch(url);

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

const contentType = response.headers.get('Content-Type') || '';

if (contentType.includes('application/json')) {
return await response.json();
} else if (contentType.includes('text/')) {
return await response.text();
} else if (contentType.includes('image/') || contentType.includes('application/pdf')) {
return await response.blob();
} else {
return await response.arrayBuffer();
}
}

Quick Reference: Body Reading Methods

MethodReturnsUse Case
.json()Promise<Object>API responses, configuration files
.text()Promise<String>HTML, plain text, CSV, XML
.blob()Promise<Blob>Images, files, downloads
.arrayBuffer()Promise<ArrayBuffer>Binary processing, audio, WASM
.formData()Promise<FormData>Form submissions (in Service Workers)

Request Headers

When making a request, you often need to send custom headers to the server. Common reasons include authentication tokens, specifying the expected response format, sending API keys, or identifying your application.

Setting Headers with the headers Option

You can pass headers as a plain object:

const response = await fetch('https://api.example.com/data', {
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIs...',
'X-API-Key': 'abc123',
'Accept': 'application/json'
}
});

Or use the Headers constructor for more control:

const headers = new Headers();
headers.append('Content-Type', 'application/json');
headers.append('Authorization', 'Bearer eyJhbGciOiJIUzI1NiIs...');

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

The Headers Object API

The Headers object provides methods for managing headers:

const headers = new Headers({
'Content-Type': 'application/json',
'Accept': 'application/json'
});

// Add a header
headers.append('X-Custom-Header', 'value');

// Set (replace) a header
headers.set('Content-Type', 'text/plain');

// Get a header value
console.log(headers.get('Content-Type')); // "text/plain"

// Check if a header exists
console.log(headers.has('Authorization')); // false

// Delete a header
headers.delete('X-Custom-Header');

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

Common Request Headers

Here are headers you will use frequently:

// Authentication
headers: {
'Authorization': 'Bearer <token>' // JWT or OAuth token
'Authorization': 'Basic dXNlcjpwYXNz' // Base64-encoded credentials
}

// Content negotiation
headers: {
'Accept': 'application/json', // Tell server what you expect
'Content-Type': 'application/json' // Tell server what you are sending
}

// Caching
headers: {
'Cache-Control': 'no-cache', // Skip cache
'If-None-Match': '"etag-value"' // Conditional request
}

// Custom application headers
headers: {
'X-Request-ID': 'uuid-here', // Request tracing
'X-API-Version': '2' // API versioning
}

Practical Example: Authenticated API Request

class ApiClient {
constructor(baseURL, token) {
this.baseURL = baseURL;
this.token = token;
}

getHeaders() {
return {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${this.token}`
};
}

async get(endpoint) {
const response = await fetch(`${this.baseURL}${endpoint}`, {
headers: this.getHeaders()
});

if (!response.ok) {
throw new Error(`GET ${endpoint} failed: ${response.status}`);
}

return response.json();
}
}

// Usage
const api = new ApiClient('https://api.example.com', 'my-auth-token');
const users = await api.get('/users');
note

Some headers are forbidden and cannot be set by JavaScript for security reasons. These include Cookie, Host, Origin, Referer (partially), and several others. The browser manages these headers automatically. Attempting to set them with fetch() will be silently ignored.

POST Requests with fetch

By default, fetch() makes GET requests. To send data to a server, you need to specify the HTTP method and provide a body.

Basic POST Request

const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: 'My New Post',
body: 'This is the content of my post.',
userId: 1
})
});

const createdPost = await response.json();
console.log(createdPost);

Output:

{
id: 101,
title: "My New Post",
body: "This is the content of my post.",
userId: 1
}

The method Option

The method option accepts any valid HTTP method:

// GET (default)
fetch(url);
fetch(url, { method: 'GET' });

// POST (create)
fetch(url, { method: 'POST', body: data });

// PUT (full update)
fetch(url, { method: 'PUT', body: data });

// PATCH (partial update)
fetch(url, { method: 'PATCH', body: data });

// DELETE
fetch(url, { method: 'DELETE' });

The body Option

The body can be several types:

// String (typically JSON)
body: JSON.stringify({ key: 'value' })

// FormData (for file uploads or form submissions)
body: new FormData(formElement)

// URLSearchParams (for URL-encoded form data)
body: new URLSearchParams({ username: 'john', password: '1234' })

// Blob (binary data)
body: new Blob(['file content'], { type: 'text/plain' })

// ArrayBuffer or TypedArray (raw binary)
body: new Uint8Array([72, 101, 108, 108, 111])

// ReadableStream (for streaming)
body: readableStream
caution

GET and HEAD requests cannot have a body. If you pass a body with method: 'GET', the browser will throw a TypeError. If you need to send data with a GET request, use URL query parameters instead.

// WRONG: GET with body
fetch('/api/search', {
method: 'GET',
body: JSON.stringify({ query: 'javascript' }) // TypeError!
});

// CORRECT: GET with query parameters
const params = new URLSearchParams({ query: 'javascript' });
fetch(`/api/search?${params}`);

PUT, PATCH, and DELETE Examples

PUT replaces the entire resource:

async function updateUser(id, userData) {
const response = await fetch(`https://api.example.com/users/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData)
});

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

return response.json();
}

await updateUser(1, {
name: 'Jane Doe',
email: 'jane@example.com',
role: 'admin'
});

PATCH updates only the specified fields:

async function updateEmail(userId, newEmail) {
const response = await fetch(`https://api.example.com/users/${userId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: newEmail })
});

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

return response.json();
}

DELETE removes a resource:

async function deletePost(postId) {
const response = await fetch(`https://api.example.com/posts/${postId}`, {
method: 'DELETE'
});

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

// DELETE responses often have no body (204 No Content)
if (response.status === 204) {
return null;
}

return response.json();
}

Sending JSON Data

Sending JSON is the most common data format for modern APIs. The process involves three pieces: setting the Content-Type header, stringifying the data, and reading the JSON response.

The Complete Pattern

async function createPost(postData) {
const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json', // Tell server: "I'm sending JSON"
'Accept': 'application/json' // Tell server: "I want JSON back"
},
body: JSON.stringify(postData) // Convert object to JSON string
});

if (!response.ok) {
// Try to parse error details from the response body
const errorBody = await response.text();
throw new Error(`Request failed (${response.status}): ${errorBody}`);
}

return response.json();
}

// Usage
const newPost = await createPost({
title: 'Understanding Fetch',
body: 'The Fetch API is the modern way to make HTTP requests.',
userId: 1
});

console.log(`Created post with ID: ${newPost.id}`);

Common Mistake: Forgetting Content-Type

If you forget to set the Content-Type header, the server might not understand your request body:

// WRONG: No Content-Type header
const response = await fetch('https://api.example.com/data', {
method: 'POST',
body: JSON.stringify({ name: 'John' })
// Server might interpret this as plain text, not JSON!
});

// CORRECT: Always set Content-Type when sending data
const response = await fetch('https://api.example.com/data', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ name: 'John' })
});

Common Mistake: Forgetting JSON.stringify()

Another frequent error is passing an object directly as the body without stringifying it:

// WRONG: Object is converted to "[object Object]"
const response = await fetch('https://api.example.com/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { name: 'John' } // Becomes the string "[object Object]"!
});

// CORRECT: Stringify the object
const response = await fetch('https://api.example.com/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'John' }) // Becomes '{"name":"John"}'
});

Sending Nested and Complex Data

JSON.stringify() handles nested objects, arrays, and complex structures automatically:

const orderData = {
customer: {
name: 'Jane Doe',
email: 'jane@example.com',
address: {
street: '123 Main St',
city: 'Springfield',
zip: '62701'
}
},
items: [
{ productId: 'abc123', quantity: 2, price: 29.99 },
{ productId: 'def456', quantity: 1, price: 49.99 }
],
couponCode: null,
expressShipping: true
};

const response = await fetch('https://api.example.com/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(orderData)
});

Building a Reusable Fetch Wrapper

In real applications, you will make many API calls. A reusable wrapper eliminates repetition and centralizes error handling:

async function apiFetch(endpoint, options = {}) {
const baseURL = 'https://api.example.com';

const config = {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
...options.headers
},
...options
};

// Automatically stringify body if it's an object
if (config.body && typeof config.body === 'object' && !(config.body instanceof FormData)) {
config.body = JSON.stringify(config.body);
}

const response = await fetch(`${baseURL}${endpoint}`, config);

if (!response.ok) {
const errorData = await response.json().catch(() => null);
const error = new Error(`API Error: ${response.status} ${response.statusText}`);
error.status = response.status;
error.data = errorData;
throw error;
}

// Handle 204 No Content
if (response.status === 204) {
return null;
}

return response.json();
}

// Clean, simple API calls
const users = await apiFetch('/users');

const newUser = await apiFetch('/users', {
method: 'POST',
body: { name: 'John', email: 'john@example.com' }
});

await apiFetch('/users/1', {
method: 'PATCH',
body: { name: 'Updated Name' }
});

await apiFetch('/users/1', { method: 'DELETE' });

Complete CRUD Example

Here is a practical module demonstrating all four CRUD operations with proper error handling:

const API_BASE = 'https://jsonplaceholder.typicode.com';

// CREATE
async function createTodo(todo) {
const response = await fetch(`${API_BASE}/todos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(todo)
});

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

// READ (single item)
async function getTodo(id) {
const response = await fetch(`${API_BASE}/todos/${id}`);

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

// READ (list with query parameters)
async function getTodos({ userId, completed } = {}) {
const params = new URLSearchParams();
if (userId !== undefined) params.set('userId', userId);
if (completed !== undefined) params.set('completed', completed);

const url = `${API_BASE}/todos${params.toString() ? '?' + params : ''}`;
const response = await fetch(url);

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

// UPDATE
async function updateTodo(id, updates) {
const response = await fetch(`${API_BASE}/todos/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});

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

// DELETE
async function deleteTodo(id) {
const response = await fetch(`${API_BASE}/todos/${id}`, {
method: 'DELETE'
});

if (!response.ok) throw new Error(`Delete failed: ${response.status}`);
return true;
}

// Usage
async function demo() {
// Create
const newTodo = await createTodo({
title: 'Learn Fetch API',
completed: false,
userId: 1
});
console.log('Created:', newTodo);
// Created: { id: 201, title: "Learn Fetch API", completed: false, userId: 1 }

// Read one
const todo = await getTodo(1);
console.log('Fetched:', todo.title);
// "Fetched: delectus aut autem"

// Read many with filters
const completedByUser1 = await getTodos({ userId: 1, completed: true });
console.log(`User 1 completed ${completedByUser1.length} todos`);

// Update
const updated = await updateTodo(1, { completed: true });
console.log('Updated:', updated);
// "Updated: {userId: 1, id: 1, title: "delectus aut autem", completed: true}"

// Delete
await deleteTodo(1);
console.log('Deleted successfully');
// "Deleted successfully"
}

demo().catch(console.error);

Error Handling Patterns

Robust error handling is essential for production applications. Here are the patterns you should know.

Network Errors vs. HTTP Errors

async function fetchWithFullErrorHandling(url) {
try {
const response = await fetch(url);

// HTTP errors (4xx, 5xx) - fetch resolved but server returned an error
if (!response.ok) {
if (response.status === 404) {
throw new Error('Resource not found');
} else if (response.status === 401) {
throw new Error('Authentication required');
} else if (response.status === 403) {
throw new Error('Access forbidden');
} else if (response.status >= 500) {
throw new Error('Server error. Please try again later.');
} else {
throw new Error(`Request failed with status ${response.status}`);
}
}

return await response.json();

} catch (error) {
// Network errors (no connection, DNS failure, CORS blocked)
if (error instanceof TypeError && error.message === 'Failed to fetch') {
throw new Error('Network error. Check your internet connection.');
}

// Re-throw our custom errors or unexpected errors
throw error;
}
}

Retry Logic

For unreliable networks or flaky servers, implementing retry logic is valuable:

async function fetchWithRetry(url, options = {}, maxRetries = 3) {
let lastError;

for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url, options);

if (!response.ok) {
// Only retry on server errors (5xx), not client errors (4xx)
if (response.status >= 500 && attempt < maxRetries) {
const delay = Math.pow(2, attempt) * 1000; // Exponential backoff
console.log(`Attempt ${attempt} failed (${response.status}). Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}

return response;
} catch (error) {
lastError = error;

// Retry on network errors
if (error instanceof TypeError && attempt < maxRetries) {
const delay = Math.pow(2, attempt) * 1000;
console.log(`Network error on attempt ${attempt}. Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}

throw error;
}
}

throw lastError;
}

// Usage
const response = await fetchWithRetry('https://api.example.com/data');
const data = await response.json();

Timeout Handling

fetch() does not have a built-in timeout. You can implement one using AbortController:

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

try {
const response = await fetch(url, {
...options,
signal: controller.signal
});
return response;
} catch (error) {
if (error.name === 'AbortError') {
throw new Error(`Request timed out after ${timeoutMs}ms`);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}

// Usage
try {
const response = await fetchWithTimeout('https://slow-api.example.com/data', {}, 3000);
const data = await response.json();
} catch (error) {
console.log(error.message); // "Request timed out after 3000ms"
}
tip

Starting from ES2024, you can use AbortSignal.timeout() for a cleaner syntax:

const response = await fetch('https://api.example.com/data', {
signal: AbortSignal.timeout(5000) // Aborts after 5 seconds
});

This built-in method is equivalent to the manual AbortController + setTimeout approach but is more concise and widely supported in modern browsers.

Practical Examples

Loading Data and Rendering It

A realistic example of fetching data and displaying it in the DOM:

async function loadUserProfiles() {
const container = document.getElementById('user-list');
container.innerHTML = '<p>Loading users...</p>';

try {
const response = await fetch('https://jsonplaceholder.typicode.com/users');

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

const users = await response.json();

container.innerHTML = users.map(user => `
<div class="user-card">
<h3>${user.name}</h3>
<p>${user.email}</p>
<p>${user.company.name}</p>
</div>
`).join('');

} catch (error) {
container.innerHTML = `<p class="error">Error: ${error.message}</p>`;
}
}

loadUserProfiles();

Sending Form Data as JSON

Capturing form input and sending it as a JSON POST request:

<form id="contactForm">
<input type="text" name="name" placeholder="Your name" required>
<input type="email" name="email" placeholder="Your email" required>
<textarea name="message" placeholder="Your message" required></textarea>
<button type="submit">Send</button>
</form>

<script>
document.getElementById('contactForm').addEventListener('submit', async (event) => {
event.preventDefault();

const form = event.target;
const button = form.querySelector('button');

// Collect form data as an object
const formData = new FormData(form);
const data = Object.fromEntries(formData);

button.disabled = true;
button.textContent = 'Sending...';

try {
const response = await fetch('https://api.example.com/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});

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

const result = await response.json();
form.reset();
alert('Message sent successfully!');

} catch (error) {
alert(`Error: ${error.message}`);
} finally {
button.disabled = false;
button.textContent = 'Send';
}
});
</script>

Parallel Requests with Promise.all

When you need data from multiple endpoints, fetch them simultaneously instead of sequentially:

// SLOW: Sequential requests (each waits for the previous)
async function getDataSequential() {
const users = await (await fetch('/api/users')).json(); // ~200ms
const posts = await (await fetch('/api/posts')).json(); // ~200ms
const comments = await (await fetch('/api/comments')).json(); // ~200ms
// Total: ~600ms
return { users, posts, comments };
}

// FAST: Parallel requests (all start at the same time)
async function getDataParallel() {
const [usersRes, postsRes, commentsRes] = await Promise.all([
fetch('/api/users'),
fetch('/api/posts'),
fetch('/api/comments')
]);

// Check all responses
if (!usersRes.ok || !postsRes.ok || !commentsRes.ok) {
throw new Error('One or more requests failed');
}

const [users, posts, comments] = await Promise.all([
usersRes.json(),
postsRes.json(),
commentsRes.json()
]);

// Total: ~200ms (time of the slowest request)
return { users, posts, comments };
}

If you want all requests to complete even if some fail, use Promise.allSettled:

async function getDataResilient() {
const results = await Promise.allSettled([
fetch('/api/users').then(r => r.ok ? r.json() : Promise.reject(r.status)),
fetch('/api/posts').then(r => r.ok ? r.json() : Promise.reject(r.status)),
fetch('/api/comments').then(r => r.ok ? r.json() : Promise.reject(r.status))
]);

return {
users: results[0].status === 'fulfilled' ? results[0].value : [],
posts: results[1].status === 'fulfilled' ? results[1].value : [],
comments: results[2].status === 'fulfilled' ? results[2].value : []
};
}

Summary

The Fetch API is the standard way to make HTTP requests in modern JavaScript. Here are the key points to remember:

fetch() returns a Promise that resolves to a Response object. It only rejects on network failures, not HTTP errors like 404 or 500. Always check response.ok or response.status before reading the body.

Reading the response body is a separate asynchronous step. Use .json() for API data, .text() for HTML and plain text, .blob() for binary files, and .arrayBuffer() for low-level binary processing. The body can only be read once unless you .clone() the response first.

For POST, PUT, and PATCH requests, set the method option, include the Content-Type header, and serialize your data with JSON.stringify() when sending JSON. The three most common mistakes are forgetting to check response.ok, forgetting Content-Type, and forgetting JSON.stringify().

Custom headers are set through the headers option, either as a plain object or a Headers instance. Some headers like Cookie and Host are browser-controlled and cannot be set manually.

For production code, wrap fetch() in utility functions that handle error checking, authentication headers, timeouts, and retries. Use Promise.all() for parallel requests and AbortController for cancellation and timeouts.