Advanced Lesson
Advanced Fetch API Patterns
Master retry logic, timeouts, AbortController, and concurrency control for production-grade network handling
๐ฏ 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
.htmlfile 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 Problems
Why plain fetch() is not enough for production
// 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 navigat
...Building a Basic Fetch Wrapper
A good pattern is to wrap fetch in a function that normalizes errors and parses JSON automatically:
Safe Fetch Wrapper
Normalize errors and auto-parse JSON
// 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 (conte
...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 Rules
- โ Retry on network errors (fetch throws)
- โ Retry on 5xx server errors
- โ DON'T retry on 4xx client errors (401, 404, etc.)
Retry Logic with Backoff
Retry on network and 5xx errors with exponential backoff
// 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
...Combining Retry with safeFetch
Refactor so callers always get parsed data with automatic retries:
Combined Retry with safeFetch
Auto-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();
}
...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
Prevent requests from hanging 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 === "AbortEr
...AbortController Basics
The browser gives us AbortController to cancel requests instantly. This is critical for modern apps.
AbortController Basics
Cancel requests instantly with AbortController
// 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 rej
...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.
Cancel Previous Search Requests
Prevent stale results and flickering
// 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 =
...Linking Multiple Requests to One Cancellation
You can connect multiple requests to one AbortController. Canceling it stops ALL in-flight operations.
Linking Multiple Requests
Cancel all related requests with one call
// 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
}
...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 to prevent API storming
// 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 } = t
...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
Prevent thundering herd with randomized delays
// 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(`Re
...๐ Production-Ready: The Complete Pattern
This is the enterprise-grade pattern combining timeout, retry, backoff, and abort:
Production-Ready Pattern
Enterprise-grade: timeout + retry + backoff + 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)
...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
// 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
...Error Lifecycle: Mapping Every Failure Type
Professionally built apps classify failures for better UX and analytics:
Error Lifecycle
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
P
...โ 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!
Infinite retry loops
Always cap retries (e.g. max 3โ5 times).
Not cancelling old requests
Causes stale UI, flickering, and wrong data.
No timeout
Causes infinite loaders that ruin retention.
No concurrency limit
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
๐ฅ 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. Next up: Web Storage APIs.
Sign up for free to track which lessons you've completed and get learning reminders.