Skip to main content

πŸ› οΈ Weekend Project: Asynchronous Javascript

You've learned error handling, Promise combinators, and async generators one concept at a time. This weekend you'll wire them together into something real: a live streaming data dashboard that pulls from several sources at once, survives flaky networks, and never freezes the page. We'll build it in milestones so you always have something running.

🎯 Learning Objectives

By the end of this project, you will be able to:

  • Structure a non-trivial async app using Polya's four-step problem-solving process
  • Model live data sources (polling, WebSocket, SSE) as async generators you can for await over
  • Build resilient fetch logic with retry + exponential backoff, timeouts via AbortSignal, and custom error types
  • Coordinate concurrent streams with Promise.allSettled and cancel everything cleanly with one AbortController
  • Judge your own result against a concrete "what good looks like" checklist

Estimated Time: 4–8 hours (a weekend)  β€’  Difficulty: Advanced

Hands-on: This whole lesson is the exercise β€” a build you carry from an empty folder to a running dashboard across four milestones.

In This Lesson

The Project Brief

Your mission: build a real-time data dashboard that streams live financial-style data, processes it on the fly, and renders it into auto-updating widgets. It's the perfect capstone for this module because a dashboard is nothing but asynchronous work β€” many slow, unreliable sources feeding a UI that has to stay smooth.

πŸ“– What you're building

Inputs: a polling REST endpoint (prices), a WebSocket (news), and a Server-Sent Events stream (market indicators).

Core: a data layer that normalizes every source into one async-iterable shape, a processing pipeline, and a resilient error layer.

Output: chart, table, and metric widgets that update themselves and degrade gracefully when a source dies.

You don't need paid market-data APIs to do this. Every source can be replaced with a small mock async generator that yields random walks on a timer β€” the async plumbing is identical, and the plumbing is the point. Here's the shape every source will share:

// The one contract every data source implements.
// Consumers never care whether it's REST, WebSocket, or SSE underneath.
async function* stream() {
  while (running) {
    yield { timestamp: Date.now(), data: /* one update */ };
  }
}

βœ… Why this is a great weekend build

It is small enough to finish, but every hard part of async JavaScript shows up naturally: cancellation, backpressure, partial failure, retries, and keeping the main thread responsive while data pours in.

A Plan of Attack: Polya's Method

Before writing code, borrow a framework from mathematician George PΓ³lya's 1945 classic How to Solve It. His four steps map perfectly onto software and keep you from the classic trap of coding before you understand the problem.

flowchart TD A[PΓ³lya's Process] --> B[1 Understand the problem] A --> C[2 Devise a plan] A --> D[3 Carry out the plan] A --> E[4 Look back] B --> B1[Inputs, outputs, constraints] C --> C1[Split into milestones] D --> D1[Build one milestone at a time] E --> E1[Test, refactor, reflect]

Step 1 β€” Understand the problem

Write down the answers before you touch an editor:

  • What must it do? Show live prices, news, and indicators that update without a page refresh.
  • What are the inputs? Three sources with three different protocols and three different failure modes.
  • What are the hard constraints? The UI must stay responsive, memory must not grow forever, and one dead source must not take down the others.

⚠️ The constraint that shapes everything

"One dead source must not take down the others" is why we lean on Promise.allSettled over Promise.all, wrap each stream in its own error boundary, and give every long-running task a way to be cancelled. Decide this now, not after it breaks in front of a user.

Steps 2–4 β€” plan, build, reflect

The rest of this lesson is steps 2 through 4: we devise a plan (an architecture plus four milestones), carry it out (the code for each milestone), and look back (the checklist and "what good looks like"). Work the milestones in order β€” each one leaves you with a program you can actually run.

Architecture & Milestones

Keep the layers strictly separated. Data sources know nothing about charts; charts know nothing about WebSockets. A thin controller wires them together. That separation is what lets you swap a mock source for a real API later without touching the UI.

Dashboard architecture Three data sources feed a data layer of async generators, which flows through a processing pipeline into a controller that updates chart, table, and metric widgets. An error handler sits alongside the pipeline. Sources REST (poll) WebSocket SSE each an async generator Pipeline map / filter indicators (SMA, RSI) + error handler Controller start / stop AbortController UI Chart Table Metrics
Figure 1 β€” Data flows left to right; the only thing crossing a boundary is a plain { timestamp, data } object. Keep it that way and every layer stays swappable.

The four milestones

#MilestoneYou can run it when…
1Async utilitiesretry(), withTimeout(), and a concurrency limiter pass a few console tests.
2Sources as async generatorsA mock source for awaits and logs a new value every second.
3Processing pipelineRaw ticks flow through map/filter/indicator steps and come out transformed.
4Dashboard controllerWidgets update live and a Stop button cancels every stream at once.

Ship each milestone before starting the next. If you run out of weekend, a working dashboard with one source beats a half-written one with three.

Milestone 1 β€” Async Utilities

Every resilient app rests on a few reusable async helpers. Build these first and the rest of the project gets dramatically shorter. Put them in utils/async.js.

Retry with exponential backoff and jitter

Networks fail transiently. Instead of giving up on the first error, retry a few times with a delay that grows each attempt β€” and add a little randomness (jitter) so a fleet of clients doesn't retry in lockstep and stampede the server.

// utils/async.js

/**
 * Retry an async function with exponential backoff + jitter.
 * @param {(attempt: number) => Promise<T>} fn
 * @param {{ retries?: number, baseMs?: number, maxMs?: number,
 *           shouldRetry?: (err: unknown) => boolean }} [opts]
 */
export async function retry(fn, opts = {}) {
  const { retries = 3, baseMs = 500, maxMs = 15000, shouldRetry = () => true } = opts;

  for (let attempt = 0; ; attempt++) {
    try {
      return await fn(attempt);
    } catch (err) {
      if (attempt >= retries || !shouldRetry(err)) throw err;
      const backoff = Math.min(maxMs, baseMs * 2 ** attempt);
      const jitter = backoff * (0.5 + Math.random() * 0.5); // 50–100% of backoff
      await delay(jitter);
    }
  }
}

/** Promise-based sleep. */
export const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

Timeouts the modern way: AbortSignal.timeout

The old pattern raced a promise against a setTimeout. Modern browsers and Node give you AbortSignal.timeout(ms), which produces a signal that aborts itself β€” pass it straight to fetch and you cancel the actual request, not just stop waiting for it.

/**
 * fetch() that aborts if it takes longer than `ms`, honoring an
 * optional outer signal too (so a global Stop can cancel it).
 */
export async function fetchWithTimeout(url, { ms = 8000, signal, ...init } = {}) {
  const timeout = AbortSignal.timeout(ms);
  // Combine the caller's signal with our timeout signal.
  const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
  return fetch(url, { ...init, signal: combined });
}

πŸ“– AbortSignal.any([...])

Returns a signal that aborts as soon as any of its inputs abort. It's the clean way to say "cancel this if the request times out or the user hit Stop." Widely available since 2024; for older runtimes, fall back to a manual AbortController plus setTimeout.

Limiting concurrency

Firing 500 requests at once will get you rate-limited or will exhaust sockets. A concurrency limiter runs at most N tasks at a time and, crucially, never rejects β€” it returns a settled result per task so one failure doesn't sink the batch.

/**
 * Run async task factories with at most `limit` in flight.
 * Returns results in input order, each { status, value | reason }.
 */
export async function mapLimit(items, limit, task) {
  const results = new Array(items.length);
  let next = 0;

  async function worker() {
    while (next < items.length) {
      const i = next++;
      try {
        results[i] = { status: 'fulfilled', value: await task(items[i], i) };
      } catch (reason) {
        results[i] = { status: 'rejected', reason };
      }
    }
  }

  // Spin up `limit` workers that pull from the shared cursor.
  const size = Math.min(limit, items.length);
  await Promise.all(Array.from({ length: size }, worker));
  return results;
}

βœ… Milestone 1 done when…

You can paste these into a scratch file and prove each one in the console: retry eventually succeeds against a function that fails twice, fetchWithTimeout rejects on a deliberately slow URL, and mapLimit(urls, 3, fetchJson) returns one settled entry per URL.

Milestone 2 β€” Sources as Async Generators

The key insight of the whole project: any source of values over time can be exposed as an async generator. Once it is, consuming it is just a for await…of loop, regardless of whether the data arrived by polling, a socket, or SSE.

A polling REST source

Polling is a loop: fetch, yield, wait, repeat β€” wrapped in retry so a blip doesn't end the stream, and watching an AbortSignal so it can be stopped.

// data/sources.js
import { retry, fetchWithTimeout, delay } from '../utils/async.js';

/** Poll a JSON endpoint every `intervalMs`, yielding each response. */
export async function* pollingSource(url, { intervalMs = 5000, signal } = {}) {
  while (!signal?.aborted) {
    try {
      const data = await retry(
        () => fetchWithTimeout(url, { signal }).then((r) => {
          if (!r.ok) throw new ApiError(`HTTP ${r.status}`, { status: r.status });
          return r.json();
        }),
        { retries: 2, shouldRetry: (e) => e.retryable !== false },
      );
      yield { timestamp: Date.now(), data };
    } catch (err) {
      // Surface the error to the consumer instead of throwing away the stream.
      yield { timestamp: Date.now(), error: err };
    }
    await delay(intervalMs);
  }
}

Push sources: turning events into an async iterable

WebSocket and SSE push data at you via event listeners. To make them iterable you bridge events into a queue that the generator drains β€” resolving a pending promise when a consumer is waiting, otherwise buffering. Here's the pattern once; a WebSocket and an EventSource both use it.

/** Bridge an EventTarget's 'message' events into an async generator. */
export async function* socketSource(url, { signal } = {}) {
  const socket = new WebSocket(url);
  const queue = [];
  let notify;                     // resolver for the "next message" promise
  let closed = false;

  const push = (value) => {
    if (notify) { notify(value); notify = null; }
    else queue.push(value);
  };

  socket.addEventListener('message', (e) =>
    push({ timestamp: Date.now(), data: JSON.parse(e.data) }));
  socket.addEventListener('close', () => { closed = true; push(null); });
  socket.addEventListener('error', () => push({ timestamp: Date.now(), error: new NetworkError('socket error') }));
  signal?.addEventListener('abort', () => socket.close());

  try {
    while (!closed && !signal?.aborted) {
      const msg = queue.length ? queue.shift()
                               : await new Promise((res) => { notify = res; });
      if (msg === null) break;    // socket closed
      yield msg;
    }
  } finally {
    socket.close();               // always clean up, even on early break/throw
  }
}

⚠️ The finally block is not optional

When a consumer stops iterating early (a break, a thrown error, or .return()), the generator's finally runs. That's your one guaranteed chance to close the socket and free memory. Forget it and every "stopped" widget quietly leaks a live connection.

Testing without any real API

Prove the plumbing with a mock that random-walks a price. It has the exact same signature, so your dashboard can't tell it from the real thing.

/** A fake price feed: a random walk emitted every `intervalMs`. */
export async function* mockPriceSource({ start = 100, intervalMs = 1000, signal } = {}) {
  let price = start;
  while (!signal?.aborted) {
    price = Math.max(1, price + (Math.random() - 0.5) * 2);
    yield { timestamp: Date.now(), data: { symbol: 'AAPL', price: +price.toFixed(2) } };
    await delay(intervalMs);
  }
}

// Milestone 2 smoke test:
const ac = new AbortController();
setTimeout(() => ac.abort(), 5000);        // stop after 5s
for await (const tick of mockPriceSource({ signal: ac.signal })) {
  console.log(tick.data.price);
}

Milestone 3 β€” The Processing Pipeline

Raw ticks are rarely what a widget wants. A pipeline is itself an async generator that wraps a source and applies a sequence of transforms β€” filtering symbols, mapping shapes, computing indicators β€” yielding the finished result. Because generators are lazy, this adds essentially no overhead and no intermediate arrays.

// data/pipeline.js

/**
 * Compose a source generator with an ordered list of async transforms.
 * A transform returns a value to keep, or `undefined` to drop the item.
 */
export async function* pipeline(source, transforms) {
  for await (const item of source) {
    if (item.error) { yield item; continue; }   // pass errors straight through
    let value = item;
    for (const transform of transforms) {
      value = await transform(value);
      if (value === undefined) break;            // dropped by a filter
    }
    if (value !== undefined) yield value;
  }
}

// Reusable transforms β€” small, pure, testable.
export const keepSymbol = (sym) => (item) =>
  item.data.symbol === sym ? item : undefined;

export const pick = (...fields) => (item) => ({
  ...item,
  data: Object.fromEntries(fields.map((f) => [f, item.data[f]])),
});

A stateful transform: simple moving average

Indicators need memory of past values. A closure that keeps a sliding window is the cleanest way to add state to an otherwise stateless pipeline.

/** Attach a simple moving average of `data[field]` over the last `period` ticks. */
export function movingAverage(field, period) {
  const window = [];
  return (item) => {
    window.push(item.data[field]);
    if (window.length > period) window.shift();
    const sma = window.length === period
      ? window.reduce((a, b) => a + b, 0) / period
      : null;                      // not enough data yet
    return { ...item, data: { ...item.data, sma } };
  };
}

// Compose it all:
const stream = pipeline(mockPriceSource({ signal }), [
  keepSymbol('AAPL'),
  movingAverage('price', 20),
]);
for await (const tick of stream) {
  console.log(tick.data.price, 'β†’ SMA:', tick.data.sma);
}

πŸ’‘ Why generators instead of arrays here

A streaming source is conceptually infinite β€” you can't .map() it into an array because it never ends. Async generators let you express map/filter/reduce style logic over an endless sequence, processing one item at a time with bounded memory. That's the exact tool this project is built to practice.

Milestone 4 β€” Wiring the Dashboard

The controller is deliberately thin. It owns a single AbortController, starts one consumer loop per widget, and β€” the part that ties the whole module together β€” stops everything at once.

// DashboardController.js
export class DashboardController {
  #ac = new AbortController();
  #tasks = [];

  constructor(widgets) {
    this.widgets = widgets;       // [{ id, source, transforms, render }]
  }

  start() {
    const { signal } = this.#ac;
    // Each widget consumes its own stream; failures are isolated per widget.
    this.#tasks = this.widgets.map((w) => this.#run(w, signal));
  }

  async #run(widget, signal) {
    const stream = pipeline(widget.source(signal), widget.transforms);
    try {
      for await (const item of stream) {
        if (signal.aborted) break;
        if (item.error) { widget.render({ status: 'error', error: item.error }); continue; }
        widget.render({ status: 'live', data: item.data });
      }
    } catch (err) {
      widget.render({ status: 'error', error: err });
    }
  }

  async stop() {
    this.#ac.abort();             // signals every source + fetch to cancel
    await Promise.allSettled(this.#tasks); // wait for loops to unwind cleanly
    this.#ac = new AbortController();       // fresh controller for next start()
  }
}

Notice how stop() is a single line of real work: abort the shared signal and every polling loop, socket, and in-flight fetch tears itself down through the finally blocks you wrote in Milestone 2. That is the payoff of threading one AbortSignal through the whole system.

πŸ’‘ Starting the whole thing

Wire it to two buttons and you have a real app: startBtn.onclick = () => controller.start() and stopBtn.onclick = () => controller.stop(). Each render callback is where you push a point into a chart, prepend a table row, or update a metric β€” capped in length so memory stays flat.

Handling partial failure well

Because each widget runs its own isolated loop, a WebSocket that dies only turns that widget's status badge red. The prices keep ticking. That is the concrete, visible result of the "one dead source must not take down the others" decision you made back in Step 1.

Checklist & What Good Looks Like

🏁 Build checklist

  • ☐ M1: retry, fetchWithTimeout, and mapLimit each verified in the console.
  • ☐ M2: at least one source is an async generator you can for await; a mock source lets you develop offline.
  • ☐ M2: every generator has a finally that closes its connection.
  • ☐ M3: a pipeline filters, reshapes, and computes at least one indicator (SMA).
  • ☐ M4: a single Stop button cancels every stream and in-flight request.
  • ☐ Resilience: killing one source leaves the others running.
  • ☐ Memory: widgets cap their retained data; nothing grows unbounded.

What good looks like

DimensionJust workingGood
CancellationStop reloads the pageOne AbortController; loops unwind through finally
Errorsconsole.log(err) and hopeTyped errors, per-widget status, retryable vs. fatal distinction
ConcurrencyPromise.all (one failure kills all)Promise.allSettled / mapLimit; isolated failures
Data flowCallbacks nested in callbacksComposable async generators with for await
MemoryArrays grow foreverSliding windows; capped widget buffers
πŸš€ Stretch goals if you finish early
  • Batch UI updates inside requestAnimationFrame so a burst of ticks causes one repaint, not fifty.
  • Add a reconnect-with-backoff wrapper around the WebSocket source using your Milestone 1 retry.
  • Cache the last known value per source so a widget shows stale-but-labeled data during an outage instead of blanking out.
  • Persist which widgets are visible to localStorage and restore them on load.

🎯 Quick Quiz

Question 1: Why does the controller thread a single AbortController through every source and fetch?

Question 2: A widget's data source throws while the dashboard runs. Which choice keeps the other widgets alive?

Question 3: Why model a live data source as an async generator rather than collecting results into an array?

Summary & Quiz

πŸŽ‰ Key Takeaways

  • Plan before you code. PΓ³lya's four steps turn a vague "build a dashboard" into concrete milestones you can ship one at a time.
  • One contract, many sources. Expose polling, WebSocket, and SSE as async generators yielding { timestamp, data } and the rest of the app stops caring about protocols.
  • Resilience is a design decision. Retry with backoff, timeouts via AbortSignal, typed errors, and Promise.allSettled keep one failure from cascading.
  • Cancellation ties it together. A single AbortController plus finally cleanup gives you a Stop button that actually stops everything.

πŸ“š Further Reading

πŸš€ What's Next?

You've closed out asynchronous JavaScript by building something real. Next, Module 12 turns to the frontend framework that will consume APIs like the one behind this dashboard: we start with a tour of the React library and how it re-thinks building user interfaces.

πŸŽ‰ Great work!

Ship your dashboard, run the checklist against it, then take that momentum into React.