completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

Async patterns

Learn the practical async patterns that keep apps calm: retry with backoff, timeout races, task pools, polling loops, and sleep helpers.

Patterns you’ll be able to build
  • 01
    Retry 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.
  • 02
    Control waitingRace a task against a timer, and explain why the race alone doesn't stop the slower task.
  • 03
    Limit 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.

The short version

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.

Real-life analogyAsync patterns are road rules

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

INTERACTIVE

A 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.

Three attempts in a row. Attempt 1 fails with a 503 error, then the code waits 100 ms. Attempt 2 fails with a 503 error, then the code waits 200 ms. Attempt 3 works.attempt 1503 errorwait 100 msattempt 2503 errorwait 200 msattempt 3success
The retry lab with jitter off: after each failed try, the wait doubles, 100 ms and then 200 ms. The third try works, so the retries stop.

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.

Real-life analogyRetry with backoff is calling a busy friend

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") or 429 ("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.

Retry lab: a server that fails at first
Retry helperPop out in the code editor (opens in a new tab)JavaScript
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");}
Retry settings0 events
  1. Press Run to call the pretend server.
Try it yourself

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 waits are kept short in the browser. Reset clears any timers that haven't fired yet.

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.

Retry or not?
  • 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 POST with no idempotency key
Try it yourself
0 of 7 correct

Sort each failure. Ask two questions: will the error probably pass, and is the same request safe to send again?

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Timeouts with race

STEP THROUGH

A 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.

Two promises race into a box called race. The task needs 180 ms and keeps running. The timer rejects at 120 ms, so the race box holds the error Timed out.task: 180 mskeeps runningtimer: 120 msrejects firstTimed outracethe timer wins
The timer finishes at 120 ms, before the 180 ms task, so the race rejects with Timed out. The task is not stopped. It keeps running and finishes later.
Real-life analogyA timeout race is a kitchen timer racing your cake

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.race settles like the first promise to finish
In real life: The cake keeps baking
In JavaScript: The race doesn't stop the slower task
A time limit with Promise.race
Step 0 of 7Ready
Your turn: follow the blue line

Step through a time limit made with Promise.race. Change the setting to let the task or the timer finish first.

Running in
  1. script
Next: line 1
  let timer;  const timeout = new Promise((_, reject) => {    timer = setTimeout(() => reject(new Error("Timed out")), ms);  });  return Promise.race([promise, timeout])    .finally(() => clearTimeout(timer));}
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
Change the race

Plain race only picks the first to finish. The AbortSignal version also tells the task to stop.

A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

Two details matter:

  • Clear the timer in finally. finally runs 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 AbortController signal. 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

INTERACTIVE

A 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.

Jobs 4 to 8 wait in a line. Three boxes, worker 1, worker 2, and worker 3, hold job 1, job 2, and job 3. Job 2 ends first, at 120 ms, so an arrow takes job 4 to worker 2.waiting in linejob 4job 5job 6job 7job 8job 4 is nextjob 1worker 1ends at 320 msjob 2worker 2ends at 120 msjob 3worker 3ends at 240 ms
With 3 workers, jobs 1, 2, and 3 start at once and jobs 4 to 8 wait. Job 2 ends first, at 120 ms, so worker 2 takes job 4 next.
Real-life analogyA task pool is a bank with three tellers

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
Task pool: 8 jobs, a few workers
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;}
WorkersTotal 560 ms
Worker 1
  1. Job 1: 0–320 ms
  2. Job 7: 320–460 ms
Worker 2
  1. Job 2: 0–120 ms
  2. Job 4: 120–300 ms
  3. Job 6: 300–560 ms
Worker 3
  1. Job 3: 0–240 ms
  2. Job 5: 240–320 ms
  3. Job 8: 320–540 ms

Results: job 1, job 2, job 3, job 4, job 5, job 6, job 7, job 8

Try it yourself

At most 3 jobs run at once. The results stay in input order, even when short jobs finish early.

The times come from each job's length. No real timers run here.

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

INTERACTIVE

Polling 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.

Real-life analogyPolling is checking the mailbox

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)
Left: a status checks window lists poll 1 queued, poll 2 working, poll 3 working, and poll 4 done. Right: a loop. await checkStatus() leads down to await sleep(interval), and an arrow leads back up to the check.status checkspoll 1: queuedpoll 2: workingpoll 3: workingpoll 4: doneawait checkStatus()not doneawait sleep(interval)then check again
The loop checks the status, then sleeps 150 ms, then checks again. One check never starts before the last one ends. Poll 4 says done, so the loop stops.
Polling loop: check, then sleep
Polling loopPop out in the code editor (opens in a new tab)JavaScript
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");}
Statusidle
  1. Press Start to check the job's status.
Try it yourself

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.

Real apps also stop when the job fails, when the user cancels, or when a time limit passes. Many also pause polling while the tab is hidden, using document.visibilityState.

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()

CODE

Browsers 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.

Real-life analogysleep() is a nap alarm

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
A small sleep helperPop out in the code editor (opens in a new tab)JavaScript
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.

A sleep you can stopPop out in the code editor (opens in a new tab)JavaScript
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.
Retry a save, then read the reply with a time limitPop out in the code editor (opens in a new tab)JavaScript
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.
Share one in-flight requestPop out in the code editor (opens in a new tab)JavaScript
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.

Ignore an older replyPop out in the code editor (opens in a new tab)JavaScript
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.

Wait for a pause in typingPop out in the code editor (opens in a new tab)JavaScript
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.

Run tasks one at a timePop out in the code editor (opens in a new tab)JavaScript
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.

Which pattern to use
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.
Testing tip

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 EXERCISES
Exercise 1 · Warm-upPredict the backoff waits

Work it out in your head first. What exact text does the program print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
const waits = [];
for (let attempt = 0; attempt < 3; attempt += 1) {
  waits.push(100 * 2 ** attempt);
}
console.log(waits.join(", "));

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeExplain the race

    Answer yes or no: does Promise.race stop the slower promise?

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeKeep pool results in order

      What does the starter code print? And which line keeps this order in an async pool?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      const finished = [];
      const jobs = ["A", "B", "C"];
      for (const name of jobs) {
        finished.push(name);
      }
      console.log(finished.join(" -> "));

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeCount the polling loop

        Predict what this small polling counter prints.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        let polls = 0;
        while (polls < 3) {
          polls += 1;
        }
        console.log("polls", polls);

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengePlan a safe retry

          Write a retry plan for saving a profile form. Say what you retry, what you never retry, and when you stop.

            Check your understanding

            7 QUESTIONS
            Async patterns quiz · 7 questionsScore: first tries count
            1. Question 1 of 7Which wait formula doubles each time and has a cap?

              Choose an answer to see the explanation.

            2. Question 2 of 7What does this print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const 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.

            3. Question 3 of 7What does Promise.race([task, timeout]) do when the timeout finishes first?

              Choose an answer to see the explanation.

            4. Question 4 of 7How many rounds does this pool need?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const concurrency = 3;
              const jobs = 8;
              console.log(Math.ceil(jobs / concurrency));

              Choose an answer to see the explanation.

            5. Question 5 of 7Why poll with an async loop instead of setInterval?

              Choose an answer to see the explanation.

            6. Question 6 of 7Which request is safest to retry?

              Choose an answer to see the explanation.

            7. 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.race gives 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.
            • sleep is 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.

            CompleteFrontend Clear concepts. Working examples.