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) {
  if (!Number.isInteger(limit) || limit < 1) {
    throw new RangeError("limit must be a positive integer");
  }

  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
const reader = response.body.getReader();

try {
  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    consume(value);
  }
} finally {
  reader.releaseLock();
}

Properly designed streams can provide backpressure: the consumer requests data at a pace it can handle rather than buffering the entire input. Direct async iteration over a browser ReadableStream is convenient where supported; the reader loop is more portable. Browser streams and Node streams differ in API details.

Assemble policies instead of hiding them ​

A production job processor needs more than a worker count. Its contract should state whether results preserve input order, whether one failure stops other work, whether retries apply to every error, and what cancellation means for work already running.

js
async function processJobs(jobs, {
  limit = 4,
  signal,
  onProgress = () => {},
  run,
}) {
  let completed = 0;

  return mapConcurrent(jobs, limit, async job => {
    signal?.throwIfAborted();
    const result = await retry(
      () => run(job, { signal }),
      { attempts: 3, signal },
    );
    completed += 1;
    onProgress({ completed, total: jobs.length, job });
    return result;
  });
}

This preserves input order because mapConcurrent writes by original index. Promise.all rejects when a worker fails, but already-started work does not magically stop. Passing one signal into the operation and retry delay is what makes cancellation cooperative. Progress is reported after success; a system that counts failures needs an explicit status in that event contract.

AbortSignal.timeout and AbortSignal.any exist in modern environments, but a library should declare its compatibility baseline or accept a caller-created signal rather than assuming every target supplies them.

Optional prompts ​

Predict: Without its positive-integer guard, what result can mapConcurrent(items, 0, transform) produce, and why is that more dangerous than an immediate exception?

Answer: It creates zero workers, performs no transforms, and can return a sparse result array sized like the input. That resembles successful completion and pushes the defect downstream. Rejecting the invalid concurrency contract immediately makes the caller responsible for correcting it.

Explain: Two withdrawals both read a balance, await separate checks, and then write an updated balance. How can this lose an update if JavaScript executes one statement at a time?

Answer: Each function runs until its await, allowing the other to observe the same old balance. Their continuations later write results derived from stale snapshots. The race is logical interleaving rather than simultaneous memory access; serialize the operation or move the atomic decision to the data owner.

Modify: The job processor must preserve input order but record failures instead of rejecting the entire batch. What should each result contain?

Answer: Catch inside each indexed transform and return a discriminated result such as { status: "fulfilled", value } or { status: "rejected", reason }. Because mapConcurrent writes by original index, output order remains stable. Decide separately whether cancellation is represented as another status or aborts the whole operation.

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 ​