Skip to content

The Event Loop, Cancellation, and Async Design

JavaScript code on a main thread executes one piece at a time. Yet a program can wait for timers, files, network responses, and user input concurrently because the host performs or coordinates that work and later schedules JavaScript callbacks.

The event loop is the scheduling relationship between the currently executing stack and queues of future work. It is not one universally identical queue: browsers and Node have distinct host loops. A compact model still explains most application behavior.

Run-to-completion, tasks, and microtasks

Synchronous code runs to completion before another callback interrupts it:

js
console.log("A");

setTimeout(() => console.log("timer"), 0);

Promise.resolve().then(() => console.log("promise"));

console.log("B");

The usual output is:

text
A
B
promise
timer

The initial script is a task. The promise reaction enters the microtask queue. The timer callback becomes eligible as a later task. After the current stack empties, microtasks are drained before the next task runs.

await continuation is also scheduled through promises:

js
async function demonstrate() {
  console.log("inside 1");
  await null;
  console.log("inside 2");
}

console.log("outside 1");
demonstrate();
console.log("outside 2");

This prints outside 1, inside 1, outside 2, then inside 2. Even awaiting an already fulfilled value yields to a later microtask.

A zero-millisecond timer means “not before this delay, once the loop can run it,” not “immediately.” Long synchronous work delays timers, input, rendering, and every other callback:

js
button.addEventListener("click", () => {
  const result = expensiveSynchronousCalculation();
  render(result);
});

Breaking CPU work into promises does not create parallel execution. For substantial computation, use a Web Worker in browsers, a worker thread or child process in Node, or move the work elsewhere.

Single-threaded code can still race

No two JavaScript statements on one thread execute simultaneously, but operations can interleave across await points:

js
let balance = 100;

async function withdraw(amount) {
  const observed = balance;
  await confirmWithdrawal(amount);
  balance = observed - amount;
}

Two withdrawals can both observe 100 before either resumes, then overwrite each other. The race is logical, not a simultaneous memory write. Protect the operation with an application-level queue, move the atomic decision into a database, or redesign ownership so one coordinator serializes updates.

Cancellation is cooperative

A promise has no general cancel() method. Web-platform and modern Node APIs commonly accept an AbortSignal:

js
const controller = new AbortController();

const request = fetch(url, { signal: controller.signal });

cancelButton.addEventListener("click", () => controller.abort());

try {
  const response = await request;
  // ...
} catch (error) {
  if (error.name !== "AbortError") throw error;
}

Your own operations can accept and check the same signal:

js
async function processJobs(jobs, { signal, onProgress = () => {} }) {
  const results = [];

  for (const job of jobs) {
    signal?.throwIfAborted();
    results.push(await processJob(job, { signal }));
    onProgress(results.length, jobs.length);
  }

  return results;
}

Cancellation must flow through every layer that owns cancellable work. Aborting a caller while an inner network request ignores the signal only discards the result; it does not stop the request.

A timeout is a cancellation policy, not just a race for a result. Modern environments provide AbortSignal.timeout:

js
const response = await fetch(url, {
  signal: AbortSignal.timeout(5_000),
});

When combining a user cancellation signal and timeout, AbortSignal.any can form a signal that aborts when either source does. Check runtime support when targeting older environments.

Retries require policy

Retry only failures likely to be transient, and only operations safe to repeat. An exponential delay with jitter reduces synchronized retry storms:

js
async function retry(operation, {
  attempts = 3,
  signal,
  shouldRetry = () => true,
} = {}) {
  let lastError;

  for (let attempt = 1; attempt <= attempts; attempt += 1) {
    signal?.throwIfAborted();
    try {
      return await operation({ signal, attempt });
    } catch (error) {
      lastError = error;
      if (attempt === attempts || !shouldRetry(error)) throw error;

      const delay = 100 * 2 ** (attempt - 1) + Math.random() * 50;
      await new Promise((resolve, reject) => {
        const timer = setTimeout(resolve, delay);
        signal?.addEventListener("abort", () => {
          clearTimeout(timer);
          reject(signal.reason);
        }, { once: true });
      });
    }
  }

  throw lastError;
}

Production retry utilities should clean up abort listeners and encode precise error policies. The important point is that “retry three times” is incomplete without delay, cancellation, idempotency, and failure classification.

Bound concurrency deliberately

Processing every job sequentially is safe but may underuse I/O. Starting all jobs can overwhelm a service. A small worker pool provides a middle ground:

js
async function mapConcurrent(items, limit, transform) {
  const results = new Array(items.length);
  let nextIndex = 0;

  async function worker() {
    while (true) {
      const index = nextIndex;
      nextIndex += 1;
      if (index >= items.length) return;
      results[index] = await transform(items[index], index);
    }
  }

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

Only limit transforms are pending at once, and indexed writes preserve input order. Decide separately whether one failure should stop new work, whether already-running work should be aborted, and whether partial outcomes should be returned.

Async iteration models values over time

An async iterable produces multiple future values rather than one future value:

js
for await (const chunk of response.body) {
  consume(chunk);
}

Streams can also provide backpressure: the consumer requests data at a pace it can handle rather than buffering the entire input. Browser streams and Node streams differ in API details, but async iteration is a useful common shape.

Asynchronous design becomes manageable when ownership is explicit: which layer starts work, awaits it, cancels it, retries it, limits it, and presents its errors. The syntax is small; those policies are the real program.

Further reference