Async patterns
Learn the practical async patterns that keep apps calm: retry with backoff, timeout races, task pools, polling loops, and sleep helpers.
- 01Retry carefullyRetry only transient, safe operations with capped exponential backoff and jitter.
- 02Control waitingRace work against a timeout and know why race alone does not cancel the loser.
- 03Limit trafficUse pools and polling loops so async work happens steadily instead of all at once.
Async work needs traffic rules
async and await make asynchronous code read from top to bottom, but real applications still need patterns. A network can be slow, a server can be busy, a user can leave, and eight jobs can all want the same resource at once.
This lesson collects five patterns you will reuse constantly: retry with backoff, timeouts, queues and pools, polling, and sleep helpers. They all share one rule: waiting should be deliberate, bounded, and cancelable when possible.
An async pattern is a small, repeatable shape for waiting without blocking: try again later, stop waiting after a limit, keep only a few tasks in flight, ask for status until done, or pause with a promise-based timer.
If you want to review the building blocks, revisit Promises, Timers, Error handling strategies, and The event loop.
Roads work because everyone agrees on a few rules. Async code is similar. You can launch everything at once, retry forever, and poll every millisecond, but the app becomes noisy and unfair. Patterns are the traffic rules that keep work moving.
- In real life: Speed limits
- In JavaScript: Timeouts and total retry budgets
- In real life: Traffic lights
- In JavaScript: Pools that let only N tasks run
- In real life: Detours
- In JavaScript: Retries after temporary failures
- In real life: Exit signs
- In JavaScript: Abort signals and stop conditions
Where the analogy stops: Roads do not choose for you where to go. Async patterns do not make a flaky API correct; they only make your waiting safer and more predictable.
Retry with backoff
INTERACTIVEA retry is another attempt after a failure. Backoff means the waits get longer: 100 ms, 200 ms, 400 ms, then perhaps capped at 2 seconds. With full jitter, you pick a random wait between 0 and that cap so every client does not retry at the same instant.
If your friend is busy, redialing ten times per second is rude and useless. Waiting a little longer each time gives them room to become available. Jitter prevents every caller who got the same busy signal from redialing together.
- In real life: Busy signal
- In JavaScript: Temporary failure such as timeout, 503, or 429
- In real life: Wait 1, then 2, then 4 minutes
- In JavaScript: Exponential backoff: base × 2^attempt
- In real life: Add a small random pause
- In JavaScript: Jitter spreads clients out
- In real life: Stop after a few tries
- In JavaScript: Attempt and total-time caps
Where the analogy stops: A friend knows you personally. Servers see huge crowds, so polite retry code must also check whether the operation is safe to repeat.
Retry only when both conditions are true: the error is probably temporary, and repeating the operation is safe. Reads are usually safe. Writes need idempotency, such as a payment API accepting an idempotency key so repeated attempts do not charge twice.
async function retry(operation, options) { for (let attempt = 0; attempt < options.maxAttempts; attempt += 1) { try { return await operation(); } catch (error) { if (!error.temporary) throw error; const uncapped = options.baseDelay * 2 ** attempt; const delay = Math.min(uncapped, options.maxDelay); const wait = options.random() * delay; await options.sleep(wait); } } throw new Error("No attempts left");}- Press Run to call the flaky server.
Run a fake server that fails first. Temporary failures retry with capped backoff; permanent failures stop immediately. The random value is fixed here so the timeline is teachable.
The lab injects a fixed random function and short sleeps. Production code should do the same for tests: inject sleep and random, then assert delays and attempt counts instead of waiting for real time.
- Network timeout while reading a profile
- 503 Service Unavailable
- 429 Too Many Requests with
Retry-After - 400 Bad Request
- 401 Unauthorized
- 404 Not Found
- A payment
POSTwith no idempotency key
Sort each failure. Imagine the request is otherwise well-formed and ask whether another identical attempt is safe.
Timeouts with race
STEP THROUGHA timeout is a promise that rejects after a limit. Promise.race lets the real task and the timer compete. The first one to settle decides the wrapper’s result.
If the kitchen timer rings before the cake is done, you know you have waited long enough. But the timer does not magically remove the cake from the oven. Likewise, a timed-out race does not stop the promise that lost.
- In real life: Cake in the oven
- In JavaScript: The original async task
- In real life: Kitchen timer
- In JavaScript: A timeout promise
- In real life: Whichever finishes first gets attention
- In JavaScript:
Promise.racesettles with the first promise - In real life: The cake can keep baking
- In JavaScript: Race does not cancel the losing task
Where the analogy stops: A human can pull the cake out. Promise.race alone cannot stop JavaScript work; the task must accept a cancellation signal.
Step through a timeout wrapper. Change the setting to see a task beat the timer or a timer beat the task.
script
let timer; const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("Timed out")), ms); }); return Promise.race([promise, timeout]) .finally(() => clearTimeout(timer));}Two details matter. First, clear the timeout in finally so the timer does not fire later or keep a Node process alive. Second, use an AbortController when the underlying task can cooperate. The previous lesson, Cancellation with AbortController, covers that pattern in detail.
Async queues & pools
INTERACTIVEA pool keeps at most N tasks in flight. It is the difference between opening 100 network requests at once and allowing four workers to steadily drain a line. This protects the browser, the server, and the user’s battery.
A bank with three tellers does not serve everyone at once. It serves up to three people, then calls the next ticket as each teller becomes free. A task pool follows the same shape.
- In real life: Numbered-ticket line
- In JavaScript: The queue of jobs
- In real life: Three tellers
- In JavaScript: Concurrency of 3
- In real life: Next customer walks up
- In JavaScript: Next task starts when a worker finishes
- In real life: Receipt order
- In JavaScript: Results can be stored by original input index
Where the analogy stops: Bank customers are people; tasks can fail. Your pool must decide whether to collect errors and continue or fail fast and stop launching new work.
async function runPool(tasks, concurrency) { const results = []; let next = 0; async function worker() { while (next < tasks.length) { const index = next++; results[index] = await tasks[index](); } } await Promise.all(Array.from({ length: concurrency }, worker)); return results;}- Job 1: 0–320 ms
- Job 7: 320–460 ms
- Job 2: 0–120 ms
- Job 4: 120–300 ms
- Job 6: 300–560 ms
- Job 3: 0–240 ms
- Job 5: 240–320 ms
- Job 8: 320–540 ms
Results: job 1, job 2, job 3, job 4, job 5, job 6, job 7, job 8
At most 3 jobs run at once. Results stay in input order even when short jobs finish early.
Decide the failure policy before you write the helper. Collect errors when independent jobs should all get a chance, such as image thumbnails. Fail fast when one failure makes the whole result useless, such as loading required configuration.
Polling
INTERACTIVEPolling means checking again and again until a job is done, failed, aborted, or timed out. It is useful when the server cannot push updates yet. Later, push tools such as WebSocket or Server-Sent Events can act more like a doorbell.
Without a doorbell, you might check the mailbox every few minutes. Checking every second wastes energy; checking forever is worse. Good polling is patient and has exit ramps.
- In real life: Walk to the mailbox every few minutes
- In JavaScript: Send a status request on an interval
- In real life: Stop when the package arrives
- In JavaScript: Return on
done - In real life: Stop when the delivery is cancelled
- In JavaScript: Throw on
failedor abort - In real life: A doorbell would be nicer
- In JavaScript: Push updates, covered in a later lesson
Where the analogy stops: Mailboxes do not overload your house. Servers can be overloaded, so poll slowly, back off when responses are slow, and stop when the page no longer needs the answer.
async function pollJob(checkStatus, { interval, timeout, signal, sleep }) { const deadline = Date.now() + timeout; while (Date.now() < deadline) { if (signal?.aborted) throw signal.reason; const status = await checkStatus(); if (status.state === "done") return status.value; if (status.state === "failed") throw new Error(status.reason); await sleep(interval, { signal }); } throw new Error("Polling timed out");}- Press Start to check job status.
This model waits for each check, then sleeps, then checks again. It is an async loop shape, not setInterval, so requests cannot pile up on top of each other.
Prefer an async loop or nested setTimeout over setInterval for network polling. The loop can await the request and the sleep, so the next poll does not start while the previous one is still running.
sleep()
CODEJavaScript does not have a built-in browser sleep function, but a timer can resolve a promise. Awaiting that promise pauses only your async function; the page and event loop keep working.
A nap alarm lets you pause yourself without stopping the whole household. await sleep(500) pauses one async function without blocking the browser.
- In real life: Set an alarm
- In JavaScript: Call
setTimeout - In real life: Nap until it rings
- In JavaScript:
await sleep(ms)inside one async function - In real life: Cancel the alarm
- In JavaScript: Abort signal clears the timer
Where the analogy stops: A real nap stops you completely. await sleep does not freeze JavaScript; other tasks, rendering, and user events continue.
const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms);}); await sleep(500);console.log("half a second later");function sleep(ms, { signal } = {}) { return new Promise((resolve, reject) => { if (signal?.aborted) return reject(signal.reason); const timer = setTimeout(resolve, ms); signal?.addEventListener("abort", () => { clearTimeout(timer); reject(signal.reason); }, { once: true }); });}In Node.js, you can also import setTimeout from node:timers/promises and await it directly. In browser lessons, a small helper keeps the idea visible.
Where you’ll use this
Real front-end code often combines patterns. A draft save might retry a safe idempotent PUT, parse the response with a timeout, and stop if the user leaves the page.
async function saveDraft(draft, api) { const result = await retry( () => api.putDraft(draft, { idempotencyKey: draft.id }), { maxAttempts: 4, baseDelay: 100, maxDelay: 1000, sleep, random: Math.random }, ); return withTimeout(result.json(), 2000);}| Need | Pattern | Stop condition |
|---|---|---|
| Temporary network failure | Retry with backoff | Attempt cap, total time, permanent error |
| User should not wait forever | Timeout | Timer wins, then cleanup and maybe abort |
| Many independent tasks | Pool | Queue empty, fail fast, or collected errors returned |
| Long server job | Polling loop | Done, failed, abort, or timeout |
| Pause between attempts | sleep(ms) | Timer resolves or signal aborts |
Common misconceptions
- “Retry everything.” Retry transient errors only. A 400, 401, or unsafe payment write needs a fix, not another identical attempt.
- “Backoff can run forever.” Always cap delay, attempts, and total time.
- “Promise.race cancels the loser.” It settles the wrapper. The losing task keeps running unless it cooperates with cancellation.
- “setInterval is perfect for polling.” It can overlap slow requests. An async loop waits before the next check.
- “A pool changes result order.” Completion order changes, but you can store each result at its original index.
- “sleep blocks JavaScript.” Awaiting a sleep promise pauses one async function, not the whole event loop.
Make helpers testable by injecting sleep and random. Unit tests can then assert “attempts were 1, 2, 3 and waits were 100, 200, 400” without depending on wall-clock time.
Practice exercises
5 EXERCISESRun the mental model first: what exact text reaches the console?
const waits = [];
for (let attempt = 0; attempt < 3; attempt += 1) {
waits.push(100 * 2 ** attempt);
}
console.log(waits.join(", "));const waits = [];
for (let attempt = 0; attempt < 3; attempt += 1) {
waits.push(100 * 2 ** attempt);
}
console.log(waits.join(", "));The loop pushes 100, then 200, then 400, so the joined output is 100, 200, 400.
Answer with yes or no: does Promise.race cancel the slower promise?
No. Promise.race settles with the winner, but the slower promise may keep doing work. Use an AbortSignal for cooperative cancellation.
What does the starter print, and which line shape preserves this order in an async pool?
const finished = [];
const jobs = ["A", "B", "C"];
for (const name of jobs) {
finished.push(name);
}
console.log(finished.join(" -> "));results[index] = await tasks[index]();A real pool can finish jobs out of order. Store each value at results[index] so the final array matches the input order.
Predict the output of this simplified polling counter.
let polls = 0;
while (polls < 3) {
polls += 1;
}
console.log("polls", polls);let polls = 0;
while (polls < 3) {
polls += 1;
}
console.log("polls", polls);The loop runs for 0 → 1, 1 → 2, and 2 → 3, then prints polls 3.
Write down a retry policy for saving a profile form. Include what you retry, what you never retry, and how you stop.
retry(save, { maxAttempts: 4, baseDelay: 100, maxDelay: 1000, sleep, random });A safe policy retries only transient errors, requires idempotent or idempotency-keyed operations, uses capped exponential backoff with jitter, and stops after a small attempt or time budget.
Check your understanding
7 QUESTIONSQuestion 1 of 7Which retry delay formula is capped exponential backoff?
Choose an answer to see the explanation.
Question 2 of 7What does this print?
Read the code, then predictconst waits = []; for (let attempt = 0; attempt < 3; attempt += 1) { waits.push(50 * 2 ** attempt); } console.log(waits.join("/"));Choose an answer to see the explanation.
Question 3 of 7What does
Promise.race([task, timeout])do when the timeout wins?Choose an answer to see the explanation.
Question 4 of 7How many waves does this pool need?
Read the code, then predictconst concurrency = 3; const jobs = 8; console.log(Math.ceil(jobs / concurrency));Choose an answer to see the explanation.
Question 5 of 7Why prefer an async polling loop over
setIntervalfor requests?Choose an answer to see the explanation.
Question 6 of 7Which operation is safest to retry?
Choose an answer to see the explanation.
Question 7 of 7What should a timeout helper do with its timer after the race settles?
Choose an answer to see the explanation.
Key takeaways
- Retry only transient, safe operations; use exponential backoff, jitter, and caps.
Promise.raceis a timeout wrapper, not cancellation. Clear timers and use signals when work must stop.- Pools keep at most N tasks in flight and should define how failures are handled.
- Polling should be an async loop or nested timeout so checks do not overlap.
sleepis just a promise around a timer; make it cancellable when it may wait a long time.
Async patterns are reusable waiting shapes that make asynchronous work bounded, polite, and predictable.
Up next: The browser environment.