Event Loop

JavaScript on the browser's main thread is single-threaded: only one piece of JavaScript runs at a time. The event loop is the mechanism that lets the browser decide what should run next after the current JavaScript call stack becomes empty.

When something happens, such as a user click, a timer firing, or a message being posted, the browser schedules a callback as a task. Once the current task finishes, the event loop picks another task and executes it.

Event Cycle

An event cycle, often called a tick or a turn of the event loop, is one pass through the event loop. In a simplified browser model, each cycle looks like this:

  1. Take one task from a task queue and run it to completion.
  2. Run all queued microtasks, such as callbacks scheduled with queueMicrotask or resolved Promise handlers.
  3. Give the browser an opportunity to update rendering, including style calculation, layout, and paint.
  4. Move to the next cycle and choose another task.

The important detail is that rendering cannot happen while JavaScript is still running. If a task takes too long, the browser cannot process input or paint the next frame until that task finishes.

Microtasks also matter here. They run before the browser gets a chance to paint, so moving heavy work into a microtask does not help the UI become responsive. A long chain of microtasks can block rendering just like a long synchronous function.

Yielding to the Browser

This model makes it possible to delay the execution of certain code. For example, imagine you have two functions:

  • setButtonBackgroundColor() updates a button's background color.
  • doHeavyComputation() performs an expensive calculation that takes a long time to finish.

If you write:

setButtonBackgroundColor()

doHeavyComputation()

Even though the UI update comes first, the main thread immediately moves into doHeavyComputation(). While that function is running, nothing else on the main thread can run: not your other JavaScript, not input handling, and not the browser's rendering work. The page is effectively blocked until the heavy computation finishes.

You can delay that heavy computation by scheduling it with setTimeout:

setButtonBackgroundColor()

setTimeout(() => {
  doHeavyComputation()
}, 0)

This puts doHeavyComputation() into a later task. The current task can finish first, which gives the browser a chance to continue through the event loop before the computation starts.

However, this only delays the problem. At some point, doHeavyComputation() still has to run. If it runs as one long function, it will still block the main thread for its entire duration.

Why This Matters for Scheduler

This is the general idea Scheduler builds on: long work should be split into smaller chunks, and each chunk should give the browser a chance to regain control before the next one starts.

In practice, that means a system can run some work, stop, and continue the rest in a later task. That pause is what gives the browser a chance to handle user input, paint updates, and keep the page responsive.

Microtasks are not a good fit for this kind of yielding. Because microtasks run before rendering, continuing heavy work through queueMicrotask or resolved Promise handlers can still block paint. If the goal is to let the browser update the screen, the continuation needs to happen in a later task, not in the same cycle's microtask phase.

The specific APIs used to schedule those later tasks are implementation details. The important mental model for now is simpler: yielding only helps when JavaScript actually stops long enough for the browser to do its own work.