The Async Machine

Advanced · 90 min build · 🏆 milestone project

You are going to build an async machine: a console dashboard that starts several slow data sources, combines their results, renders loading/ready/error states, and cancels stale work when a newer refresh starts.

The live workspace uses simulated sources with fixed delays so every run is deterministic. The control flow is the point: timers stand in for network waiting, promises carry future results, async / await keeps the code readable, combinators collect the work, classes hold the machine together, and cancellation keeps old work from winning.

What you'll build

You will build one async dashboard workspace with five jobs:

  • model dashboard state as plain objects
  • load from multiple simulated async sources with distinct delays
  • combine results with Promise.allSettled
  • cancel stale refreshes with AbortController
  • render clear state transitions from a class

A state transition is one move from an old state to a new state, such as idleloadingready. A stale request is old async work that finishes after a newer request has already started. Your job is to make the machine predictable even when work completes later.

javascript — live consolereal engine
⌘/Ctrl + Enter to run

Checkpoint 1: model the machine state

Start with the state object. It should be the one place that describes what the dashboard currently knows.

Acceptance criteria:

  • State includes requestId, status, rows, and errors.
  • status uses clear strings such as idle, loading, ready, partial, and cancelled.
  • setState creates a new object with spread instead of mutating the old state in place.
  • Each refresh increments requestId.
  • render() reads from state only; it does not start async work.

Checkpoint 2: build simulated async sources

Each source should feel like a tiny API call. It starts now, finishes later, and either fulfills with a value or rejects with an error.

Acceptance criteria:

  • Every source returns a promise.
  • Source delays are distinct, such as 120, 240, and 360 milliseconds.
  • At least one source can be edited to reject with new Error("message").
  • No source depends on Math.random(), current time, or equal-delay races.
  • The starter run has no uncaught errors.

Checkpoint 3: combine the results

Use Promise.allSettled so one broken source does not erase the entire dashboard. Your dashboard should still show the data that arrived.

Acceptance criteria:

  • Fulfilled results become display rows.
  • Rejected results become readable error messages.
  • If every source fulfills, status becomes ready.
  • If at least one source rejects but at least one fulfills, status becomes partial.
  • Rows are created with map / filter or a small readable loop, not hidden side effects.

Checkpoint 4: cancel stale work

Cancellation is an async contract: when newer work starts, older work should stop or be ignored. The starter already creates an AbortController; make that behavior visible.

Acceptance criteria:

  • Starting a second refresh aborts the first refresh.
  • The aborted refresh does not overwrite newer state.
  • A cancelled source rejects with a clear Cancelled ... message.
  • The UI state never goes from a newer request back to an older request.
  • Any timer created for a cancelled source is cleared.

Checkpoint 5: turn the scaffold into a dashboard core

Now make it feel like a small app engine, not just a promise demo.

Acceptance criteria:

  • AsyncMachine owns the state and the refresh behavior.
  • Source definitions stay outside the class so you can swap them.
  • Rendering shows loading, success rows, and errors clearly.
  • State updates are immutable enough that old snapshots can be logged for debugging.
  • The final version runs twice in a row without leftover timers or stale output.

Real app integration shape

The live project uses simulated sources. In a real browser app, a source can keep the same load(signal) shape and call fetch with that signal:

function makeFetchSource(source, url) {
  return {
    source,
    async load(signal) {
      const response = await fetch(url, { signal });
 
      if (!response.ok) {
        throw new Error(`${source} failed with ${response.status}`);
      }
 
      return {
        source,
        payload: await response.json(),
      };
    },
  };
}

That is the bridge: your project logic can depend on source.load(signal) without caring whether the source waits on a timer today or a network request in a real app.

Stretch goals

Choose one or two after the base machine works:

  • Add a fourth source called schedule with a 480 millisecond delay.
  • Add a self-clearing progress interval that prints three ticks, then stops.
  • Add a refreshTwice() demo that starts one refresh and then starts another after 80 milliseconds.
  • Add a toDashboardLine(row) pure function and test it with two row objects.
  • Use Intl.DateTimeFormat with a fixed time zone to display a deterministic updatedAt value.
  • Add a static makeFetchSource next to the simulated source factory in your own notes.

How to get unstuck

If output order surprises you, revisit the event loop. If callbacks and inversion of control feel fuzzy, return to Callbacks & the Pyramid of Doom. If promise states or allSettled results get tangled, review Promises and Promise Combinators. If await accidentally makes independent work run one at a time, compare your code to async / await. If cancellation or timers misbehave, use Timers, Cancellation & Async Patterns. If the class starts growing too much, revisit Classes & Instances, Inheritance vs Composition, and Functional Patterns & Immutability.

You have finished Part III when this machine can refresh, combine, cancel, and render without mystery. Part IV moves the same state and async habits onto the page.

+50 XP on completion