Async Patterns — Concurrency, Queues and Cancellation
AdvancedBeyond a single request: limiting how many run at once, cancelling superseded work, and making retries safe.
Overview
Real applications rarely make one request. They fire many, and doing that naively either overwhelms the server or takes far too long. The three patterns worth having are a concurrency limit, so a thousand items do not become a thousand simultaneous requests; cancellation, so a superseded search does not overwrite a newer result; and idempotent retry, so repeating a failed request cannot double-charge someone. None of these are built in, which is why every codebase reimplements them.
Limiting Concurrency
Promise.all starts everything at once. With hundreds of items that is a self-inflicted denial of service.
// All 500 at once — the server, or the browser's 6-connection limit, suffers
await Promise.all(ids.map(fetchOne))
// At most N in flight
async function pool(items, limit, worker) {
const results = []
const running = new Set()
for (const [i, item] of items.entries()) {
const p = Promise.resolve(worker(item, i))
.then(r => { results[i] = r })
.finally(() => running.delete(p))
running.add(p)
if (running.size >= limit) await Promise.race(running)
}
await Promise.all(running)
return results
}
const pages = await pool(urls, 5, url => fetch(url).then(r => r.json()))Cancelling Superseded Work
When a newer request supersedes an older one, the old response must not be allowed to win. There are two fixes.
// The race: request A (slow) then B (fast). B lands, then A lands
// and overwrites it with stale data.
// Fix 1 — abort the previous request
let controller
async function search(q) {
controller?.abort()
controller = new AbortController()
const res = await fetch(`/api/search?q=${q}`, { signal: controller.signal })
setResults(await res.json())
}
// Fix 2 — ignore stale responses by sequence number
let latest = 0
async function search(q) {
const seq = ++latest
const data = await fetchResults(q)
if (seq === latest) setResults(data) // drop if superseded
}Idempotent Retry
Retrying a GET is safe. Retrying a POST can create two orders — unless the server can recognise the repeat.
// Safe to retry: GET, PUT, DELETE (idempotent by definition)
// Unsafe: POST — a retry after a timeout may create a second record,
// because you cannot tell "never arrived" from "arrived, reply lost"
// The fix is an idempotency key the server remembers:
const key = crypto.randomUUID()
await fetch('/api/orders', {
method: 'POST',
headers: { 'Idempotency-Key': key, 'Content-Type': 'application/json' },
body: JSON.stringify(order),
})
// Retry with the SAME key — the server returns the original result
// instead of creating a second order. This is how payment APIs work.
// Backoff with jitter, so retries from many clients do not synchronise
const wait = Math.random() * 2 ** attempt * 200Key Points to Remember
- 1Promise.all starts every task at once — use a concurrency pool for large lists
- 2A slow earlier response can overwrite a newer one; fix it with AbortController or a sequence check
- 3GET, PUT and DELETE are safe to retry; POST is not, without an idempotency key
- 4An idempotency key lets the server recognise a repeat and return the original result instead of acting twice
- 5Add jitter to backoff so retries from many clients do not arrive together
Interview Questions
Sign in to ask AriaHow would you fetch 500 URLs without making 500 simultaneous requests?
A user types quickly and an older search response arrives after a newer one. How do you fix it?
Why is retrying a POST request dangerous, and what makes it safe?
Ask Aria about Async Patterns — Concurrency, Queues and Cancellation
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.