Skip to main content

Fetch API and HTTP

The Fetch API is a modern interface for making network requests. It replaces XMLHttpRequest and works with Promises, integrating naturally with async/await.


Basic fetch Usage

// Basic GET request
const response = await fetch('https://api.example.com/users');
const users = await response.json();
console.log(users);

// fetch proceeds in two stages
// 1. Receive response headers: returns a Response object (includes status code)
// 2. Parse response body: call .json(), .text(), .blob(), etc.

// Important: fetch does NOT reject on HTTP errors (4xx, 5xx)!
const response = await fetch('https://api.example.com/not-found');
console.log(response.ok); // false (404)
console.log(response.status); // 404
// Rejects only on network errors (internet disconnection, etc.)

Response Object

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

// Status information
console.log(response.status); // 200, 404, 500, etc.
console.log(response.statusText); // 'OK', 'Not Found', etc.
console.log(response.ok); // true if 200-299
console.log(response.url); // final URL (after redirect)
console.log(response.redirected); // whether a redirect occurred

// Reading the body (can only be done once!)
const jsonData = await response.json(); // parse JSON
const textData = await response.text(); // text
const blobData = await response.blob(); // Blob (for images, etc.)
const bufferData = await response.arrayBuffer(); // ArrayBuffer
const formData = await response.formData(); // FormData

// The body can only be read once
// Use clone() to read it twice
const response = await fetch('/api/data');
const cloned = response.clone();

const json = await response.json();
const text = await cloned.text(); // possible (reads the clone)

Headers Object

// Create a Headers object
const headers = new Headers({
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'X-Custom-Header': 'value'
});

// Manipulate headers
headers.set('Content-Type', 'text/plain'); // set (replaces existing value)
headers.append('Accept', 'application/json'); // append (multiple values for same header)
headers.get('Content-Type'); // 'text/plain'
headers.has('Authorization'); // true
headers.delete('X-Custom-Header');

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

// Read response headers
const response = await fetch('/api/data');
console.log(response.headers.get('Content-Type')); // 'application/json'
console.log(response.headers.get('Cache-Control')); // 'max-age=3600'
console.log(response.headers.get('X-Rate-Limit-Remaining'));

// Iterate over response headers
response.headers.forEach((value, name) => {
console.log(`${name}: ${value}`);
});

Customizing the Request Object

// Create a Request object directly
const request = new Request('https://api.example.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ name: 'Alice', email: 'alice@example.com' }),
mode: 'cors', // 'cors', 'no-cors', 'same-origin'
credentials: 'include', // 'omit', 'same-origin', 'include'
cache: 'no-cache', // 'default', 'no-store', 'reload', 'no-cache', 'force-cache'
redirect: 'follow', // 'follow', 'error', 'manual'
referrerPolicy: 'no-referrer-when-downgrade'
});

const response = await fetch(request);

// Usage for each HTTP method
// GET
const getResponse = await fetch('/api/users');

// POST
const postResponse = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Alice' })
});

// PUT
const putResponse = await fetch('/api/users/1', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Alice Updated' })
});

// PATCH
const patchResponse = await fetch('/api/users/1', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'new@example.com' })
});

// DELETE
const deleteResponse = await fetch('/api/users/1', {
method: 'DELETE'
});

Cancelling Requests with AbortController

// Cancel a fetch with AbortController
const controller = new AbortController();
const { signal } = controller;

// Start the request
fetch('/api/large-data', { signal })
.then(r => r.json())
.then(data => console.log(data))
.catch(err => {
if (err.name === 'AbortError') {
console.log('Request was cancelled');
} else {
console.error('Request failed:', err);
}
});

// Cancel
setTimeout(() => controller.abort(), 3000); // cancel after 3 seconds
// Or cancel on user action
cancelButton.addEventListener('click', () => controller.abort());

// async/await style
async function fetchWithCancel(url) {
const controller = new AbortController();

const cancelButton = document.querySelector('#cancel');
cancelButton.addEventListener('click', () => controller.abort(), { once: true });

try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (err) {
if (err.name === 'AbortError') {
console.log('User cancelled');
return null;
}
throw err;
}
}

// Implementing a timeout
function fetchWithTimeout(url, options = {}, timeout = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);

return fetch(url, { ...options, signal: controller.signal })
.finally(() => clearTimeout(timeoutId));
}

// Cancel previous request before making a new one (useful for search)
let currentController = null;

async function search(query) {
// Cancel the previous request
currentController?.abort();
currentController = new AbortController();

try {
const response = await fetch(`/api/search?q=${query}`, {
signal: currentController.signal
});
return await response.json();
} catch (err) {
if (err.name !== 'AbortError') throw err;
return null;
}
}

XMLHttpRequest vs fetch Comparison

// XMLHttpRequest (legacy)
function xhrRequest(url) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.setRequestHeader('Accept', 'application/json');

xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(`HTTP ${xhr.status}`));
}
};

xhr.onerror = () => reject(new Error('Network error'));
xhr.ontimeout = () => reject(new Error('Timeout'));
xhr.timeout = 5000;

xhr.send();
});
}

// fetch (modern)
async function fetchRequest(url) {
const response = await fetch(url, {
headers: { 'Accept': 'application/json' }
});

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

// Comparison table:
// | Feature | XHR | fetch |
// |-----------------|--------------------------|---------------------|
// | Syntax | Event/callback-based | Promise-based |
// | Error handling | Complex | try/catch |
// | Cancel request | xhr.abort() | AbortController |
// | Progress | onprogress supported | Manual implementation|
// | Streaming | Not supported | ReadableStream |
// | Cookies | withCredentials | credentials option |
// | Browser support | IE6+ | Chrome 42+ |

// When XHR is still useful: tracking upload progress
function uploadWithProgress(file, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append('file', file);

xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
onProgress(percent);
}
};

xhr.onload = () => {
if (xhr.status === 200) resolve(JSON.parse(xhr.responseText));
else reject(new Error(`Upload failed: ${xhr.status}`));
};

xhr.onerror = () => reject(new Error('Network error'));
xhr.open('POST', '/api/upload');
xhr.send(formData);
});
}

Error Handling Patterns

Network Errors vs HTTP Errors

async function safeFetch(url, options = {}) {
let response;

try {
response = await fetch(url, options);
} catch (err) {
// Network errors (internet disconnection, DNS failure, etc.)
if (err instanceof TypeError) {
throw new NetworkError('Please check your network connection', { cause: err });
}
if (err.name === 'AbortError') {
throw new AbortError('Request was cancelled');
}
throw err;
}

// HTTP errors (4xx, 5xx)
if (!response.ok) {
let errorData = {};
try {
errorData = await response.json();
} catch {
// Ignore JSON parsing failure
}

throw new HttpError(
errorData.message ?? `HTTP error: ${response.status}`,
response.status,
errorData
);
}

return response;
}

class HttpError extends Error {
constructor(message, status, data = {}) {
super(message);
this.name = 'HttpError';
this.status = status;
this.data = data;
}
}

class NetworkError extends Error {
constructor(message, options) {
super(message, options);
this.name = 'NetworkError';
}
}

// Usage
try {
const response = await safeFetch('/api/users');
const users = await response.json();
} catch (err) {
if (err instanceof HttpError) {
if (err.status === 401) redirectToLogin();
else if (err.status === 403) showForbiddenMessage();
else if (err.status === 404) showNotFoundMessage();
else showErrorMessage(err.message);
} else if (err instanceof NetworkError) {
showOfflineMessage();
} else {
console.error('Unexpected error:', err);
}
}

Retry Logic and Timeout

// Exponential backoff retry + timeout
async function fetchWithRetry(url, options = {}) {
const {
retries = 3,
baseDelay = 1000,
maxDelay = 30000,
timeout = 10000,
shouldRetry = (err) => !(err instanceof HttpError && err.status < 500)
} = options;

for (let attempt = 0; attempt <= retries; attempt++) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);

try {
const response = await fetch(url, {
...options,
signal: controller.signal
});

clearTimeout(timeoutId);

if (!response.ok) {
throw new HttpError(`HTTP ${response.status}`, response.status);
}

return response;

} catch (err) {
clearTimeout(timeoutId);

if (err.name === 'AbortError') {
throw new Error(`Request timeout (${timeout}ms)`);
}

if (attempt === retries || !shouldRetry(err)) {
throw err;
}

// Exponential backoff + random jitter
const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
const jitter = Math.random() * 0.3 * delay;
console.log(`Retry ${attempt + 1}/${retries}, after ${Math.round(delay + jitter)}ms...`);
await new Promise(resolve => setTimeout(resolve, delay + jitter));
}
}
}

// Usage
const response = await fetchWithRetry('/api/data', {
retries: 3,
timeout: 5000,
baseDelay: 500
});
const data = await response.json();

Real-World: API Client

class ApiClient {
#baseUrl;
#defaultHeaders;
#interceptors = { request: [], response: [] };

constructor(baseUrl, options = {}) {
this.#baseUrl = baseUrl.replace(/\/$/, '');
this.#defaultHeaders = {
'Content-Type': 'application/json',
'Accept': 'application/json',
...options.headers
};
}

// Register interceptors
addRequestInterceptor(fn) {
this.#interceptors.request.push(fn);
}

addResponseInterceptor(fn) {
this.#interceptors.response.push(fn);
}

async #request(method, path, options = {}) {
let requestOptions = {
method,
headers: {
...this.#defaultHeaders,
...options.headers
},
...options
};

if (options.body && typeof options.body === 'object') {
requestOptions.body = JSON.stringify(options.body);
}

// Run request interceptors
for (const interceptor of this.#interceptors.request) {
requestOptions = await interceptor(requestOptions);
}

let response;
try {
response = await fetchWithRetry(
`${this.#baseUrl}${path}`,
requestOptions
);
} catch (err) {
throw err;
}

// Run response interceptors
for (const interceptor of this.#interceptors.response) {
response = await interceptor(response);
}

if (response.status === 204) return null;
return response.json();
}

get(path, options) { return this.#request('GET', path, options); }
post(path, body, options) { return this.#request('POST', path, { ...options, body }); }
put(path, body, options) { return this.#request('PUT', path, { ...options, body }); }
patch(path, body, options) { return this.#request('PATCH', path, { ...options, body }); }
delete(path, options) { return this.#request('DELETE', path, options); }
}

// Usage
const api = new ApiClient('https://api.example.com');

// Automatically attach auth token
api.addRequestInterceptor(async (options) => ({
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${await getAccessToken()}`
}
}));

// Refresh token on 401 response
api.addResponseInterceptor(async (response) => {
if (response.status === 401) {
await refreshAccessToken();
// retry logic...
}
return response;
});

// API calls
const users = await api.get('/users?page=1&limit=10');
const newUser = await api.post('/users', { name: 'Alice', email: 'alice@example.com' });

File Upload and FormData

// Single file upload
async function uploadFile(file) {
const formData = new FormData();
formData.append('file', file);
formData.append('name', file.name);

const response = await fetch('/api/upload', {
method: 'POST',
body: formData
// Do NOT set the Content-Type header!
// The browser automatically sets multipart/form-data with boundary
});

return response.json();
}

// Upload from file input
document.querySelector('#file-input').addEventListener('change', async (e) => {
const files = Array.from(e.target.files);

for (const file of files) {
const result = await uploadFile(file);
console.log('Upload complete:', result.url);
}
});

// Track upload progress with fetch (using ReadableStream)
async function uploadWithFetchProgress(file, onProgress) {
const response = await fetch('/api/upload', {
method: 'POST',
body: file,
headers: {
'Content-Type': file.type,
'Content-Length': file.size
}
});

const reader = response.body.getReader();
const total = Number(response.headers.get('Content-Length')) || 0;
let loaded = 0;

while (true) {
const { done, value } = await reader.read();
if (done) break;
loaded += value.length;
onProgress(total ? (loaded / total) * 100 : 0);
}
}

Expert Tips

1. Response Caching Strategy

const cache = new Map();

async function cachedFetch(url, ttl = 60000) {
const cached = cache.get(url);
if (cached && Date.now() - cached.timestamp < ttl) {
return cached.data;
}

const response = await fetch(url);
const data = await response.json();
cache.set(url, { data, timestamp: Date.now() });
return data;
}

2. Request Deduplication

const pendingRequests = new Map();

async function deduplicatedFetch(url) {
if (pendingRequests.has(url)) {
return pendingRequests.get(url); // reuse in-progress request
}

const promise = fetch(url).then(r => r.json()).finally(() => {
pendingRequests.delete(url);
});

pendingRequests.set(url, promise);
return promise;
}

3. Handling Streaming Responses (SSE, ChatGPT style)

async function* streamResponse(url, body) {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});

const reader = response.body.getReader();
const decoder = new TextDecoder();

try {
while (true) {
const { done, value } = await reader.read();
if (done) break;

const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n').filter(l => l.startsWith('data: '));

for (const line of lines) {
const data = line.slice(6);
if (data === '[DONE]') return;
try {
yield JSON.parse(data);
} catch { /* ignore parse failure */ }
}
}
} finally {
reader.releaseLock();
}
}