Async patterns for real request flows
Choose the right combinator for parallel work, cap concurrency, retry safely with backoff, and stop duplicate calls before they leave the browser.
Choosing a combinator
Four promise helpers cover almost every fan-out. They differ in exactly two ways: when they resolve, and what happens to the other requests when one fails. Getting that wrong turns one failed call into a blank page, or a silent partial result into a confident wrong answer.
| Helper | Resolves when | Rejects when | Use it for |
|---|---|---|---|
Promise.all | every input resolves | the first input rejects | all-or-nothing loading of a screen |
Promise.allSettled | every input settles | never | dashboards where partial data is fine |
Promise.race | the first input settles | the first rejection if that wins | timeouts, first-of-many mirrors |
Promise.any | the first input resolves | every input rejects | redundant endpoints, fallback hosts |
const urls = ['/api/a', '/api/b', '/api/c'];
const get = u => fetch(u).then(r => r.json());
// all: one failure loses everything already fetched
const [a, b, c] = await Promise.all(urls.map(get));
// allSettled: keeps the successes and describes the failures
const results = await Promise.allSettled(urls.map(get));
const ok = results.filter(r => r.status === 'fulfilled').map(r => r.value);
const bad = results.filter(r => r.status === 'rejected').map(r => r.reason);
// race: useful for a deadline, not for choosing the best response
const withDeadline = Promise.race([
get('/api/slow'),
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000))
]);
// any: fails only when every candidate has failed
const first = await Promise.any(urls.map(get));Promise.alldoes not cancel the other requests when one rejects. They continue and their results are discarded, so a failure does not reduce the load you placed on the server.Promise.raceleaves the losing requests running. Without anAbortSignalthey are pure waste and they can still mutate cached state.Promise.anyrejects with anAggregateErrorwhoseerrorsarray holds every reason. Read that array before logging, or you lose the real cause.- Map over the URL list rather than spawning a fixed number of awaits, and keep the input order: the resulting array matches the order of
urls, not the order responses arrive.
Concurrency limits and retries
Firing 200 requests at once is the fastest way to be rate limited, and every promise rejects together. A pool of four to eight workers keeps the browser and the server comfortable, and it makes failures easier to attribute.
async function pool(items, limit, worker) {
const results = new Array(items.length);
let next = 0;
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (next < items.length) {
const i = next++;
results[i] = await worker(items[i], i);
}
});
await Promise.all(runners);
return results;
}
const items = await pool(ids, 4, id => fetch('/api/items/' + id).then(r => r.json()));const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
async function retry(fn, { tries = 3, base = 300, cap = 5000 } = {}) {
for (let attempt = 0; ; attempt++) {
try {
return await fn();
} catch (err) {
const transient = !err.status || err.status === 429 || err.status >= 500;
if (!transient || attempt >= tries - 1) throw err;
const backoff = Math.min(cap, base * 2 ** attempt);
const jitter = Math.random() * Math.min(100, backoff / 2);
await sleep(backoff + jitter);
}
}
}
const data = await retry(() => fetch('/api/report').then(checkOk).then(r => r.json()));- Retry only what can succeed on a second attempt: network errors, 408, 429 and 5xx. A 400 or 404 will fail identically and only delays the error message.
- Exponential backoff without jitter synchronises every client that failed at the same moment, producing a second, sharper spike. Always add jitter.
- Honour
Retry-Afterwhen the server sends it; it knows more than your backoff formula does. - Never retry a non-idempotent write automatically unless the request carries an idempotency key the server can deduplicate.
Debouncing and request deduplication
function debounce(fn, ms) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
const search = debounce(q => fetch('/api/search?q=' + encodeURIComponent(q)), 250);
// deduplicate identical requests that are already in flight
const inflight = new Map();
function getOnce(url) {
if (inflight.has(url)) return inflight.get(url);
const p = fetch(url).finally(() => inflight.delete(url));
inflight.set(url, p);
return p;
}
const [r1, r2] = await Promise.all([getOnce('/api/me'), getOnce('/api/me')]);
r1 === r2; // true: one request, two callers- Debouncing delays work; throttling limits its rate. A search box wants debounce, a scroll or resize handler usually wants throttle.
- A debounce timer that fires after a component unmounts still sends the request. Combine it with an
AbortSignalyou abort on teardown. - Deduplicate only reads. Sharing an in-flight
POSTbetween two callers hides which one created the record, and a retry after a failure would replay a write. - Key the in-flight map on the method, URL and body together. Keying on the URL alone merges two different writes and produces a bug that looks like data loss.
⚠️
Deduplication is not caching. An in-flight map only merges requests that overlap in time; once the first one settles, the entry is gone and the next caller sends a fresh request. That is deliberate, because it keeps the client correct without needing to reason about freshness.
FAQ
Why did Promise.all reject even though most requests succeeded?
That is its contract: the first rejection wins and the resolved values are lost. Switch to
Promise.allSettled when a partial result is useful, then read each entry's status.How many parallel requests should I allow?
Four to six per origin is a safe default in a browser, because the browser itself limits connections per host. Higher limits rarely increase throughput and make rate limiting and failure bursts more likely.
Related
Caching and conditional requests Building an API client layer
Last refreshed 2026-09-18.