DEV Community

Alex Chen
Alex Chen

Posted on

Mastering the Fetch API: Practical Patterns for Real-World Apps

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' }),
});
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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!
]);
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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)})`;
});
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

What's your favorite fetch pattern? Anything I missed?

Follow @armorbreak for more JavaScript content.

Top comments (0)