Mastering the Fetch API: Practical Patterns for Real-World Apps
Fetch is more powerful than most developers realize.
The Basics (Quick Refresher)
// GET request
const response = await fetch('https://api.example.com/data');
const data = await response.json();
// POST request
const response = await fetch('https://api.example.com/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Alex', email: 'alex@example.com' }),
});
A Robust Fetch Wrapper
class ApiClient {
constructor(baseURL, options = {}) {
this.baseURL = baseURL;
this.timeout = options.timeout || 10000;
this.headers = {
'Content-Type': 'application/json',
...options.headers,
};
}
async request(endpoint, options = {}) {
const url = `${this.baseURL}${endpoint}`;
// Abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const response = await fetch(url, {
...options,
headers: { ...this.headers, ...options.headers },
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new ApiError(response.status, await this.parseError(response));
}
return response;
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
throw new Error(`Request timed out after ${this.timeout}ms`);
}
throw error;
}
}
async get(endpoint, params = {}) {
const query = new URLSearchParams(params).toString();
const url = query ? `${endpoint}?${query}` : endpoint;
const response = await this.request(url);
return response.json();
}
async post(endpoint, data) {
const response = await this.request(endpoint, {
method: 'POST',
body: JSON.stringify(data),
});
return response.json();
}
async put(endpoint, data) {
const response = await this.request(endpoint, {
method: 'PUT',
body: JSON.stringify(data),
});
return response.json();
}
async delete(endpoint) {
const response = await this.request(endpoint, { method: 'DELETE' });
return response.status === 204 || response.json();
}
async parseError(response) {
try {
return await response.json();
} catch {
return { message: response.statusText };
}
}
}
class ApiError extends Error {
constructor(statusCode, errorBody) {
super(errorBody.message || `API Error ${statusCode}`);
this.statusCode = statusCode;
this.errorBody = errorBody;
}
}
// Usage:
const api = new ApiClient('https://api.example.com', {
timeout: 5000,
headers: { Authorization: `Bearer ${token}` },
});
try {
const users = await api.get('/users', { page: 1, limit: 20 });
console.log(users);
} catch (error) {
if (error instanceof ApiError) {
console.error(`${error.statusCode}:`, error.errorBody);
}
}
Request Caching
class CachedFetch {
#cache = new Map();
async get(url, ttlMs = 60000) {
const cached = this.#cache.get(url);
if (cached && Date.now() - cached.timestamp < ttlMs) {
return cached.data; // Return cache hit
}
const data = await fetch(url).then(r => r.json());
this.#cache.set(url, { data, timestamp: Date.now() });
return data;
}
invalidate(url) {
this.#cache.delete(url);
}
clear() {
this.#cache.clear();
}
}
const cachedApi = new CachedFetch();
// These two calls only make ONE HTTP request
const [data1, data2] = await Promise.all([
cachedApi.get('/api/data'),
cachedApi.get('/api/data'), // Cache hit!
]);
Retry Logic with Exponential Backoff
async function fetchWithRetry(url, options = {}) {
const {
maxRetries = 3,
baseDelay = 1000,
backoffFactor = 2,
retryableStatuses = [408, 429, 500, 502, 503, 504],
} = options;
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url, options.fetchOptions);
if (response.ok || !retryableStatuses.includes(response.status)) {
return response;
}
// Retryable server error — don't throw, retry instead
lastError = new Error(`HTTP ${response.status}`);
} catch (error) {
lastError = error;
}
if (attempt < maxRetries) {
const delay = baseDelay * Math.pow(backoffFactor, attempt);
console.warn(`Attempt ${attempt + 1} failed. Retrying in ${delay}ms...`);
await sleep(delay);
}
}
throw lastError;
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
Concurrent Requests
// Parallel: All requests run simultaneously
async function loadDashboardData(userId) {
const [user, posts, notifications, friends] = await Promise.all([
fetch(`/api/users/${userId}`).then(r => r.json()),
fetch(`/api/users/${userId}/posts`).then(r => r.json()),
fetch(`/api/notifications`).then(r => r.json()),
fetch(`/api/users/${userId}/friends`).then(r => r.json()),
]);
return { user, posts, notifications, friends };
}
// Race: Use fastest successful response
async function fetchWithFallback(primaryUrl, fallbackUrl) {
try {
const result = await Promise.any([
fetch(primaryUrl).then(r => r.json()),
fetch(fallbackUrl).then(r => r.json()),
]);
return result;
} catch {
throw new Error('All endpoints failed');
}
}
// Batched: Process in groups to avoid overwhelming the server
async function batchFetch(urls, batchSize = 5) {
const results = [];
for (let i = 0; i < urls.length; i += batchSize) {
const batch = urls.slice(i, i + batchSize);
const batchResults = await Promise.all(
batch.map(url => fetch(url).then(r => r.json()).catch(() => null))
);
results.push(...batchResults);
}
return results.filter(Boolean);
}
Progress Tracking for Uploads
async function uploadWithProgress(file, url, onProgress) {
const xhr = new XMLHttpRequest();
return new Promise((resolve, reject) => {
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
onProgress(percent, e.loaded, e.total);
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(`Upload failed: ${xhr.status}`));
}
});
xhr.addEventListener('error', () => reject(new Error('Network error')));
xhr.open('POST', url);
xhr.send(file);
});
}
// Usage:
uploadWithProgress(file, '/api/upload', (percent, loaded, total) => {
progressBar.value = percent;
statusText.textContent = `${percent}% (${formatBytes(loaded)} / ${formatBytes(total)})`;
});
File Download
async function downloadFile(url, filename) {
const response = await fetch(url);
if (!response.ok) throw new Error(`Download failed: ${response.status}`);
const blob = await response.blob();
// Create download link
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href); // Clean up memory
}
What's your favorite fetch pattern? Anything I missed?
Follow @armorbreak for more JavaScript content.
Top comments (0)