Async Dashboard

Mental model: Loading, error, empty, aborted and stale are states you design. Concurrency is a scheduling decision you make on purpose, not a side effect of where you put the awaits.

Level: advanced · about 30 minutes

Four widgets, one flaky API. This is the lab that turns "I know Promise.all" into "I know what my page does when the third endpoint is down". You will finish with a request layer you can lift into a real app: concurrent by default, bounded, retried with backoff, cancellable, and immune to a response that arrives after you stopped caring.

The lab has no network. fakeApi() is a promise around a setTimeout with a failure dial, so it runs offline and the timing is yours to control. You pick a strategy (sequential, all, allSettled, race), a base latency, a failure rate and a retry cap, then watch the request timeline draw itself. Sequential is a staircase. Concurrent is four bars starting together. That shape is the lesson.

The build, decision by decision

  1. Enumerate the states before the happy path The lab names eight, each with its own words on screen. Write this list first and the rest of the code has somewhere to put every outcome.
  2. Decide the shape of the concurrency Sequential awaits cost the sum of every request. Independent jobs started together cost the slowest one. Only chain awaits when the second call genuinely needs the first answer.
  3. Choose what a failure is allowed to take down Promise.all rejects the moment one job rejects, so a render gated on it shows nothing even though three answers arrived. allSettled never rejects and hands you a status per job, which is what a dashboard wants.
  4. Give every promise a handler With allSettled this is free. With race or a manual pick it is not: a rejection nobody observed is an unhandled rejection, which crashes Node and fills the console in a browser.
  5. Cap the concurrency when the list is long Four widgets can all go at once. Four hundred cannot: browsers queue after roughly six connections per host, and your API has opinions too. A pool keeps N in flight and starts the next job as one finishes.
  6. Retry with backoff, jitter and a cap A retry loop without a cap is a denial of service you wrote yourself. Each attempt waits about twice as long as the last, shifted randomly so a thousand clients do not all return in the same millisecond.
  7. Make cancellation real, not cosmetic One controller per generation of requests. The abort listener has to clear the timer that stands in for the wire, otherwise abort only flips a flag while the work keeps running.
  8. Guard the generation, not just the request Press Reload twice quickly and the first run may still land. Every state update carries the run id it belongs to, and a patch from an old run is dropped. Abort plus a generation guard is belt and braces, and you want both.
  9. Design the skeleton, and let text carry the state A skeleton is a promise about layout: same shape, no number yet. Colour alone is never the signal, so each card also says "loading, attempt 2 of 3" in words a screen reader will read out.

The core mechanism

const fake = (label, ms, fail = false) =>
  new Promise((resolve, reject) => {
    setTimeout(() => (fail ? reject(new Error('GET /api/' + label + ' failed: 503')) : resolve(label)), ms);
  });

const jobs = [fake('revenue', 30), fake('signups', 10, true), fake('errors', 20)];

// Every promise needs a handler, even the ones you are about to ignore.
for (const job of jobs) job.catch(() => {});

const settled = await Promise.allSettled(jobs);
console.log('allSettled ->', settled.map((r) => r.status).join(' '));
// -> allSettled -> fulfilled rejected fulfilled

console.log('values we can render ->', settled.filter((r) => r.status === 'fulfilled').map((r) => r.value));
// -> [ 'revenue', 'errors' ]

try {
  await Promise.all(jobs);
} catch (err) {
  console.log('all -> rejected with:', err.message);
}
// -> all -> rejected with: GET /api/signups failed: 503

One flaky job in three. Watch what each combinator does with it.

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function pool(tasks, limit) {
  const results = new Array(tasks.length);
  let next = 0;
  let live = 0;
  let peak = 0;

  const worker = async () => {
    while (next < tasks.length) {
      const i = next++;
      live += 1;
      peak = Math.max(peak, live);
      results[i] = await tasks[i]();
      live -= 1;
    }
  };

  const size = Math.min(limit, tasks.length);
  await Promise.all(Array.from({ length: size }, worker));
  return { results, peak };
}

// Deliberately out of order: the first job is the slowest.
const durations = [40, 10, 30, 20, 5, 25];
const tasks = durations.map((ms, i) => async () => {
  await sleep(ms);
  return 'job' + i;
});

const { results, peak } = await pool(tasks, 2);
console.log(results.join(' '));            // -> job0 job1 job2 job3 job4 job5
console.log('peak in flight ->', peak);    // -> 2

The concurrency pool: six jobs, two at a time, results still in order.

Interactive visualiser: waterfall. Enable JavaScript to use it.

const slow = new Promise((resolve) => setTimeout(() => {
  console.log('slow finished anyway');
  resolve('slow');
}, 30));
const fast = new Promise((resolve) => setTimeout(() => resolve('fast'), 5));

console.log(await Promise.race([fast, slow]));

race tells you who won. It does not cancel the losers, because a promise is not a handle on work, it is a notification about work. The slow timer keeps running and its callback still fires. If you want the loser to stop, you have to abort it yourself, which is exactly what the Cancel button in the lab does.

CombinatorSettles whenRejects whenUse it for
Promise.allevery job fulfilsthe first rejectionwork that is worthless in part
Promise.allSettledevery job settlesneverdashboards, independent widgets
Promise.racethe first job settlesif that first one rejectedtimeouts, first-responder patterns
Promise.anythe first fulfilmentonly if all reject (AggregateError)mirrors, fallback endpoints

Extend it

  1. Add a per-request timeout with AbortSignal.timeout(5000) and show it as a distinct state from a server error.
  2. Cache the last good value per widget and show it, dimmed, with a "stale" chip while a refresh is in flight.
  3. Swap the four fixed widgets for a list of twenty and run them through the concurrency pool with a cap of three.
  4. Add a circuit breaker: after three consecutive failures for one endpoint, stop retrying for thirty seconds.
  5. Deduplicate in-flight requests, so two components asking for the same endpoint share one promise.

Try it yourself

Measure the difference

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const load = async (label, ms) => {
  await sleep(ms);
  return label;
};

const t0 = Date.now();
const a = await load('revenue', 60);
const b = await load('signups', 60);
const c = await load('errors', 60);
console.log('sequential', [a, b, c].join(','), Date.now() - t0 + 'ms (about 180)');

const t1 = Date.now();
const all = await Promise.all([load('revenue', 60), load('signups', 60), load('errors', 60)]);
console.log('concurrent', all.join(','), Date.now() - t1 + 'ms (about 60)');

Make the third call depend on the first result. Which of the two shapes is still correct?

Abort has to clear something

function request(label, ms, signal) {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) return reject(new Error('AbortError'));
    const timer = setTimeout(() => {
      console.log(label + ' did its work');
      resolve(label);
    }, ms);
    signal?.addEventListener('abort', () => {
      clearTimeout(timer);              // <- the cancellation
      reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
    }, { once: true });
  });
}

const controller = new AbortController();
const job = request('slow-job', 40, controller.signal);
job.catch((err) => console.log('rejected with', err.name));

setTimeout(() => controller.abort(), 10);
await new Promise((resolve) => setTimeout(resolve, 80));
console.log('done: notice slow-job never printed');

Comment out the clearTimeout line. The promise still rejects, but the work keeps running. That is the bug people ship.

Exercises

A promise pool with a concurrency cap

Write async promisePool(tasks, limit). tasks is an array of functions that each return a promise. Run them with at most limit in flight at any moment, start them in order, and resolve with an array of results in the same order as tasks. An empty task list resolves with []. If a task rejects, the returned promise rejects.

Retry with backoff and a cap

Write async retry(fn, options) where options are { attempts = 3, baseMs = 100, sleep }. Call fn(attemptNumber) starting at 1. Return the first value it resolves with. Before attempt n (n greater than 1) await sleep(baseMs * 2 ** (n - 2)), so the waits are baseMs, 2 * baseMs, 4 * baseMs. Give up after attempts and throw the error from the last attempt. If an error has the name AbortError, rethrow it at once and do not retry.

Check yourself

What does this log?
false — Both timers started when the promises were created, so they run at the same time. The two awaits just collect the answers, and the whole thing takes about 50 ms, not 100. Awaits do not serialise work, creating promises one after another does.
A dashboard renders only after await Promise.all(jobs). The third of four endpoints returns a 503. What does the user see?
Nothing, because the aggregate promise rejected before any render ran — all rejects on the first rejection, so the code after it never runs and the three good answers are dropped on the floor. The other requests do finish, which makes it worse: you paid for the data and threw it away. allSettled renders per widget instead.
You abort a request and the timer that simulates it is never cleared. What actually happens?
The promise rejects with an AbortError, but the work still runs and may write state after you gave up — A promise settles once, so the rejection wins and your catch runs. The callback inside the timer is unaffected: it fires later and can happily update state for a screen nobody is looking at. Cancellation means stopping the work, and the abort listener is where you do it.
Why does each retry wait a random amount around the doubling delay?
Jitter spreads the retries out, so many clients recovering from the same outage do not all return in the same instant — Without jitter, every client that failed at the same moment retries at the same moment, and your server gets a synchronised stampede exactly when it is weakest. A random factor of about plus or minus a quarter is enough to smear the load out.

Common mistakes

  • Awaiting inside a loop for jobs that are independent, and paying the sum instead of the max.
  • Gating the whole render on Promise.all and blanking a page that had most of its data.
  • Retrying an AbortError, which restarts work the user explicitly cancelled.
  • Retrying without a cap or without backoff, which turns a wobble into an outage.
  • Aborting without clearing the underlying timer or request, so cancellation is only cosmetic.
  • Forgetting that race leaves the losers running, and paying for answers you throw away.
  • Letting a response from an older run write into the current state because nothing checks the generation.

Takeaways

  • Promises start when they are created, so concurrency is decided by where you create them.
  • all is for work that is worthless in part, allSettled is for independent widgets.
  • Every promise needs a handler, even one you intend to ignore.
  • A pool of workers beats batching: nothing waits for the slowest job in its batch.
  • Retry needs three things: a cap, exponential backoff, and jitter.
  • Cancellation must clear the underlying work, and a generation guard catches whatever is already in flight.