async-patterns.md 22 KB

Async Patterns Reference

Comprehensive patterns for asynchronous JavaScript — Promises, async/await, iterators, streams, workers, and cancellation.


Promise Fundamentals

Constructor

// Only use the constructor to wrap callback-based APIs
function readFileAsync(path) {
  return new Promise((resolve, reject) => {
    fs.readFile(path, 'utf8', (err, data) => {
      if (err) reject(err);
      else resolve(data);
    });
  });
}

// Anti-pattern: Promise constructor around another Promise
// BAD — "deferred anti-pattern"
const p = new Promise((resolve) => {
  fetch('/api/data').then(resolve); // don't do this
});

// GOOD — just return the Promise directly
const p = fetch('/api/data');

then / catch / finally

fetch('/api/users')
  .then(res => {
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json(); // return value becomes next then's input
  })
  .then(users => console.log(users))
  .catch(err => {
    // catches ALL errors from any then() above
    console.error('Failed:', err);
    return []; // recover — return value continues the chain
  })
  .finally(() => {
    // always runs, receives no argument, does NOT change resolved value
    hideLoadingSpinner();
  });

Chaining vs Nesting

// BAD — nested (pyramid of doom with Promises)
fetch('/api/user')
  .then(res => res.json().then(user => {
    fetch(`/api/posts/${user.id}`).then(res => res.json().then(posts => {
      console.log(posts);
    }));
  }));

// GOOD — flat chain
fetch('/api/user')
  .then(res => res.json())
  .then(user => fetch(`/api/posts/${user.id}`))
  .then(res => res.json())
  .then(posts => console.log(posts));

// BEST — async/await
const res = await fetch('/api/user');
const user = await res.json();
const postsRes = await fetch(`/api/posts/${user.id}`);
const posts = await postsRes.json();

Promise Combinators

Promise.all — All must succeed

// Runs in parallel, fails fast on first rejection
const [user, posts, comments] = await Promise.all([
  fetchUser(userId),
  fetchPosts(userId),
  fetchComments(userId),
]);

// With error handling — if any rejects, the whole thing rejects
try {
  const results = await Promise.all([a(), b(), c()]);
} catch (err) {
  // err is from whichever promise rejected first
  // The other promises are NOT cancelled (still running!)
}

// Use case: multiple independent API calls that all must succeed
async function loadDashboard(userId) {
  const [profile, analytics, notifications] = await Promise.all([
    api.getProfile(userId),
    api.getAnalytics(userId),
    api.getNotifications(userId),
  ]);
  return { profile, analytics, notifications };
}

Promise.allSettled — Handle each individually

// Never rejects — always resolves with array of outcome objects
const results = await Promise.allSettled([
  fetch('/api/primary'),
  fetch('/api/secondary'),
  fetch('/api/tertiary'),
]);

for (const result of results) {
  if (result.status === 'fulfilled') {
    console.log('Success:', result.value);
  } else {
    console.warn('Failed:', result.reason);
  }
}

// Use case: batch operations where partial success is acceptable
async function syncAllRecords(records) {
  const results = await Promise.allSettled(
    records.map(r => syncRecord(r))
  );
  const failures = results.filter(r => r.status === 'rejected');
  if (failures.length > 0) {
    console.warn(`${failures.length} of ${records.length} records failed to sync`);
  }
  return results;
}

Promise.race — First settled wins

// Resolves or rejects with the FIRST settled promise (fulfilled OR rejected)
const result = await Promise.race([
  fetch('/api/data'),
  new Promise((_, reject) =>
    setTimeout(() => reject(new Error('Timeout')), 5000)
  ),
]);

// Use case: implement timeout (prefer AbortSignal.timeout() in modern code)
function withTimeout(promise, ms) {
  const timeout = new Promise((_, reject) =>
    setTimeout(() => reject(new Error(`Operation timed out after ${ms}ms`)), ms)
  );
  return Promise.race([promise, timeout]);
}

// Use case: racing multiple endpoints (first one wins)
const data = await Promise.race([
  fetchFromPrimary(),
  fetchFromFallback(),
]);

Promise.any — First success wins

// Resolves with FIRST fulfilled promise; rejects (AggregateError) only if ALL reject
try {
  const data = await Promise.any([
    fetchFromCDN1(),
    fetchFromCDN2(),
    fetchFromOrigin(),
  ]);
  console.log('Got data from fastest source:', data);
} catch (err) {
  // err is AggregateError — all failed
  console.error('All sources failed:', err.errors);
}

// Use case: redundant data sources with automatic fallback
async function fetchWithFallback(urls) {
  return Promise.any(urls.map(url => fetch(url).then(r => r.json())));
}

async/await Patterns

Sequential vs Parallel

// SEQUENTIAL — each waits for the previous (slow if independent)
const user = await getUser(id);
const posts = await getPosts(id);     // waits for getUser to finish first
const friends = await getFriends(id); // waits for getPosts to finish first

// PARALLEL — all start together (fast)
const [user, posts, friends] = await Promise.all([
  getUser(id),
  getPosts(id),
  getFriends(id),
]);

// MIXED — some sequential dependencies, some parallel
const user = await getUser(id); // must come first
const [posts, preferences] = await Promise.all([
  getPosts(user.blogId),         // depends on user
  getPreferences(user.settingsId), // depends on user
]);

Error Handling

// 1. try/catch — most readable
async function loadUser(id) {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`, { cause: res });
    return await res.json();
  } catch (err) {
    if (err.name === 'AbortError') return null; // cancelled — not an error
    throw err; // re-throw unexpected errors
  }
}

// 2. Inline error handling for parallel calls
const [userResult, postsResult] = await Promise.allSettled([
  getUser(id),
  getPosts(id),
]);
const user = userResult.status === 'fulfilled' ? userResult.value : null;

// 3. Utility: "safe" wrapper that returns [error, result]
async function safe(promise) {
  try {
    return [null, await promise];
  } catch (err) {
    return [err, null];
  }
}

const [err, user] = await safe(getUser(id));
if (err) { /* handle */ }

Top-Level Await (ESM only)

// Works at the top level of an ES module — no async wrapper needed
// file: config.mjs
const config = await fetch('/api/config').then(r => r.json());

export const DB_URL = config.dbUrl;
export const API_KEY = config.apiKey;

// Importers of this module will wait for the top-level awaits to settle
// import { DB_URL } from './config.mjs'; // already resolved by the time you use it

Promise.withResolvers — Deferred Pattern

// ES2024 — cleaner deferred pattern
const { promise, resolve, reject } = Promise.withResolvers();

// Classic use case: expose resolve/reject to external code
class EventQueue {
  #waiters = [];

  async waitForNext() {
    const { promise, resolve } = Promise.withResolvers();
    this.#waiters.push(resolve);
    return promise;
  }

  emit(value) {
    const resolve = this.#waiters.shift();
    resolve?.(value);
  }
}

// Use case: WebSocket message waiting
class WebSocketClient {
  #pending = new Map();
  #counter = 0;

  send(data) {
    const id = ++this.#counter;
    const { promise, resolve, reject } = Promise.withResolvers();
    this.#pending.set(id, { resolve, reject });
    this.#ws.send(JSON.stringify({ id, data }));
    return promise;
  }

  #onMessage(event) {
    const { id, result, error } = JSON.parse(event.data);
    const { resolve, reject } = this.#pending.get(id) ?? {};
    this.#pending.delete(id);
    if (error) reject(new Error(error));
    else resolve(result);
  }
}

// Before ES2024 — manual deferred
function deferred() {
  let resolve, reject;
  const promise = new Promise((res, rej) => {
    resolve = res;
    reject = rej;
  });
  return { promise, resolve, reject };
}

Async Iterators

for await...of

// Works with any object implementing Symbol.asyncIterator
async function processLargeFile(filePath) {
  const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
  for await (const chunk of stream) {
    await processChunk(chunk);
  }
}

// Paginated API — async iterator wraps pagination logic
async function* paginate(url) {
  let nextUrl = url;
  while (nextUrl) {
    const res = await fetch(nextUrl);
    const { data, next } = await res.json();
    yield* data;        // yield each item individually
    nextUrl = next;     // null/undefined stops the loop
  }
}

for await (const user of paginate('/api/users?limit=100')) {
  await processUser(user);
}

Custom Async Generators

// Async generator function — yields Promises
async function* generateNumbers(start, end, delayMs = 100) {
  for (let i = start; i <= end; i++) {
    await new Promise(r => setTimeout(r, delayMs));
    yield i;
  }
}

// Async generator for event-based sources
async function* fromEventTarget(target, event, { signal } = {}) {
  const { promise, resolve } = Promise.withResolvers();
  const handler = (e) => resolve(e);
  target.addEventListener(event, handler, { once: true, signal });
  try {
    yield await promise;
    // Recurse to get next event
    yield* fromEventTarget(target, event, { signal });
  } finally {
    target.removeEventListener(event, handler);
  }
}

// Consuming with early exit
const controller = new AbortController();
for await (const event of fromEventTarget(emitter, 'data', { signal: controller.signal })) {
  processEvent(event);
  if (shouldStop) controller.abort();
}

Symbol.asyncIterator

class AsyncRange {
  constructor(start, end, delay = 0) {
    this.start = start;
    this.end = end;
    this.delay = delay;
  }

  [Symbol.asyncIterator]() {
    let current = this.start;
    const { end, delay } = this;
    return {
      async next() {
        if (delay) await new Promise(r => setTimeout(r, delay));
        if (current <= end) {
          return { value: current++, done: false };
        }
        return { value: undefined, done: true };
      },
      return() {
        // Called when consumer breaks early
        return { value: undefined, done: true };
      }
    };
  }
}

for await (const n of new AsyncRange(1, 5, 100)) {
  console.log(n); // 1, 2, 3, 4, 5 with 100ms between each
}

AbortController — Cancellation

Fetch Cancellation

// Cancel an in-flight fetch
const controller = new AbortController();

const button = document.querySelector('#cancel');
button.addEventListener('click', () => controller.abort());

try {
  const res = await fetch('/api/large-export', {
    signal: controller.signal,
  });
  const data = await res.json();
} catch (err) {
  if (err.name === 'AbortError') {
    console.log('Request was cancelled');
  } else {
    throw err;
  }
}

AbortSignal.timeout — Built-in Timeout

// No need for Promise.race with a timeout Promise anymore
const res = await fetch('/api/data', {
  signal: AbortSignal.timeout(5000), // throws TimeoutError after 5s
});

// TimeoutError is a subclass of DOMException
try {
  const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
} catch (err) {
  if (err.name === 'TimeoutError') {
    console.log('Request timed out');
  }
}

AbortSignal.any — Combine Signals

// Cancel if EITHER the user aborts OR we time out
const userController = new AbortController();
const combined = AbortSignal.any([
  userController.signal,
  AbortSignal.timeout(10_000),
]);

const res = await fetch(url, { signal: combined });

Custom Abortable Operations

// Make your own async functions respect abort signals
async function delay(ms, { signal } = {}) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(resolve, ms);
    signal?.addEventListener('abort', () => {
      clearTimeout(timer);
      reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
    }, { once: true });
  });
}

// Propagate cancellation through a chain of operations
async function processItems(items, { signal } = {}) {
  for (const item of items) {
    signal?.throwIfAborted(); // throws if already aborted
    await processItem(item, { signal });
  }
}

Streams API

ReadableStream

// Create a readable stream from scratch
const stream = new ReadableStream({
  start(controller) {
    // Called once when stream is created
  },
  async pull(controller) {
    // Called when consumer wants more data
    const chunk = await getNextChunk();
    if (chunk === null) {
      controller.close();
    } else {
      controller.enqueue(chunk);
    }
  },
  cancel(reason) {
    // Called if consumer cancels
    cleanup();
  },
});

// Consume with for await
const reader = stream.getReader();
try {
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    process(value);
  }
} finally {
  reader.releaseLock();
}

// Or use for-await-of (web streams are async iterable in modern browsers/Node 18+)
for await (const chunk of stream) {
  process(chunk);
}

WritableStream

const writable = new WritableStream({
  async write(chunk, controller) {
    await persistChunk(chunk);
  },
  close() {
    console.log('Stream finished');
  },
  abort(err) {
    console.error('Stream aborted:', err);
  },
});

const writer = writable.getWriter();
await writer.write('chunk 1');
await writer.write('chunk 2');
await writer.close();

TransformStream — Data Transformation in Flight

// Parse newline-delimited JSON (NDJSON)
class NDJSONParser extends TransformStream {
  constructor() {
    let buffer = '';
    super({
      transform(chunk, controller) {
        buffer += chunk;
        const lines = buffer.split('\n');
        buffer = lines.pop(); // last line may be incomplete
        for (const line of lines) {
          if (line.trim()) {
            controller.enqueue(JSON.parse(line));
          }
        }
      },
      flush(controller) {
        if (buffer.trim()) {
          controller.enqueue(JSON.parse(buffer));
        }
      },
    });
  }
}

// Usage: fetch NDJSON and stream parsed objects
const response = await fetch('/api/export.ndjson');
const objectStream = response.body
  .pipeThrough(new TextDecoderStream())
  .pipeThrough(new NDJSONParser());

for await (const record of objectStream) {
  await insertRecord(record);
}

Node.js Streams — pipeline()

import { pipeline } from 'node:stream/promises';
import { createReadStream, createWriteStream } from 'node:fs';
import { createGzip } from 'node:zlib';
import { Transform } from 'node:stream';

// pipeline() properly handles errors and cleanup
await pipeline(
  createReadStream('./input.csv'),
  new Transform({
    objectMode: true,
    transform(chunk, encoding, callback) {
      // Transform each chunk
      const processed = processCSVChunk(chunk.toString());
      callback(null, processed);
    },
  }),
  createGzip(),
  createWriteStream('./output.csv.gz'),
);

Backpressure

// Writer respects backpressure automatically
const writer = writable.getWriter();

for (const chunk of largeDataset) {
  // write() returns a Promise that resolves when ready for more
  await writer.write(chunk);
}

Web Workers

Basic Worker (browser)

// main.js
const worker = new Worker('./worker.js', { type: 'module' });

worker.postMessage({ type: 'COMPUTE', data: largeArray });

worker.addEventListener('message', (event) => {
  console.log('Result:', event.data.result);
});

worker.addEventListener('error', (event) => {
  console.error('Worker error:', event.message);
});

// Terminate when done
worker.terminate();
// worker.js
self.addEventListener('message', async (event) => {
  const { type, data } = event.data;
  if (type === 'COMPUTE') {
    const result = heavyComputation(data);
    self.postMessage({ result });
  }
});

Transferable Objects — Zero-Copy Transfer

// Transfer ownership of ArrayBuffer — zero-copy, no serialization
const buffer = new ArrayBuffer(1024 * 1024 * 100); // 100MB
const view = new Uint8Array(buffer);

// After transfer, buffer is detached in the sender
worker.postMessage({ buffer }, [buffer]); // second arg = transferables
// buffer.byteLength === 0 now — ownership transferred

// In worker.js
self.addEventListener('message', ({ data: { buffer } }) => {
  const view = new Uint8Array(buffer);
  // ... process ...
  // Transfer back
  self.postMessage({ buffer }, [buffer]);
});

SharedArrayBuffer + Atomics

// SharedArrayBuffer — both threads read/write same memory
// Requires Cross-Origin Isolation: COOP + COEP headers

// main.js
const shared = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * 10);
const array = new Int32Array(shared);

worker.postMessage({ shared }); // no transfer needed — it's shared

// Atomic operations prevent data races
Atomics.add(array, 0, 1);          // atomic increment
const val = Atomics.load(array, 0); // atomic read
Atomics.store(array, 0, 42);        // atomic write
const exchanged = Atomics.compareExchange(array, 0, expected, replacement);

// Atomic wait/notify (for coordination between threads)
// worker: wait for index 0 to change from 0
Atomics.wait(array, 0, 0); // blocks worker thread

// main: signal worker
Atomics.store(array, 0, 1);
Atomics.notify(array, 0, 1); // wake one waiter

MessageChannel — Direct Worker-to-Worker Communication

const channel = new MessageChannel();
const worker1 = new Worker('./worker1.js');
const worker2 = new Worker('./worker2.js');

// Give each worker a port — they can now talk directly
worker1.postMessage({ port: channel.port1 }, [channel.port1]);
worker2.postMessage({ port: channel.port2 }, [channel.port2]);

// In worker1.js
self.addEventListener('message', ({ data: { port } }) => {
  port.addEventListener('message', (e) => console.log('From worker2:', e.data));
  port.start();
  port.postMessage('Hello from worker1');
});

Structured Concurrency — Cancellation Propagation

// Cancel a group of operations atomically
async function fetchWithCleanup(urls, { signal } = {}) {
  const controller = new AbortController();

  // Cancel our internal controller if the parent signal fires
  signal?.addEventListener('abort', () => controller.abort(signal.reason), {
    once: true,
    signal: controller.signal, // auto-remove listener when we're done
  });

  try {
    return await Promise.all(
      urls.map(url => fetch(url, { signal: controller.signal }))
    );
  } catch (err) {
    controller.abort(err); // cancel remaining on first failure
    throw err;
  }
}

// Race with cleanup: cancel losers when winner is found
async function raceWithCleanup(operations) {
  const controller = new AbortController();
  try {
    return await Promise.any(
      operations.map(op => op({ signal: controller.signal }))
    );
  } finally {
    controller.abort(); // cancel all remaining operations
  }
}

Error Handling in Async Code

Global Unhandled Rejection Handlers

// Browser
window.addEventListener('unhandledrejection', (event) => {
  console.error('Unhandled promise rejection:', event.reason);
  event.preventDefault(); // prevent default browser behavior (console error)
});

window.addEventListener('rejectionhandled', (event) => {
  console.log('Previously unhandled rejection was handled:', event.promise);
});

// Node.js
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled rejection at:', promise, 'reason:', reason);
  process.exit(1); // crash — unknown state is dangerous
});

process.on('uncaughtException', (err) => {
  console.error('Uncaught exception:', err);
  process.exit(1);
});

Async Stack Traces

// Node.js — enable async stack traces (slight performance cost)
// node --async-stack-traces script.js  (default in Node 12+)

// The Error.captureStackTrace pattern
class AsyncError extends Error {
  constructor(message, options) {
    super(message, options);
    this.name = 'AsyncError';
    // Stack trace starts here, not inside Error constructor
    Error.captureStackTrace?.(this, AsyncError);
  }
}

// Preserving context across async boundaries with cause
async function loadConfig(path) {
  try {
    const text = await readFile(path, 'utf8');
    return JSON.parse(text);
  } catch (err) {
    throw new Error(`Failed to load config from ${path}`, { cause: err });
  }
}

Anti-Patterns to Avoid

// BAD: async void — fire and forget, errors are silently swallowed
someButton.addEventListener('click', async () => {
  await doSomething(); // if this throws, nobody knows
});

// GOOD: handle the error
someButton.addEventListener('click', () => {
  doSomething().catch(err => showErrorToUser(err));
});

// BAD: floating promise (not awaited, not .catch'd)
function processItem(item) {
  fetch('/api/track'); // fire and forget — may fail silently
  return transformItem(item);
}

// BAD: Promise constructor wrapping a Promise
const p = new Promise(resolve => resolve(fetch(url))); // unnecessary wrapping

// BAD: sequential await where parallel is possible
const a = await getA();
const b = await getB(); // waits unnecessarily
const c = await getC(); // waits unnecessarily

// GOOD
const [a, b, c] = await Promise.all([getA(), getB(), getC()]);

// BAD: .then() inside async function (mixing styles)
async function getData() {
  return fetch(url).then(r => r.json()); // confusing mix
}

// GOOD: consistent async/await
async function getData() {
  const res = await fetch(url);
  return res.json();
}