π οΈ 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 awaitover - Build resilient fetch logic with retry + exponential backoff, timeouts via
AbortSignal, and custom error types - Coordinate concurrent streams with
Promise.allSettledand cancel everything cleanly with oneAbortController - 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.
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.
{ timestamp, data } object. Keep it that way and every layer stays swappable.The four milestones
| # | Milestone | You can run it when⦠|
|---|---|---|
| 1 | Async utilities | retry(), withTimeout(), and a concurrency limiter pass a few console tests. |
| 2 | Sources as async generators | A mock source for awaits and logs a new value every second. |
| 3 | Processing pipeline | Raw ticks flow through map/filter/indicator steps and come out transformed. |
| 4 | Dashboard controller | Widgets 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, andmapLimiteach 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
finallythat 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
| Dimension | Just working | Good |
|---|---|---|
| Cancellation | Stop reloads the page | One AbortController; loops unwind through finally |
| Errors | console.log(err) and hope | Typed errors, per-widget status, retryable vs. fatal distinction |
| Concurrency | Promise.all (one failure kills all) | Promise.allSettled / mapLimit; isolated failures |
| Data flow | Callbacks nested in callbacks | Composable async generators with for await |
| Memory | Arrays grow forever | Sliding windows; capped widget buffers |
π Stretch goals if you finish early
- Batch UI updates inside
requestAnimationFrameso 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
localStorageand 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, andPromise.allSettledkeep one failure from cascading. - Cancellation ties it together. A single
AbortControllerplusfinallycleanup gives you a Stop button that actually stops everything.
π Further Reading
- MDN β
for await...of - MDN β AbortController & AbortSignal
- MDN β Server-Sent Events
- PΓ³lya β How to Solve It
π 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.