JavaScript & the runtime

The event loop and async

Tasks, microtasks and rendering, how await desugars, timer semantics, unhandled rejections, cancellation with AbortController, Promise combinators, breaking up long tasks, and the Node differences.

"What is the output" questions about promises and timers are the JavaScript interview's litmus test. They are not trivia: the same model explains why Vue batches updates into a microtask, why a for loop of awaits is slow, and why a 300 ms click handler ruins INP.

Know

  • One thread, several queues. Synchronous code runs on the call stack to completion. Then the microtask queue is drained completely (promise reactions, queueMicrotask, MutationObserver), including microtasks queued while draining. Then, in browsers, a rendering opportunity may occur (style, layout, paint; requestAnimationFrame callbacks run just before). Then one task (a timer, an I/O callback, an event) is taken from the task queue and the cycle repeats.
  • await is a microtask boundary. await x suspends the function and resumes in a microtask after x settles; awaiting a native promise costs one microtask tick, awaiting a non-promise value also yields once. Code after await never runs synchronously with the code before it.
  • Timers are tasks with a minimum delay: setTimeout(fn, 0) runs after at least one full turn, browsers clamp nested timers to 4 ms after a few levels and throttle background tabs; setInterval drifts. Never use timers to "wait for the DOM" when await nextTick() or requestAnimationFrame expresses the intent.
  • Long tasks block everything. A task over 50 ms delays input handling and paints; INP counts the delay. Break work up with await scheduler.yield() (yields and resumes with priority; Baseline in 2025 browsers) or setTimeout chunks, move CPU work to a Web Worker (structuredClone transfer, Transferable buffers), and keep handlers short.
  • Unhandled rejections fire unhandledrejection in browsers; in Node they crash the process by default. Every promise chain needs a consumer of its rejection; void promise is a lie to the linter, not a handler.
  • Cancellation is cooperative: AbortController + signal passed to fetch, addEventListener, streams and your own async functions; AbortSignal.timeout(ms) and AbortSignal.any([...]) compose signals; cleanup checks signal.aborted or listens for abort.
  • Combinators: Promise.all (fail fast), allSettled (wait for all), race (first settled), any (first fulfilled, AggregateError if none), Promise.withResolvers() (ES2024, replaces the deferred pattern), Promise.try(fn) (ES2025, runs sync-or-async functions uniformly with errors as rejections).
  • Sequential versus concurrent: for (const u of urls) await fetch(u) serialises; await Promise.all(urls.map(fetch)) runs concurrently; a concurrency limit (a pool of N) is the production version of both.
  • Node's loop adds phases (timers → pending callbacks → poll → check (setImmediate) → close) and process.nextTick, which runs before promise microtasks. setImmediate runs after I/O in the check phase; setTimeout(fn, 0) versus setImmediate ordering is only deterministic inside an I/O callback.

How it works

Predict, then run:

ordering.ts
setTimeout(() => console.log('A timeout'), 0)
requestAnimationFrame?.(() => console.log('B rAF (browser, before next paint)'))
Promise.resolve().then(() => {
  console.log('C microtask')
  queueMicrotask(() => console.log('D nested microtask, still before any task'))
})
;(async () => {
  console.log('E sync part of async fn')
  await undefined
  console.log('F after await')
})()
console.log('G sync')
// Browser: E, G, C, F, D, then B (if a frame is due) and A. Node: E, G, C, F, D, A (no rAF).

A concurrency-limited runner with cancellation, the shape interviewers ask you to write:

app/utils/p-limit.ts
export async function mapWithLimit<T, R>(
  items: readonly T[],
  limit: number,
  fn: (item: T, signal: AbortSignal) => Promise<R>,
  signal?: AbortSignal,
): Promise<R[]> {
  const results: R[] = Array.from({ length: items.length })
  let next = 0

  async function worker() {
    while (next < items.length) {
      signal?.throwIfAborted()
      const index = next++
      results[index] = await fn(items[index]!, signal ?? new AbortController().signal)
    }
  }

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

// usage: await mapWithLimit(ids, 4, (id, signal) => $fetch(`/api/items/${id}`, { signal }), AbortSignal.timeout(10_000))

Breaking a long task so input stays responsive:

app/utils/chunked.ts
export async function processInChunks<T>(items: T[], handle: (item: T) => void, budgetMs = 8) {
  let deadline = performance.now() + budgetMs
  for (const item of items) {
    handle(item)
    if (performance.now() > deadline) {
      // Yield to the event loop so clicks and paints can happen; resume with priority where supported.
      await ('scheduler' in globalThis && 'yield' in scheduler ? scheduler.yield() : new Promise(r => setTimeout(r, 0)))
      deadline = performance.now() + budgetMs
    }
  }
}

Retry with exponential backoff and jitter, honouring cancellation:

app/utils/retry.ts
export async function retry<T>(fn: (signal?: AbortSignal) => Promise<T>, { retries = 3, baseMs = 200, signal }: { retries?: number, baseMs?: number, signal?: AbortSignal } = {}): Promise<T> {
  for (let attempt = 0; ; attempt++) {
    try {
      return await fn(signal)
    }
    catch (error) {
      if (attempt >= retries || signal?.aborted) throw error
      const delay = baseMs * 2 ** attempt * (0.5 + Math.random()) // jitter spreads retries out
      await new Promise((resolve, reject) => {
        const t = setTimeout(resolve, delay)
        signal?.addEventListener('abort', () => { clearTimeout(t); reject(signal.reason) }, { once: true })
      })
    }
  }
}
Gotcha· await in a loop

for (const id of ids) { results.push(await load(id)) } takes the sum of the latencies. Unless order or rate limiting demands it, start the work first (ids.map(load)) and await the group, or use a limited pool. Interviewers ask this as "make it faster".

Gotcha· Floating promises

An async function called without await or .catch produces a promise nobody observes; when it rejects, Node exits and the browser logs an error you never see in tests. Lint for it (@typescript-eslint/no-floating-promises) and handle or explicitly void with a comment explaining why the result is ignored.

Verify before the interview:

Browser support for scheduler.yield() and scheduler.postTask() as of the interview week; both were becoming Baseline through 2025.

docs ↗

Exercise

Exercise
  • Write five ordering puzzles mixing setTimeout, Promise.then, await, queueMicrotask and (in Node) process.nextTick/setImmediate; predict, run, explain each discrepancy.
  • Implement mapWithLimit and test it with a fake 100 ms task: 20 items at limit 4 should take roughly 500 ms, not 2 s or 100 ms.
  • Take a loop that processes 50,000 rows synchronously, measure the long task in the Performance panel, then chunk it and confirm the input delay disappears.

Be able to say

Be able to say· Explain the event loop and how await fits into it.

"JavaScript runs one task at a time on a single call stack. When the stack empties, the engine drains the microtask queue completely, promise reactions and queueMicrotask, including ones queued while draining, then the browser gets a chance to render, then it takes the next task: a timer, an event, an I/O callback. await is a microtask boundary: the function suspends and resumes in a microtask after the awaited promise settles, so the code after an await always runs later than the code around the call, but before any timer. That model explains everything else: Vue batching updates into a microtask, why sequential awaits are slow, why a long synchronous handler blocks input, and why setTimeout(fn, 0) is a full turn later."

Be able to say· How do you make a burst of async work fast without overwhelming the server?

"Start the work concurrently rather than awaiting in a loop, but bound the concurrency: a pool of N workers pulling from a shared index, Promise.all over the workers, results written by index so order is preserved. Every task receives an AbortSignal so the whole batch can be cancelled with one AbortController or an AbortSignal.timeout, and transient failures retry with exponential backoff plus jitter so retries do not synchronise. That is p-limit and p-retry in thirty lines, and knowing the shape means I can also explain why a naive Promise.all over a thousand fetches is a self-inflicted denial of service."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.