Advanced Fetch API Patterns

Reviewed & published by Brayan K

Master retry logic, timeouts, AbortController, and concurrency control for production-grade network handling

Part of the free JavaScript course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

🎯 What You'll Learn

💡 Running Code Locally: While this online editor runs real JavaScript, some advanced examples may have limitations. For the best experience:

Why Plain fetch() Is Not Enough

Modern JavaScript apps live and die by network calls. A slow or failed request can ruin the whole UX: loaders that never end, buttons that "do nothing," and users smashing refresh.

The Fetch API is powerful but low-level. On its own, it only sends a request and returns a response. Real production apps need retries, timeouts, and cancellation.

// Basic fetch - works for tutorials but NOT production
fetch("https://api.example.com/data")
  .then(res => {
    if (!res.ok) {
      throw new Error("HTTP error " + res.status);
    }
    return res.json();
  })
  .then(data => {
    console.log("Data:", data);
  })
  .catch(err => {
    console.error("Request failed:", err);
  });

// Problems with this approach:
// ❌ No retry when network randomly fails
// ❌ No timeout – request can hang for 30+ seconds
// ❌ No way to cancel if user navigates away
// ❌ Error handling scattered everywhere

Building a Basic Fetch Wrapper

A good pattern is to wrap fetch in a function that normalizes errors and parses JSON automatically:

// A good pattern: wrap fetch to normalize errors and parse JSON
async function safeFetch(url, options = {}) {
  const res = await fetch(url, options);

  if (!res.ok) {
    // Turn HTTP errors into thrown errors
    const text = await res.text().catch(() => "");
    const err = new Error(`Request failed with ${res.status}`);
    err.status = res.status;
    err.body = text;
    throw err;
  }

  // Auto-detect JSON vs text
  const contentType = res.headers.get("content-type") || "";
  if (contentType.includes("application/json")) {
    return res.json();
  }
  return res.text();
}

// Usage - clean and consistent
safeFetch("/api/user")
  .then(user => console.log("User:", user))
  .catch(err => console.error("Network error:", err));

Implementing Retry Logic with Backoff

Retries are useful when failure is temporary: user's Wi-Fi hiccups, server has a momentary glitch, DNS blip. But we should NOT blindly retry on every error. Retrying a 404 or 401 doesn't help.

// Retry helper with exponential backoff
async function fetchWithRetry(url, options = {}, retries = 3, backoffMs = 500) {
  let attempt = 0;

  while (true) {
    try {
      const res = await fetch(url, options);

      // Retry only on 5xx server errors (not 4xx client errors)
      if (!res.ok && res.status >= 500 && res.status < 600) {
        throw new Error("Server error " + res.status);
      }

      return res; // success
    } catch (err) {
      attempt++;

      // If we've used all retries, rethrow
      if (attempt > retries) {
        throw err;
      }

      console.warn(
        `Fetch failed (attempt ${attempt}/${retries}). Retrying in ${backoffMs}ms`,
        err
      );

      // Wait before next try
      await new Promise(resolve => setTimeout(resolve, backoffMs));

      // Exponential backoff - each retry waits longer
      backoffMs *= 2;
    }
  }
}

// Usage
async function loadUser() {
  try {
    const res = await fetchWithRetry("/api/user", {}, 3, 300);
    const user = await res.json();
    console.log("Loaded user:", user);
  } catch (err) {
    console.error("Failed to load user after retries:", err);
  }
}

loadUser();

Worked Example: The Policy, Without the Network

Two decisions inside that wrapper do all the real work: which failures you retry and how long you wait between attempts. Get either wrong and you either hammer a dying server or give up on a request that would have worked. The example below pulls both decisions out on their own so you can watch them, with every line commented with what it prints.

It deliberately makes no real request. A live server answers differently on every run, and you cannot learn a rule from something that will not sit still.

// The retry policy — the two decisions every production wrapper makes.
// There is no network in this example on purpose. A real request depends on a
// server you do not control, and you cannot learn a rule from something that
// answers differently every run. These are exactly the rules fetchWithRetry
// applies, pulled out where you can see them.

// DECISION 1 — is this failure worth trying again?
// Retry only when a second attempt could plausibly succeed.
function shouldRetry(status) {
  if (status === 429) return true;                   // Too Many Requests: the server told you to slow down
  if (status >= 500 && status <= 599) return true;   // 5xx: the fault is server-side and may be temporary
  return false;                                      // 4xx: YOUR request is wrong, repeating it changes nothing
}

console.log("500 ->", shouldRetry(500));   // true  — internal server error
console.log("503 ->", shouldRetry(503));   // true  — service unavailable, often a restart
console.log("429 ->", shouldRetry(429));   // true  — rate limited, backing off is the correct answer
console.log("404 ->", shouldRetry(404));   // false — the URL is wrong; retrying just wastes the user's time
console.log("401 ->", shouldRetry(401));   // false — bad credentials; retrying can get you blocked

// DECISION 2 — how long to wait before the next attempt?
// Exponential backoff: each wait doubles. A struggling server gets breathing
// room instead of being hit again instantly by every client at once.
function backoffDelay(attempt, baseMs = 300, capMs = 5000) {
  const raw = baseMs * Math.pow(2, attempt - 1);   // attempt 1 -> 300, 2 -> 600, 3 -> 1200 ...
  return Math.min(raw, capMs);                     // the cap stops the wait running away
}

for (let attempt = 1; attempt <= 6; attempt++) {
  console.log("attempt " + attempt + " waits " + backoffDelay(attempt) + "ms");
}

// Why the cap matters: doubling gets out of hand fast.
console.log("uncapped attempt 10:", 300 * Math.pow(2, 9) + "ms");
console.log("capped attempt 10:", backoffDelay(10) + "ms");

// ✅ Expected output:
// 500 -> true
// 503 -> true
// 429 -> true
// 404 -> false
// 401 -> false
// attempt 1 waits 300ms
// attempt 2 waits 600ms
// attempt 3 waits 1200ms
// attempt 4 waits 2400ms
// attempt 5 waits 4800ms
// attempt 6 waits 5000ms
// uncapped attempt 10: 153600ms
// capped attempt 10: 5000ms

🎯 Your Turn

Now write the policy yourself. The attempt loop is already there — you supply the two status-code rules and the ceiling on the wait. Compare your output with the expected output at the bottom of the file.

// 🎯 YOUR TURN — write the retry policy yourself
// The loop underneath is done for you. Only the two decisions are missing.
// Replace each ___ then press Run.

function shouldRetry(status) {
  if (status === ___) return true;                  // 👉 the "Too Many Requests" status code
  if (status >= ___ && status <= 599) return true;  // 👉 the lowest server-error status code
  return false;                                     // everything else: do not retry
}

function backoffDelay(attempt, baseMs = 200, capMs = 2000) {
  const raw = baseMs * Math.pow(2, attempt - 1);    // the wait doubles every attempt
  return Math.___(raw, capMs);                      // 👉 which Math function enforces a ceiling?
}

// A pretend run: the statuses the server sent back, in order.
const responses = [503, 503, 429, 200];
let attempt = 0;

for (const status of responses) {
  attempt++;

  if (status === 200) {
    console.log("attempt " + attempt + ": 200 — success, stop retrying");
    break;
  }

  if (shouldRetry(status)) {
    console.log("attempt " + attempt + ": " + status + " — retry in " + backoffDelay(attempt) + "ms");
  } else {
    console.log("attempt " + attempt + ": " + status + " — give up, retrying will not help");
    break;
  }
}

// ✅ Expected output:
// attempt 1: 503 — retry in 200ms
// attempt 2: 503 — retry in 400ms
// attempt 3: 429 — retry in 800ms
// attempt 4: 200 — success, stop retrying

Combining Retry with safeFetch

Refactor so callers always get parsed data with automatic retries:

// Combined: safeFetch + Retry + Backoff
async function safeFetchWithRetry(url, options = {}, retries = 3, backoffMs = 500) {
  let attempt = 0;

  while (true) {
    try {
      const res = await fetch(url, options);

      if (!res.ok && res.status >= 500 && res.status < 600) {
        throw new Error(`Server error ${res.status}`);
      }

      const contentType = res.headers.get("content-type") || "";
      if (contentType.includes("application/json")) {
        return res.json();
      }
      return res.text();
    } catch (err) {
      attempt++;

      // Check if it's a network error (worth retrying)
      const isNetworkError = err.name === "TypeError" || 
                             err.message.includes("NetworkError");

      if (attempt > retries || !isNetworkError) {
        throw err;
      }

      await new Promise(res => setTimeout(res, backoffMs));
      backoffMs *= 2;
    }
  }
}

// Usage - auto-parsed, with retries
safeFetchWithRetry("/api/dashboard", { method: "GET" }, 4, 250)
  .then(data => {
    console.log("Dashboard:", data);
  })
  .catch(err => {
    console.error("Permanent failure:", err);
  });

Adding Timeouts with AbortController

By default, fetch can hang for a long time on slow networks. We want client-side timeouts so requests don't hang forever:

// Timeout with AbortController
function fetchWithTimeout(url, options = {}, timeoutMs = 7000) {
  const controller = new AbortController();
  const id = setTimeout(() => controller.abort(), timeoutMs);

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

// Usage
fetchWithTimeout("/api/slow-report", {}, 5000)
  .then(res => res.json())
  .then(data => console.log("Report:", data))
  .catch(err => {
    if (err.name === "AbortError") {
      console.error("Request timed out after 5 seconds");
    } else {
      console.error("Request failed:", err);
    }
  });

AbortController Basics

The browser gives us AbortController to cancel requests instantly. This is critical for modern apps.

// Basic AbortController example
const controller = new AbortController();

fetch("/api/search?q=apple", { signal: controller.signal })
  .then(res => res.json())
  .then(data => console.log("Search results:", data))
  .catch(err => {
    if (err.name === "AbortError") {
      console.log("Search aborted");
    } else {
      console.error("Fetch error:", err);
    }
  });

// Cancel the request at any time
controller.abort();

// Once aborted:
// ✅ Fetch is instantly terminated
// ✅ Promise rejects with AbortError
// ✅ No further processing happens
// ✅ Saves bandwidth, CPU, server load

Real-World: Cancel Previous Search Requests

Consider a user rapidly typing into a search bar. Without cancellation, you'll get multiple responses arriving out of order — causing flickering, stale results, and horrible UX.

// Real-World Pattern: Cancel Previous Search Requests
// This is one of the MOST important practical use cases

let currentController = null;

async function searchQuery(query) {
  // Cancel old search if still running
  if (currentController) {
    currentController.abort();
  }

  // Create new controller for this request
  currentController = new AbortController();

  try {
    const res = await fetch(`/api/search?q=${query}`, {
      signal: currentController.signal
    });
    const data = await res.json();
    return data;
  } catch (err) {
    if (err.name === "AbortError") return; // silently ignore
    throw err;
  }
}

// Usage with input field
document.getElementById("searchInput")
  .addEventListener("input", e => {
    searchQuery(e.target.value).then(results => {
      if (results) {
        renderResults(results);
      }
    });
  });

// Now your UI stays synced with the LATEST query only

Linking Multiple Requests to One Cancellation

You can connect multiple requests to one AbortController. Canceling it stops ALL in-flight operations.

// Linking Multiple Requests to One AbortController
// Cancel all related requests with one call

const controller = new AbortController();

async function loadUserDashboard() {
  try {
    // All three requests share the same signal
    const profilePromise = fetch("/api/profile", { 
      signal: controller.signal 
    });
    const statsPromise = fetch("/api/stats", { 
      signal: controller.signal 
    });
    const newsPromise = fetch("/api/news", { 
      signal: controller.signal 
    });

    const [profile, stats, news] = await Promise.all([
      profilePromise.then(r => r.json()),
      statsPromise.then(r => r.json()),
      newsPromise.then(r => r.json())
    ]);

    console.log("Dashboard loaded:", { profile, stats, news });
  } catch (err) {
    if (err.name === "AbortError") {
      console.log("Dashboard load cancelled");
    } else {
      console.error("Dashboard error:", err);
    }
  }
}

// Start loading
loadUserDashboard();

// Cancel ALL requests with one call
document.getElementById("cancelBtn").addEventListener("click", () => {
  controller.abort();
});

Concurrency Control: Limiting Simultaneous Requests

If your app fires too many fetches at once, you get UI lag, browser freezing, and backend spikes. A good system caps how many fetches can run at once:

// Concurrency Control: Limit simultaneous requests
// Prevents API storming when users scroll fast or load dashboards

class RequestQueue {
  constructor(limit = 5) {
    this.limit = limit;
    this.active = 0;
    this.queue = [];
  }

  add(task) {
    return new Promise((resolve, reject) => {
      this.queue.push({ task, resolve, reject });
      this.run();
    });
  }

  run() {
    if (this.active >= this.limit || this.queue.length === 0) return;

    const { task, resolve, reject } = this.queue.shift();
    this.active++;

    task()
      .then(resolve)
      .catch(reject)
      .finally(() => {
        this.active--;
        this.run(); // Process next in queue
      });
  }
}

// Usage
const rq = new RequestQueue(4); // Max 4 concurrent requests

// Queue up many requests - only 4 run at once
for (let i = 0; i < 20; i++) {
  rq.add(() => fetch(`/api/item/${i}`).then(r => r.json()))
    .then(data => console.log(`Item ${i}:`, data));
}

Exponential Backoff with Jitter

Used by Amazon, Google, Stripe, PayPal. Jitter adds randomness to prevent "thundering herd" problems where many clients retry at the same time.

// Exponential Backoff with Jitter
// Used by Amazon, Google, Stripe, PayPal

async function fetchBackoff(url, retries = 5) {
  let delay = 300;

  for (let i = 0; i < retries; i++) {
    try {
      const res = await fetch(url);
      if (!res.ok) throw new Error(res.statusText);
      return res;
    } catch (err) {
      if (i === retries - 1) throw err;
      
      // Add jitter (randomness) to prevent thundering herd
      const jitter = delay * (0.5 + Math.random());
      console.log(`Retry ${i + 1} in ${Math.round(jitter)}ms`);
      
      await new Promise(r => setTimeout(r, jitter));
      delay *= 2; // Exponential backoff
    }
  }
}

// Retry timeline example:
// Attempt 1 → fail → wait ~300ms
// Attempt 2 → fail → wait ~600ms  
// Attempt 3 → fail → wait ~1200ms
// Attempt 4 → fail → wait ~2400ms
// Attempt 5 → success or final failure

🚀 Production-Ready: The Complete Pattern

This is the enterprise-grade pattern combining timeout, retry, backoff, and abort:

// Production-Ready: Timeout + Retry + Backoff + Abort
// This is enterprise-grade reliability

async function robustFetch(url, { timeout = 5000, retries = 4 } = {}) {
  let controller;

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

    try {
      const res = await fetch(url, { signal: controller.signal });
      clearTimeout(timeoutId);

      if (!res.ok) throw new Error("HTTP " + res.status);
      return res;
    } catch (err) {
      clearTimeout(timeoutId);

      // Don't retry if manually aborted
      if (err.name === "AbortError") throw err;

      // Final attempt - throw the error
      if (i === retries - 1) throw err;

      // Exponential backoff with jitter
      const delay = 300 * Math.pow(2, i) * (0.5 + Math.random());
      console.log(`Retry ${i + 1} after ${Math.round(delay)}ms`);
      await new Promise(r => setTimeout(r, delay));
    }
  }
}

// Usage
robustFetch("/api/critical-data", { timeout: 3000, retries: 3 })
  .then(res => res.json())
  .then(data => console.log("Data:", data))
  .catch(err => console.error("Final failure:", err));

UI Stability: Preventing Race Conditions

The worst UX mistake in async UIs is overwriting content from old responses. This pattern ensures only the latest request updates the UI:

// UI Stability: Prevent stale data from overwriting new data
// Solves race condition bugs

let activeRequestID = 0;

async function loadData(endpoint) {
  const id = ++activeRequestID;

  const res = await robustFetch(endpoint);
  const data = await res.json();

  // Only update UI if this is still the current request
  if (id === activeRequestID) {
    renderUI(data);
    console.log("UI updated with latest data");
  } else {
    console.log("Ignoring stale response");
  }
}

// Example: User clicks rapidly
// Click 1: "Load Users" → request starts
// Click 2: "Load Posts" → request starts, ID = 2
// Click 3: "Load Comments" → request starts, ID = 3
// "Users" response arrives → ID 1 !== 3, IGNORED
// "Comments" response arrives → ID 3 === 3, RENDERED

Error Lifecycle: Mapping Every Failure Type

Professionally built apps classify failures for better UX and analytics:

// Error Lifecycle: Classify failures for better UX

class RequestError extends Error {
  constructor(type, message, details = {}) {
    super(message);
    this.type = type;
    this.details = details;
  }
}

// Error types and how to handle them:
const ErrorTypes = {
  ABORT: "AbortError",      // User cancelled → silent
  TIMEOUT: "TimeoutError",  // Too slow → "Try again"
  NETWORK: "NetworkError",  // No connection → "Check internet"
  HTTP: "HTTPError",        // 404, 500 → show status
  PARSE: "ParseError"       // JSON fail → "Server error"
};

async function smartFetch(url, options = {}) {
  try {
    const res = await fetch(url, options);
    
    if (!res.ok) {
      throw new RequestError(
        ErrorTypes.HTTP, 
        `HTTP ${res.status}`,
        { status: res.status }
      );
    }
    
    try {
      return await res.json();
    } catch (e) {
      throw new RequestError(ErrorTypes.PARSE, "Invalid JSON response");
    }
  } catch (err) {
    if (err.name === "AbortError") {
      throw new RequestError(ErrorTypes.ABORT, "Request cancelled");
    }
    if (err.name === "TypeError") {
      throw new RequestError(ErrorTypes.NETWORK, "Network unavailable");
    }
    throw err;
  }
}

// Handle errors differently
smartFetch("/api/data")
  .then(data => showData(data))
  .catch(err => {
    switch (err.type) {
      case ErrorTypes.ABORT:
        // Silent - user cancelled
        break;
      case ErrorTypes.TIMEOUT:
        showMessage("Request timed out. Try again?");
        break;
      case ErrorTypes.NETWORK:
        showMessage("Check your internet connection");
        break;
      case ErrorTypes.HTTP:
        showMessage(`Error: ${err.details.status}`);
        break;
      default:
        showMessage("Something went wrong");
    }
  });

❌ Common Mistakes to Avoid

Retrying on 4xx errors

These mean bad request, unauthorized, or not found. Retrying wastes time.

Retrying writes without idempotency

Re-posting an order or payment can charge multiple times!

Always cap retries (e.g. max 3–5 times).

Not cancelling old requests

Causes stale UI, flickering, and wrong data.

Causes infinite loaders that ruin retention.

API storming causes lag and backend spikes.

🎯 Key Takeaways

🎯 Mini-Challenge: The Retry Planner

This is the brain of fetchWithRetry with the network taken out: given the statuses a server hands back, decide at every step whether to retry, stop on success, or bail out because retrying cannot help. If you can write this, the async wrapper around it is just plumbing.

Nothing is filled in. You get the rules and a fixed test drive at the bottom — match the expected output and your planner is correct.

// 🎯 MINI-CHALLENGE: the retry planner
//
// Write planRetries(statuses, maxAttempts) from scratch. Only the brief is here.
// It walks a list of server responses and prints one line per attempt, then a
// final verdict line.
//
// Rules:
// 1. Walk "statuses" in order, counting attempts from 1, and never take more
//    than maxAttempts attempts.
// 2. 200                       -> print "attempt N: 200 SUCCESS", stop, verdict "resolved"
// 3. Retryable (429 or 500-599) -> print "attempt N: STATUS retrying", carry on
// 4. Anything else             -> print "attempt N: STATUS fatal", stop, verdict "failed"
// 5. Ran out of attempts or statuses with no 200 -> verdict "exhausted"
// 6. The last line of every call is always: "verdict: X"

// your code here


// --- Do not change the code below: this is the test drive ---
planRetries([503, 500, 200], 5);
console.log("---");
planRetries([503, 404], 5);
console.log("---");
planRetries([500, 500, 500], 3);

// ✅ Expected output:
// attempt 1: 503 retrying
// attempt 2: 500 retrying
// attempt 3: 200 SUCCESS
// verdict: resolved
// ---
// attempt 1: 503 retrying
// attempt 2: 404 fatal
// verdict: failed
// ---
// attempt 1: 500 retrying
// attempt 2: 500 retrying
// attempt 3: 500 retrying
// verdict: exhausted

🔥 Practice Challenges

🎉 Lesson Complete!

You now have a complete toolkit of production-grade fetch patterns.

Practice quiz

Why is plain fetch() not enough for production, per the lesson?

  • It can't parse JSON
  • It only works in Node.js
  • It lacks retries, timeouts, and cancellation on its own
  • It always returns text

Answer: It lacks retries, timeouts, and cancellation on its own. Fetch is low-level: it just sends a request and returns a response. Production apps also need retries, timeouts, and cancellation.

In the safeFetch wrapper, what happens when res.ok is false?

  • It throws an Error with the status attached
  • It returns null
  • It retries automatically
  • It returns the response unchanged

Answer: It throws an Error with the status attached. safeFetch turns HTTP errors into thrown errors, attaching err.status and err.body so callers handle them in one place.

According to the retry rules, which errors should you NOT retry?

  • Network errors
  • 5xx server errors
  • Timeout errors
  • 4xx client errors like 401 and 404

Answer: 4xx client errors like 401 and 404. The rules say retry on network errors and 5xx, but DON'T retry 4xx client errors (401, 404) since retrying won't help.

What does exponential backoff do between retries?

  • Keeps the delay constant
  • Doubles the wait time each retry (backoffMs *= 2)
  • Halves the delay each retry
  • Removes the delay entirely

Answer: Doubles the wait time each retry (backoffMs *= 2). Exponential backoff multiplies the delay by 2 each attempt, so each retry waits longer than the last.

Which browser API is used to add a client-side timeout to fetch?

  • AbortController
  • setInterval
  • Promise.allSettled
  • navigator.connection

Answer: AbortController. AbortController is used: a setTimeout calls controller.abort(), and controller.signal is passed to fetch to cancel it.

When a fetch is aborted, what is the error's name?

  • TimeoutError
  • CancelError
  • AbortError
  • NetworkError

Answer: AbortError. An aborted fetch rejects with an error whose name is 'AbortError', which the lesson checks to handle timeouts/cancellation.

In the cancel-previous-search pattern, why abort the old request before a new one?

  • To save the user's password
  • To prevent stale, out-of-order results from flickering the UI
  • To reduce JSON size
  • To enable HTTPS

Answer: To prevent stale, out-of-order results from flickering the UI. Aborting the previous controller keeps the UI synced with the LATEST query only, preventing stale, out-of-order results.

How can you cancel several in-flight requests with one call?

  • Call fetch.cancelAll()
  • Use a different controller per request
  • Reload the page
  • Pass the same AbortController signal to all of them and abort it once

Answer: Pass the same AbortController signal to all of them and abort it once. Linking multiple fetches to one controller's signal means a single controller.abort() stops all of them.

What problem does the RequestQueue concurrency control solve?

  • Parsing invalid JSON
  • API storming (too many simultaneous requests causing lag and backend spikes)
  • Expired tokens
  • CORS errors

Answer: API storming (too many simultaneous requests causing lag and backend spikes). RequestQueue caps how many fetches run at once (e.g. 4), preventing UI lag, browser freezing, and backend spikes.

In the UI stability pattern, how is a stale response detected and ignored?

  • By comparing timestamps
  • By checking res.ok
  • By checking if the request's id still equals the current activeRequestID
  • By aborting the controller

Answer: By checking if the request's id still equals the current activeRequestID. Each request gets an incrementing id; the UI only updates if id === activeRequestID, so older responses are ignored.

Continue this course