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 failures that may pass, and only work that is safe to repeat. Double the wait each time, cap it, and add a little randomness.
- 02Control waitingRace a task against a timer, and explain why the race alone doesn't stop the slower task.
- 03Limit trafficUse pools and polling loops so async work runs at a steady pace, not all at once.
Async work needs a few rules
Some work takes time, like asking a server for data. JavaScript doesn't stop and wait for it. Work like this is called asynchronous, or async for short.
async and await let you write async code that reads from top to bottom. But real apps still run into problems:
- The network can be slow.
- The server can be busy and say "try again later".
- The user can leave the page before the work ends.
- Eight jobs can all want the same server at the same time.
This lesson shows five small patterns for these problems. A pattern is a shape of code you can reuse again and again:
- Retry with backoff: try again after a failure, and wait longer each time.
- Timeouts: stop waiting after a time limit.
- Queues and pools: run only a few jobs at once.
- Polling: ask "Is it done yet?" again and again, with a pause in between.
- Sleep helpers: pause one async function for a set time.
All five follow one rule: every wait has a limit, and you can stop it when possible.
An async pattern is a small, reusable way to wait without freezing the page. It can try again later, stop after a time limit, run only a few jobs at once, check a job's status until it's done, or pause for a set time.
These patterns are built from things you met earlier: Promises, Timers, Error handling strategies, and The event loop. Go back to them if a word here is new to you.
Roads work because every driver follows a few rules. Without them, every car would go at once, and nobody would move. Async code is the same. You could start every job at once, retry forever, and check for news every millisecond. But the app would get slow and messy. Patterns are the road rules that keep work moving.
- In real life: Speed limits
- In JavaScript: Time limits on each wait, and on all the retries together
- In real life: Traffic lights
- In JavaScript: A pool that lets only a few jobs run at once
- In real life: Detours
- In JavaScript: Retries after a failure that may pass
- In real life: Exit signs
- In JavaScript: Stop signals and stop conditions
Retry with backoff
INTERACTIVEA retry is another try after a failure. Backoff means each wait is longer than the one before. A common choice is to double it: 100 ms, then 200 ms, then 400 ms. (ms means milliseconds. 1000 ms is one second.)
The wait can't grow forever. A cap is the longest wait you allow, such as 2 seconds. Once the doubled wait reaches the cap, it stays there.
Many users can fail at the same moment, for example when a server goes down for a second. If they all wait exactly 100 ms, they all retry at the same moment too, and the server is too busy again. Jitter fixes this. It picks a random wait between 0 and the capped wait, so the retries spread out. This kind is called full jitter.
You call a friend, and the line is busy. Calling again ten times a second is rude, and it doesn't help. Waiting a little longer each time gives your friend time to finish the other call. And if many people are calling, a small random pause means they don't all call back in the same second.
- In real life: The line is busy
- In JavaScript: A failure that may pass, such as a timeout, 503, or 429
- In real life: Wait 1, then 2, then 4 minutes
- In JavaScript: Backoff:
base * 2 ** attempt - In real life: Add a small random pause
- In JavaScript: Jitter spreads the retries out
- In real life: Stop after a few tries
- In JavaScript: A limit on tries and on total time
Retry only when both of these are true:
- The error will probably pass. A server status of
503("Service Unavailable") or429("Too Many Requests") usually means "try again later". - Doing the work twice is safe. Reading data is usually safe.
Writing data is harder. If a payment request runs twice, the customer pays twice. Work is idempotent when doing it twice has the same effect as doing it once. Some payment servers let you send an idempotency key with the request: a unique ID for it. If the same key arrives twice, the server does the work only once.
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 pretend server.
Run a pretend server that fails a few times before it works. Temporary errors are retried, and each wait doubles, up to a cap. A permanent error stops right away. Here the random number is always 0.42, so you get the same waits every time.
The lab passes in its own random function, which always gives 0.42, and its own short sleep. Do the same in tests. Pass in a fake sleep and a fake random, then check the waits and the number of tries. The test doesn't have to wait for real time.
- A 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. Ask two questions: will the error probably pass, and is the same request safe to send again?
Timeouts with race
STEP THROUGHA timeout is a time limit. In code, it's a promise that rejects (fails) when the limit passes.
Promise.race takes a list of promises. It settles (resolves or rejects) the same way as the first one to finish. So you race the real task against the timer. Whichever finishes first decides the result.
Timed out. The task is not stopped. It keeps running and finishes later.You set a kitchen timer for your cake. If the timer rings first, you stop waiting. But the cake is still in the oven, and it keeps baking. In the same way, when the timer wins the race, the slower task keeps running.
- In real life: Cake in the oven
- In JavaScript: The real async task
- In real life: Kitchen timer
- In JavaScript: The timeout promise
- In real life: Whatever finishes first gets your attention
- In JavaScript:
Promise.racesettles like the first promise to finish - In real life: The cake keeps baking
- In JavaScript: The race doesn't stop the slower task
Step through a time limit made with Promise.race. Change the setting to let the task or the timer finish first.
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:
- Clear the timer in
finally.finallyruns after success and after failure. Clearing the timer there means it can't fire later. In Node.js, a timer that is still waiting also keeps the program from ending. - If the task can stop, give it an
AbortControllersignal. A signal is an object the task listens to for a "stop now" message. The previous lesson, Cancellation with AbortController, explains it in detail.
Async queues and pools
INTERACTIVEA pool runs at most N jobs at the same time. N is called the concurrency: how many jobs run at once. The other jobs wait in a queue, a line where the first to arrive is served first. Each time a job ends, the next job in the queue starts.
Say you have 100 images to upload. Starting 100 requests at once can slow down the browser, overload the server, and drain a phone's battery. A pool of four uploads them at a steady pace, four at a time.
A bank with three tellers doesn't serve everyone at once. It serves three people. When a teller is free, the bank calls the next ticket number. A task pool works the same way.
- In real life: The line of numbered tickets
- In JavaScript: The queue of jobs
- In real life: Three tellers
- In JavaScript: A concurrency of 3
- In real life: The next customer walks up
- In JavaScript: The next job starts when a worker is free
- In real life: Each receipt has your ticket number
- In JavaScript: Each result is saved at its job's place in the input
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. The results stay in input order, even when short jobs finish early.
Decide what happens when a job fails before you write the pool. There are two common choices:
- Collect the errors when the jobs don't depend on each other. For example, if one image thumbnail fails, the others can still be made.
- Fail fast, which means stop at the first error, when one failure makes the whole result useless. For example, an app can't start without its settings file.
Polling
INTERACTIVEPolling means asking the server "Is the job done?" again and again, with a pause between each question. You stop when the job is done, when it has failed, when the user cancels, or when a time limit passes.
Polling is useful when the server can't tell you on its own that the job is done. A later lesson covers WebSocket and Server-Sent Events. With those, the server sends you a message when something changes, so you don't need to ask.
You are waiting for a package, and you have no doorbell. So you walk to the mailbox every few minutes. Checking every second wastes your energy. Checking forever is even worse. A good plan checks at a calm pace and has clear reasons to stop.
- In real life: Walk to the mailbox every few minutes
- In JavaScript: Ask for the status, then pause
- In real life: Stop when the package arrives
- In JavaScript: Return when the status is
done - In real life: Stop when the delivery is cancelled
- In JavaScript: Throw when the status is
failed, or when the user cancels - In real life: A doorbell would be nicer
- In JavaScript: The server sends updates itself (a later lesson)
done, so the loop stops.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 the job's status.
This model checks the status, then sleeps, then checks again. It works like the async loop, not like setInterval, so checks can't pile up.
For network polling, use an async loop, not setInterval. setInterval calls a function every N ms, even if the last request hasn't come back yet. Then requests can pile up. An async loop awaits the request, and then awaits the sleep. So the next check starts only after the last one ended.
sleep()
CODEBrowsers have no built-in sleep function, but you can make one. A timer can resolve a promise after some time. When you await that promise, only your async function pauses. The page and the event loop keep working.
You take a nap with an alarm set, and the rest of the house keeps going. In the same way, await sleep(500) pauses one async function, and the browser keeps working.
- 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: Turn the alarm off early
- In JavaScript: An abort signal clears the timer
const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms);}); await sleep(500);console.log("half a second later");Line 1 makes a new promise. Line 2 starts a timer that resolves the promise after ms milliseconds. Line 5 waits for that promise, so line 6 prints 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 }); });}This version also takes a signal. Line 3 rejects right away if the signal has already stopped. Line 4 starts the timer. Lines 5 to 8 listen for a stop. When it comes, line 6 clears the timer and line 7 rejects with the signal's reason. { once: true } removes the listener after it runs once.
In Node.js, you can also import setTimeout from node:timers/promises and await it directly. In the browser, a small helper like this one shows the idea.
Where you'll use this
Real apps often use several patterns together. In this example, saving a draft does three things:
- It retries the save when it fails for a reason that may pass.
- It sends an idempotency key, so a repeated save doesn't make two drafts.
- It stops waiting for the reply after 2 seconds.
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);}Line 2 calls the retry helper from the retry lab. Line 3 is the work to retry: save the draft, with the draft's ID as the idempotency key. Line 4 sets the limits: at most 4 tries, a first wait of 100 ms, and never more than 1000 ms. Line 6 reads the reply's JSON body, but waits at most 2000 ms.
Pages with buttons and search boxes need two more safety rules:
- Share a request that is in flight. "In flight" means started but not finished yet. If two parts of the page ask for the same data at once, give both the same promise. Then a double click doesn't start the same work twice.
- Ignore an old reply. Give each request a number. When a reply arrives, show it only if its number is still the newest.
const inFlight = new Map();function loadUser(id, fetchUser) { if (inFlight.has(id)) return inFlight.get(id); const request = fetchUser(id).finally(() => inFlight.delete(id)); inFlight.set(id, request); return request;} let calls = 0;const fetchUser = async (id) => { calls += 1; return { id, name: "Asha" };};const first = loadUser("42", fetchUser);const second = loadUser("42", fetchUser);console.log(first === second, calls);inFlight is a Map: a list of keys, each with a value. Line 14 is the first call. The map has nothing for "42", so line 4 starts the fetch and line 5 saves its promise under that key. Line 15 is the second call. Line 3 finds the saved promise and returns it. So line 16 prints true 1: both callers got the same promise, and the fetch ran once.
The finally on line 4 removes the promise from the map when the request ends. This matters when a request fails. Without it, every later call would get the same failed promise.
let latestRequest = 0;function acceptLatest(request) { const requestId = ++latestRequest; return request().then((value) => requestId === latestRequest ? "show " + value : "ignore stale", );} const oldResult = acceptLatest(async () => "old");const newResult = acceptLatest(async () => "new");Promise.all([oldResult, newResult]).then((results) => console.log(results.join(" | ")));Search boxes often start a request for a, and then one for ab. If the reply for a comes back last, it could replace the newer results on the screen. An old reply like this is called stale.
Line 9 starts request 1, and line 10 starts request 2. Line 3 gives each one its number. When a reply arrives, line 5 compares its number with the newest number, which is 2. So the program prints ignore stale | show new.
function debounce(task, wait) { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => task(...args), wait); };} const search = debounce((query) => console.log("search", query), 10);search("a");search("ab");search("abc");setTimeout(() => console.log("quiet"), 20);Debounce waits until the calls stop for a moment. Each call to search clears the last timer (line 4) and starts a new 10 ms timer (line 5). Lines 10, 11, and 12 run quickly, one after another, so only the last timer is left. The program prints search abc, and then quiet.
Throttle is a similar tool. It allows at most one call in each time window, for example one call every 100 ms. It suits updates while the user scrolls or resizes the window. Neither tool stops a request that has already started. For that, add a timeout or an abort signal.
function makeQueue() { let tail = Promise.resolve(); return (task) => { const result = tail.then(task); tail = result.catch(() => {}); return result; };} const queue = makeQueue();const order = [];queue(async () => order.push("first"));queue(async () => order.push("second"));queue(async () => console.log(order.join(" -> ")));This queue starts each task only after the one before it ends. tail is the promise for the last task in line (line 2). Line 4 runs the new task after tail. Line 5 makes the new task the new tail. Lines 12 to 14 add three tasks at once, but they still run in order, so the program prints first -> second.
The empty catch on line 5 keeps the queue going after a task fails. The caller still sees the failure, because line 6 returns result, not tail.
Use this table to pick a pattern. Find what you need in the first column.
| What do you need? | Which pattern? | When does it stop? |
|---|---|---|
| A request failed, but the problem may pass. | Retry with backoff. | It stops after the try limit or the time limit, or at an error that won't pass. |
| The user shouldn't wait forever. | A timeout. | It stops when the timer finishes first. Then clear the timer, and stop the task if you can. |
| Many jobs that don't depend on each other. | A pool. | It stops when the queue is empty, or at the first error if you fail fast. |
| A long job on the server. | A polling loop. | It stops when the job is done or failed, when the user cancels, or when time runs out. |
| A pause between tries. | sleep(ms). | It stops when the timer ends or the signal says stop. |
Common misconceptions
- "Retry every error." Retry only errors that may pass. A 400 (the request is wrong) or a 401 (not signed in) needs a fix, not the same request again. A payment with no idempotency key could charge twice.
- "Backoff can keep going forever." Always set a longest wait, a limit on tries, and a limit on total time.
- "Promise.race stops the slower promise." It only decides the race's result. The slower task keeps running, unless it listens for a stop signal.
- "setInterval is fine for polling." It starts a new check every N ms, even when the last request is still running. An async loop waits for each check to end.
- "A pool mixes up the order of the results." Jobs can finish in any order. But if you save each result at its job's place in the input, the results stay in input order.
- "sleep freezes JavaScript." Awaiting a sleep promise pauses one async function. The rest of the page keeps running.
Let your helpers take sleep and random as inputs. Then a test can pass in fakes and check "the tries were 1, 2, and 3, and the waits were 100, 200, and 400" without waiting for real time.
Practice exercises
5 EXERCISESWork it out in your head first. What exact text does the program print?
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 adds 100, then 200, then 400. join(", ") puts a comma and a space between them, so the program prints 100, 200, 400.
Answer yes or no: does Promise.race stop the slower promise?
No. Promise.race settles like the first promise to finish. The slower promise keeps running. To really stop it, pass it an AbortSignal and make it listen.
What does the starter code print? And which line keeps 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]();It prints A -> B -> C. In a real pool, jobs can finish in any order. Saving each value at results[index] keeps the final array in the same order as the input.
Predict what this small polling counter prints.
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 three times: polls goes from 0 to 1, from 1 to 2, and from 2 to 3. Then 3 < 3 is false, so the program prints polls 3.
Write a retry plan for saving a profile form. Say what you retry, what you never retry, and when you stop.
retry(save, { maxAttempts: 4, baseDelay: 100, maxDelay: 1000, sleep, random });A safe plan retries only errors that may pass. It retries a save only when the save is idempotent or sends an idempotency key. It doubles the wait, caps it, adds jitter, and stops after a few tries or a time limit.
Check your understanding
7 QUESTIONSQuestion 1 of 7Which wait formula doubles each time and has a cap?
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 finishes first?Choose an answer to see the explanation.
Question 4 of 7How many rounds 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 poll with an async loop instead of
setInterval?Choose an answer to see the explanation.
Question 6 of 7Which request 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 ends?
Choose an answer to see the explanation.
Key takeaways
- Retry only errors that may pass, and only work that is safe to repeat. Double the wait each time, cap it, add jitter, and limit the number of tries.
Promise.racegives you a time limit, but it doesn't stop the slower task. Clear the timer, and pass a signal when the work must stop.- A pool runs at most N jobs at once. Decide what it does when a job fails.
- Poll with an async loop, so one check never overlaps the next.
sleepis a promise around a timer. Make it stoppable when it may wait a long time.
Async patterns are reusable ways to wait. They give every wait a limit, keep the load on servers fair, and make the results easy to predict.
Up next: The browser environment.