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
- Why plain fetch() isn't enough for production
- Building safe fetch wrappers
- Retry logic with exponential backoff
- Timeouts using AbortController
- Cancelling stale requests
- Concurrency control & rate limiting
💡 Running Code Locally: While this online editor runs real JavaScript, some advanced examples may have limitations. For the best experience:
- Download Node.js to run JavaScript on your computer
- Use your browser's Developer Console (Press F12) to test code snippets
- Create a .html file with <script> tags and open it in your browser
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 everywhereBuilding 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 on network errors (fetch throws)
- ✅ Retry on 5xx server errors
- ❌ DON'T retry on 4xx client errors (401, 404, etc.)
// 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 retryingCombining 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 loadReal-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 onlyLinking 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, RENDEREDError 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
- ✓ Wrap fetch in a helper that normalizes errors and parses JSON
- ✓ Use retries with exponential backoff and jitter for resilience
- ✓ Add timeouts using AbortController to prevent infinite loading
- ✓ Cancel previous requests when user actions change (search, navigation)
- ✓ Use concurrency limits to prevent API storming
- ✓ Track request IDs to prevent stale data from overwriting new data
- ✓ Classify errors by type for better UX and analytics
🎯 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
- Build a search input that cancels old requests on every keystroke
- Create a fetchWithTimeout helper using AbortController
- Combine timeout + retry + backoff in one request() function
- Build a dashboard loader that cancels 3 parallel requests with one controller
- Add a "cancel" button that immediately stops all fetches
- Implement a RequestQueue class with concurrency limits
- Create a global request manager for your site
🎉 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
- Previous: Virtual DOM Concepts & Efficient UI Updates
- Next: Web Storage APIs (localStorage, sessionStorage, IndexedDB) — Persist data in the browser across sessions using storage APIs
- Quick reference: JavaScript cheat sheet