Representative interview topic

Frontend Interview: How Does the JavaScript Event Loop Work?

FrontendHard
Offer.cc Editorial TeamPublished Updated

Question

A click handler schedules a Promise reaction, queueMicrotask, setTimeout, and a large chain of recursive microtasks. Predict the log order, explain why timers, input, and painting are delayed, and redesign the computation to keep the page responsive.

Question and when to use it

You are handling a click in a data dashboard. Start by predicting the output of this code:

javascript
console.log("A");

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

Promise.resolve().then(() => {
  console.log("promise");
  queueMicrotask(() => console.log("nested"));
});

queueMicrotask(() => console.log("microtask"));

console.log("B");

Explain every step, then analyze this performance problem:

javascript
let remaining = 100_000;

function continueInMicrotask() {
  remaining -= 1;
  if (remaining > 0) {
    queueMicrotask(continueInMicrotask);
  }
}

queueMicrotask(continueInMicrotask);
setTimeout(() => console.log("timer can run"), 0);
requestAnimationFrame(() => console.log("frame can render"));

Explain why the timer, later clicks, and painting are delayed, then redesign the batch computation so the page remains responsive. This question concerns JavaScript on the browser main thread. A worker has its own event loop, and Node.js has a phase model that should not be copied directly into a browser answer.

This is useful for mid-level and senior frontend, web-performance, and full-stack interviews. A strong answer connects the scheduling model to observable user experience: eventual completion does not mean the page stayed interactive while the work was running.

What the interviewer is evaluating

The first signal is an accurate model. Initial script execution, click callbacks, and expired timer callbacks run as tasks. Promise reactions, queueMicrotask() callbacks, and MutationObserver callbacks use microtasks. After a task finishes, the event loop performs a microtask checkpoint and keeps processing microtasks until the queue is empty.

The second signal is whether the candidate avoids describing the browser as having exactly one permanent “macrotask queue.” The HTML Standard permits different task sources to be associated with different task queues. A browser can make an implementation-defined choice among runnable queues while preserving ordering within one task source. “Macrotask” can be conversational shorthand, but task is the more accurate standards term.

The third signal is a correct rendering boundary. Only after the microtask checkpoint ends can the browser move on to other tasks or a rendering update, and rendering is not guaranteed after every task. If code keeps adding another microtask before the queue empties, input events, timers, and rendering opportunities can all be starved.

The fourth signal is choosing the right scheduling primitive. await Promise.resolve() only resumes the function in a microtask, so it does not allow a later task or paint to run first. A real main-thread yield schedules continuation in a future task, such as scheduler.yield() where supported or a setTimeout() fallback. CPU work that cannot be safely chunked belongs in a worker.

Questions to clarify before answering

  • Which browsers must be supported? scheduler.yield() is not available in every widely used browser, so broad support requires feature detection and a fallback.
  • Can the work be split between records, or can one call block for a long time? A splittable loop can yield between batches. If one record is itself expensive, batching on the main thread still permits a long block; use a worker or change the algorithm.
  • Must progress be painted after every batch, or is only the final result needed? Visible progress requires a main-thread yield after updating state. A final-only result can avoid repeated DOM, layout, and paint cost.
  • Must results be committed in strict order? Workers can calculate concurrently, but out-of-order completion needs sequence numbers, a merge rule, or an ordered commit buffer.
  • Must processing continue in a background tab? requestAnimationFrame() is paused in most background tabs, so it is a poor general scheduler for mandatory background progress.
  • How will responsiveness be accepted? Agree on interaction latency, per-batch budget, total throughput, and cancellation behavior so “does not freeze” becomes testable.

30-second answer framework

“The browser selects one runnable task, runs it, and then performs a microtask checkpoint that drains the microtask queue. Only afterward can it move to rendering or another task. The first snippet logs A and B synchronously, then promise and microtask in enqueue order. The promise callback appends nested behind the microtask already waiting, and timeout runs last. Recursive microtasks keep the checkpoint open, starving timers, input, and rendering. await Promise.resolve() is still a microtask, so it is not a real main-thread yield. I would time-slice chunkable work and call feature-detected scheduler.yield() between batches, with setTimeout() as a fallback. If one unit is still CPU-heavy, I would move it to a worker, then verify interaction latency with a performance recording and real input.”

Step-by-step solution

Step 1: Derive the output from enqueue time

The whole script is currently running as one task. Synchronous statements do not wait for the event loop, so the first output is:

text
A
B

setTimeout(..., 0) means that after the timing condition is satisfied, its callback can become a future task. Zero does not interrupt the current script. Promise.resolve().then(...) queues a Promise reaction as the first microtask, and the following queueMicrotask(...) queues the second microtask.

When the script task finishes, the microtask checkpoint begins. The first microtask logs promise and appends nested to the end of the microtask queue. The explicit microtask was already waiting, so it logs microtask before nested. Once the microtask queue is empty, the timer task gets an opportunity to run:

text
A
B
promise
microtask
nested
timeout

For each line, record when the callback becomes queued and which scheduling category it enters. “Microtasks have priority” is not enough if one of the competing callbacks has not been queued yet.

Step 2: Establish the task, microtask, and rendering boundaries

A reusable simplified sequence is:

  1. The browser selects one task from a runnable task queue.
  2. It runs that task until the JavaScript call stack is empty.
  3. It performs a microtask checkpoint; microtasks added during the checkpoint are processed in the same checkpoint.
  4. Based on rendering opportunities, document visibility, and implementation policy, the browser may update rendering.
  5. The loop continues and can process another task.

This explains two practical observations. First, if a click handler changes the DOM and immediately starts a large synchronous calculation, the user usually does not see the intermediate state because the browser has not regained a paint opportunity. Second, microtasks are suitable for short consistency work that must happen before other events and timers, but not for unbounded or large recursive computation.

Step 3: Diagnose microtask starvation

Every invocation of the first microtask in the second snippet queues another microtask. The checkpoint cannot finish until its queue is empty, so the 100,000 callbacks complete before the timer task or the next click task can run.

Without a termination condition, the microtask queue never empties in the scheduling model. A browser might eventually show an unresponsive-page warning, terminate the page, or apply an implementation safeguard, but application correctness cannot depend on that. requestAnimationFrame() does not preempt running JavaScript; it requests a callback before a future repaint. If the main thread never reaches the relevant rendering steps, the callback waits.

This apparent yield is still wrong:

javascript
async function processAll(records) {
  for (const record of records) {
    normalize(record);
    await Promise.resolve();
  }
}

Each await continuation resumes through a Promise microtask. The call stack briefly becomes empty, but the checkpoint continues consuming those microtasks, so later input and timer tasks still cannot enter.

Step 4: Split chunkable work across tasks

Give each batch a measurable time budget, then genuinely yield between batches:

javascript
function yieldToMain() {
  return globalThis.scheduler?.yield
    ? globalThis.scheduler.yield()
    : new Promise((resolve) => setTimeout(resolve, 0));
}

async function processRecords(records, budgetMs = 5) {
  let index = 0;

  while (index < records.length) {
    const deadline = performance.now() + budgetMs;

    while (index < records.length && performance.now() < deadline) {
      normalize(records[index]);
      index += 1;
    }

    updateProgress(index / records.length);

    if (index < records.length) {
      await yieldToMain();
    }
  }
}

The 5 millisecond value is an initial assumption for this exercise, not a cross-device standard. Tune it on target devices against per-item cost, interaction latency, and throughput. scheduler.yield() schedules continuation as a later prioritized task, giving the browser a chance to handle necessary work first. Because its browser support is incomplete, the example feature-detects it. The setTimeout() fallback is broader but is affected by timer clamping, background policies, and competition from other tasks.

There is another boundary: the budget can be checked only between calls to normalize(). If one call blocks for 80 milliseconds, a five-millisecond budget cannot help. Split normalize(), replace the algorithm, or move the computation to a worker.

Step 5: Match the primitive to the work

RequirementChoiceMain cost or boundary
Run short cleanup or consistency notification after the current task but before other eventsqueueMicrotask()Recursion or heavy computation starves other work
Split long main-thread work while keeping a prioritized continuationscheduler.yield()Requires feature detection; support is incomplete
Move continuation to a future task with broad compatibilitysetTimeout()Timer delay and scheduling are not deterministic
Update animation state before a future repaintrequestAnimationFrame()Heavy callback work still blocks that paint; background tabs commonly pause it
Run CPU-heavy work that cannot be safely splitWeb WorkerMessage, copy, or shared-memory protocol cost

requestAnimationFrame() aligns visual work with painting; it is not a general background-job queue. Use it to submit lightweight visual changes, not to hide a large calculation immediately before paint. A worker removes CPU computation from the page main thread, but it does not automatically solve cancellation, progress reporting, result ordering, or transfer cost. Those need an explicit protocol.

Step 6: Verify responsiveness, not only completion

Verification should cover at least four layers:

  1. Ordering test: Log synchronous code, Promise reactions, queueMicrotask(), and timers in a minimal page and confirm the observed order matches the derivation.
  2. Timeline inspection: Record the click, batches, and progress painting in browser performance tools. Inspect long tasks, continuous microtasks, frame gaps, and when input callbacks actually run.
  3. Stress and cancellation: Increase the record count, throttle the CPU, click and scroll during processing, and cancel the operation. Verify that queues do not grow without a bound.
  4. Boundary environments: Verify the fallback in a browser without scheduler.yield(). Move the page to a background tab and confirm the business workflow does not incorrectly depend on continuous requestAnimationFrame() callbacks.

Acceptance needs both completion time and interaction latency. Batching adds scheduling overhead and may slightly increase total duration; its purpose is to leave execution windows for input, painting, and other necessary tasks. If that throughput cost is unacceptable, optimize the algorithm or use a worker instead of filling the main thread with microtasks again.

Example of a strong answer

“I would derive the order from enqueue time. The current script is one task, so A and B are synchronous. The timer only schedules a future task. The Promise reaction enters the microtask queue before the explicit queueMicrotask callback, so the checkpoint logs promise first. That callback appends nested behind the microtask already waiting. The final order is A, B, promise, microtask, nested, timeout.

After a task, the browser performs a microtask checkpoint and keeps going until the microtask queue is empty. The second snippet keeps replenishing that queue, so the checkpoint stays open for a long time. Timers and clicks are later tasks, and painting needs the main thread to reach a rendering opportunity, so all of them are delayed. requestAnimationFrame cannot preempt JavaScript, and await Promise.resolve only resumes in another microtask, so neither fixes the starvation.

If each record is quick, I would process against a time budget and call scheduler.yield between batches. Since it is not available in every browser, I would feature-detect it and fall back to setTimeout. Starting with a five-millisecond budget is only an experiment; I would tune it on target devices against input latency and throughput. If one record is itself expensive, I would split that operation or move it to a worker.

Finally, I would record a performance timeline and confirm there is no continuous microtask waterfall, progress is actually painted, clicks and scrolling run during processing, and cancellation, background tabs, and the compatibility fallback behave correctly. The design accepts some scheduling overhead in exchange for measurable responsiveness.”

Common mistakes

  • Reciting “sync, microtask, macrotask” → It cannot explain why a nested microtask runs behind one already queued and ignores multiple task sources → Annotate enqueue time, category, and queue state line by line.
  • Calling setTimeout(..., 0) immediate → An expired timer only makes a future task eligible and cannot preempt the current task or checkpoint → State the earliest eligibility without promising an exact time.
  • Assuming painting occurs after every microtask → A checkpoint drains microtasks continuously, and a later rendering opportunity may still be skipped → Discuss painting after the complete checkpoint.
  • Chunking with await Promise.resolve() The continuation is still a microtask and does not admit later input or timer tasks → Schedule continuation in a future task.
  • Using endless queueMicrotask() calls for “priority” → They starve other tasks and rendering → Reserve microtasks for short, finite consistency work.
  • Moving heavy work into requestAnimationFrame() The callback still runs on the main thread before paint, so heavy work delays the frame → Keep rAF visual work light and chunk or offload CPU work.
  • Calling scheduler.yield() unconditionally → Some widely used browsers do not support it → Feature-detect it and test the setTimeout() fallback.
  • Measuring only total duration → Normal throughput can hide long periods of unresponsive input → Inspect interaction latency, long tasks, frames, and queue growth too.
  • Using one fixed batch size everywhere → Per-item cost, refresh rate, and device speed vary → Start with a time budget and tune it in the target environment.

Follow-up questions and responses

Follow-up 1: Which runs first, two zero-delay timers or a click?

API names alone do not establish a universal order across task sources. A browser can maintain multiple task queues and choose among runnable queues while preserving order within one task source. Clarify when the timers became eligible, when the click occurred, and what else occupied the page, then observe the target implementation. Deterministic interview snippets normally control these conditions.

Follow-up 2: If scheduler.yield() returns a Promise, why is it a real yield?

The important property is how the API schedules resolution, not the return type. A continuation after an already-resolved Promise joins the current microtask checkpoint. scheduler.yield() schedules a later prioritized task that resolves its Promise, allowing pending work such as input to run before continuation. It still needs feature detection, and tiny operations should not each become a separate task.

Follow-up 3: Can processing 100,000 records in a worker still freeze the page?

Yes. The main thread can still be overloaded by excessive messages, large data copies, frequent DOM changes, or expensive result merging. Batch progress messages, limit their frequency, evaluate transferable objects or a justified shared-memory protocol, and commit only necessary visual changes on the main thread.

Follow-up 4: When should queueMicrotask() be used?

Use it for short, finite work that must run after the current synchronous logic but before other events, such as giving a synchronous cache hit and a Promise-based miss the same callback ordering, or batching one internal library notification. It is not a long-work scheduler. State both the termination rule and the work bound.

Follow-up 5: How would you cancel the chunked operation?

Check an AbortSignal at the start of each batch and after each yield, stop taking records, and prevent an older operation from overwriting the progress or result of a newer request. A worker design needs a cancellation message and a rule for discarding late results. Checks that are too sparse react slowly; checks around every tiny operation add overhead, so measure at batch boundaries.

Follow-up 6: Does requestAnimationFrame() guarantee an immediate paint?

No. It requests a callback before a future repaint, while the browser can schedule or skip rendering based on visibility and rendering policy. Most browsers pause these callbacks in background tabs. Even when the callback runs, long work inside it continues to delay the actual paint.

Public sources

Related questions