Course: JavaScript Advanced
Lesson 9: Advanced Async Patterns
Level
Advanced
Estimated Reading Time
10–15 minutes
Goal
By the end of this lesson, the learner will be able to:
- Use
Promise.allto fetch multiple resources concurrently and handle partial failures - Implement cancellable requests with
AbortControllerto prevent stale responses - Identify and avoid race conditions in async UI updates
Frontend Development Context
Most real frontend applications need to fetch data from multiple endpoints simultaneously — a dashboard might need user data, notifications, and analytics all at once. Doing these sequentially with await wastes time; doing them concurrently with Promise.all can cut load time dramatically. But concurrency also introduces new failure modes: what happens when one request fails? What happens when a user navigates away mid-fetch and the response arrives after they've moved on?
Advanced async patterns are what separate functional prototypes from production-quality frontends. Handling concurrent requests, cancelling stale fetches, avoiding memory leaks from responses that arrive too late, and dealing with race conditions in search-as-you-type inputs are real problems every frontend developer faces. This lesson teaches the tools and patterns to handle them correctly.
Explanation
Promise.all(promises) takes an array of promises and returns a single promise that resolves when all of them resolve, with an array of their results in the same order. If any promise rejects, Promise.all immediately rejects with that rejection reason and ignores the others. This fail-fast behavior is appropriate when you need all responses to render the UI — if any one fails, the whole operation is meaningless.
However, fail-fast is not always what you want. If some parts of a page are independent, failing to fetch one section shouldn't block the others. For these cases, combine Promise.all with individual try/catch blocks or use Promise.allSettled (covered in the next lesson) to handle each result individually regardless of success or failure.
AbortController is the browser API for cancelling fetch requests. You create a controller, pass its signal to fetch, and call controller.abort() whenever you need to cancel. The fetch promise rejects with an AbortError. Cancelling stale requests is essential in search-as-you-type (where a new keystroke should cancel the in-flight request from the previous keystroke) and in component cleanup (where navigating away should cancel any pending fetches).
Race conditions occur when two async operations are in flight simultaneously and the one that started later resolves first. In a search field, if the user types "ja" and then "jav", the "jav" request might resolve before the "ja" request. If the UI renders whatever resolves last, it will display results for "ja" — the wrong query. The fix is to track which request is "current" and ignore responses from stale ones.
Combining AbortController with a request ID or generation counter provides robust race-condition prevention. When a new request starts, abort the previous one. The AbortError from the cancelled request is caught and ignored, and only the latest response ever updates the UI.
Code Example
This example shows Promise.all for a dashboard that fetches three independent endpoints, with AbortController cancelling stale search requests to prevent race conditions.
// Simulate fetch with controllable delay
function fakeFetch(url, signal, delay = 200) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
resolve({ url, data: `data from ${url}` });
}, delay);
signal?.addEventListener("abort", () => {
clearTimeout(timeout);
reject(new DOMException("Aborted", "AbortError"));
});
});
}
// --- Promise.all: fetch dashboard sections concurrently ---
async function loadDashboard() {
const controller = new AbortController();
const { signal } = controller;
// Optional: cancel all if user navigates away
// window.addEventListener("beforeunload", () => controller.abort());
try {
const [user, notifications, analytics] = await Promise.all([
fakeFetch("/api/user", signal, 150),
fakeFetch("/api/notifications", signal, 200),
fakeFetch("/api/analytics", signal, 180),
]);
console.log("Dashboard loaded:");
console.log(" User:", user.data);
console.log(" Notifications:", notifications.data);
console.log(" Analytics:", analytics.data);
} catch (err) {
if (err.name === "AbortError") {
console.log("Dashboard fetch cancelled");
} else {
console.error("Dashboard fetch failed:", err.message);
}
}
}
// --- AbortController: prevent race conditions in search ---
let currentSearchController = null;
async function search(query) {
// Cancel the previous in-flight request
if (currentSearchController) {
currentSearchController.abort();
}
currentSearchController = new AbortController();
const { signal } = currentSearchController;
try {
const result = await fakeFetch(`/api/search?q=${query}`, signal, 300);
console.log(`Search result for "${query}":`, result.data);
// Safe: only the latest request reaches here
} catch (err) {
if (err.name === "AbortError") {
console.log(`Search for "${query}" was cancelled (superseded)`);
} else {
console.error("Search error:", err.message);
}
}
}
loadDashboard();
// Simulate rapid search input — first two should be cancelled
search("j");
search("ja");
search("jav"); // only this one should complete
Code Explanation
fakeFetch simulates a real fetch call: it resolves after a delay and listens to the AbortSignal. When abort() is called, the timeout is cleared and the promise rejects with an AbortError. This mirrors exactly how fetch behaves with a real signal.
loadDashboard uses Promise.all to start all three requests simultaneously. All three begin at the same moment and the dashboard loads as soon as the slowest one resolves (200ms for notifications), rather than waiting for each to finish sequentially (150 + 200 + 180 = 530ms). If any request is aborted or fails, the catch block checks for AbortError and handles it differently from network errors.
search demonstrates the critical pattern for race condition prevention. currentSearchController holds the controller for the currently active request. Every time search is called with a new query, it aborts the previous request (if one exists) and creates a fresh controller. The aborted request's catch block sees AbortError and returns silently. Only the latest request — the one for "jav" — ever reaches the console.log after await.
This pattern is used directly in production search implementations, React's useEffect cleanup functions, and any component that fetches data and might be unmounted before the response arrives.
Pattern Highlights
✅ Positive Pattern: Always check for
AbortErrorspecifically in your catch blocks when usingAbortController— treating a cancellation the same as a network error will log false failures and potentially show error UI to users who simply typed another character.
⚠️ Neutral Note:
Promise.allfails fast on the first rejection. If your UI can partially render while some sections fail, use individualtry/catcharound each fetch or usePromise.allSettledinstead, which waits for all promises and reports each result individually.
❌ Negative Pattern: Do not
awaitsequential requests that could run in parallel — awaiting each fetch one after another in aforloop makes the total wait time the sum of all delays;Promise.allmakes it the maximum of the longest delay.
Common Mistakes
Mistake 1: Awaiting requests sequentially instead of concurrently
Using await before each request forces them to run one at a time.
async function loadPage() {
const user = await fetch("/api/user"); // waits ~200ms
const posts = await fetch("/api/posts"); // waits another ~300ms
const comments = await fetch("/api/comments"); // waits another ~150ms
// Total: ~650ms
}
These requests are independent. Use Promise.all to run them concurrently and the total wait is only ~300ms (the longest request).
Mistake 2: Not cleaning up fetch when a component unmounts
In a React useEffect, if you don't abort the fetch on cleanup, a response from an unmounted component can try to update state and cause a warning (or worse, incorrect UI state).
useEffect(() => {
fetch("/api/data")
.then(res => res.json())
.then(data => setData(data)); // may run after unmount
}, []);
// Missing: cleanup function with AbortController
Create an AbortController in the effect, pass its signal to fetch, and return a cleanup function that calls controller.abort().
Mistake 3: Not handling the race condition in search
Starting a new fetch without cancelling the old one means stale results can overwrite fresh ones.
async function search(query) {
const result = await fetch(`/api/search?q=${query}`);
const data = await result.json();
renderResults(data); // may render old results if a newer fetch resolved first
}
Without aborting the previous fetch, queries "a", "ab", "abc" can all be in flight simultaneously, and whichever resolves last wins — which may be "ab" instead of "abc". Always abort the previous controller before starting a new request.
Quick Recap
Promise.allruns multiple promises concurrently and resolves when all succeed or rejects immediately when any one fails — reducing total wait time from the sum of delays to the maximum.AbortControllerprovides asignalthat can be passed tofetchand other APIs; callingcontroller.abort()cancels in-flight requests and rejects withAbortError.- Race conditions occur when multiple async operations are in flight and an older one resolves after a newer one — prevent this by aborting the previous request each time a new one starts.
- Always distinguish
AbortErrorfrom genuine errors in catch blocks — a cancelled request is not a failure and should not be reported as one.