Async JavaScript & Timing
Async JavaScript & Timing

The event loop: tasks, microtasks and rendering

Lesson · ~30 min
After this unit you can
  • Explain run-to-completion and one turn of the event loop: one task, then every queued microtask, then maybe a render
  • Predict the log order of synchronous code, setTimeout(…, 0), .then, queueMicrotask and the code after await
  • Explain why a long task or a self-requeuing microtask chain freezes input and paint, and split the work with a task-boundary yield
  • Tell orderings every host guarantees from host-specific ones: setImmediate and process.nextTick in Node, requestAnimationFrame in browsers

Read

Simple
1 / 2

JavaScript runs one thing at a time, to completion

Flag

A page's JavaScript runs on one thread, the main thread. At any moment exactly one piece of code is running on it, and once a function starts, nothing else (no timer, no click handler, no promise callback) can cut in until the call stack is empty again. MDN calls this run-to-completion. (A Web Worker is a separate thread with its own event loop, which talks to the page by messages; the long-tasks reading comes back to it.)

So "asynchronous" never means "runs at the same time as your other code". It means "runs later, on the same thread, as a separate job, after the current code has finished".

console.log("A");
setTimeout(() => console.log("B"), 0);
console.log("C");
// A
// C
// B

setTimeout(fn, 0) does not run fn now. It hands fn to the browser (or Node) with a note: "once at least 0 ms have passed, queue this as a job". The current code keeps going, prints C, and finishes. Only then does the event loop pick up the queued job and print B.

The event loop is the part of the runtime that does that picking. Its whole job:

  1. Take the next job from a queue.
  2. Run it until the call stack is empty.
  3. Repeat. If there's nothing queued, wait.

Everything else in this track (timers, promises, debounce, cancellation) is a way of putting jobs on those queues and reasoning about when they come off.

Medium
1 / 2

Tracing an ordering puzzle, queue by queue

Flag

The reliable way to predict output is to keep two lists on paper, tasks and microtasks, and walk the code line by line.

console.log("script start");

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

function fetchLike() {
  console.log("fetchLike called");
  return Promise.resolve("data");
}

async function load() {
  console.log("load: before await");
  const data = await fetchLike();
  console.log("load: after await", data);
}

load();

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

console.log("script end");

Walking it:

  1. script start logs. The timer is handed off; when it expires it queues a task. Tasks: [timeout].
  2. load() is called. An async function runs synchronously up to its first await, so load: before await logs, then fetchLike() runs and logs fetchLike called.
  3. await gets an already-resolved promise. It still pauses load and queues the rest of the function as a microtask. load() returns a pending promise to the caller. Microtasks: [rest of load].
  4. Promise.resolve().then(…) is already resolved, so its callback is queued immediately. Microtasks: [rest of load, then 1]. The second .then waits: it's attached to the promise that then 1 will resolve, which is still pending.
  5. script end logs. The script, our first task, is done, and the stack is empty.
  6. Drain microtasks: load: after await data logs, then then 1. Finishing then 1 resolves its promise, which queues then 2, and the drain keeps going until the queue is empty, so then 2 logs too.
  7. Next task: timeout.
script start
load: before await
fetchLike called
script end
load: after await data
then 1
then 2
timeout

The three habits that make these easy:

  • Synchronous first. Including the body of an async function up to its first await, and a new Promise executor, which runs immediately.
  • A .then callback is queued when its promise settles, not when you write .then. A chained .then waits for the previous callback to finish.
  • All microtasks before the next task, however many get queued along the way.

Complex
1 / 2

Microtasks can starve everything else

Flag

"Drain the microtask queue until it's empty" has a sharp edge: if each microtask queues another, the queue never empties, and the event loop never gets to the next task or the next render.

let n = 0;
function spin() {
  n += 1;
  if (n < 1_000_000) queueMicrotask(spin);
}
setTimeout(() => console.log("timer sees n =", n), 0);
spin();
// timer sees n = 1000000

The 0 ms timer was due almost immediately, but it waits for all million microtasks. Run that in a page and, for as long as the chain lasts, nothing paints and no input is handled. Make the chain unbounded and the tab hangs exactly like while (true) {}.

The same thing happens in disguise:

// ❌ Looks asynchronous, and still never yields to rendering or input
async function pump(queue) {
  while (queue.length > 0) {
    handle(queue.shift());
    await null; // a microtask hop, not a yield
  }
}

Every await here resumes as a microtask, so the whole loop runs inside one microtask checkpoint. "It has await in it" is not the same as "it lets the page breathe". Only a task boundary does that: a timer, scheduler.yield(), or a message from a MessageChannel.

When is a microtask the right tool? When you want something to run after the current synchronous code but before anything else can observe the state: batching several synchronous updates into one flush, or making a callback consistently asynchronous. That's what queueMicrotask is for. A microtask is never the way to give the browser a turn.

Quiz

What does setTimeout(fn, 0) actually do?

Flag

Which of these run as microtasks? (Select all that apply.)

Flag

Which statement about await inside an async function is true?

Flag

In a browser, this click handler sets status.textContent = "Working…", runs ~2 s of synchronous work, then sets "Done". The user never sees "Working…". Why?

Flag

processAll(rows) freezes the tab for 3 s. Which change lets the page paint progress and respond to clicks while it works?

Flag

Which of these orderings can you not rely on?

Flag

At the top level of an ES module (.mjs) in Node you write Promise.resolve().then(a) and then process.nextTick(b). What runs first?

Flag

Fill in the blank

Name the three steps of one turn of the browser's event loop.

Flag
// 1. run one  to completion
// 2. run every queued , including ones queued while doing this
// 3. if a rendering opportunity has come up,  the page

Run flush after the current synchronous code finishes but before any pending timer, without creating a promise yourself.

Flag
(flush);

Make this loop end its task between batches so the browser can paint and handle input.

Flag
for (let i = 0; i < rows.length; i += 500) {
  rows.slice(i, i + 500).forEach(summarise);
   new Promise((resolve) => (resolve, 0));
}

In Node, inside an I/O callback such as fs.readFile's, schedule next so it always runs before a 0 ms timer queued from the same callback.

Flag
fs.readFile(path, () => {
  setTimeout(() => console.log("timeout"), 0);
  (next);
});

Predict the output

setTimeout(() => {
  console.log("t1");
  Promise.resolve().then(() => console.log("m1"));
}, 0);
setTimeout(() => console.log("t2"), 0);
console.log("sync");
Flag
async function run() {
  console.log("1");
  await null;
  console.log("2");
}

setTimeout(() => console.log("3"), 0);
run();
console.log("4");
Flag
Promise.resolve()
  .then(() => console.log("a1"))
  .then(() => console.log("a2"));

Promise.resolve()
  .then(() => console.log("b1"))
  .then(() => console.log("b2"));
Flag
const start = Date.now();
let hops = 0;

setTimeout(() => console.log("timer after", Date.now() - start, "ms"), 0);

function loop() {
  hops += 1;
  if (Date.now() - start < 100) return Promise.resolve().then(loop);
}
loop();
Flag

Further reading