AniUI Academy

Event Scheduling — the Event Loop as a Scheduler

Microtasks, tasks, requestAnimationFrame, requestIdleCallback, and the modern Scheduler API — a queue hierarchy, and the algorithm-shaped choice of which queue to put your work on.

12 min read

Everything in this track so far has treated computation as "run the algorithm, get the answer, move on." Frontend work has a wrinkle: there's a scheduler — the browser's event loop — deciding when your code gets to run between its own responsibilities (handling input, drawing frames, servicing network callbacks). The queue your work goes on is an algorithmic choice as real as any data-structure choice.

This lesson is about the queue hierarchy, when to put work on each, and the time-sliced pattern that uses all of them to keep a UI smooth under heavy computation.

The queue hierarchy, in one place

The event loop's job is to pick a single next thing to run from an ordered set of queues. Simplified for a UI developer:

  1. Step 1

    Current task runs synchronously

    Your click handler, timer callback, or initial script. Nothing else runs until this returns. Long synchronous work here is the single biggest cause of dropped frames.

  2. Step 2

    Microtasks drain to empty

    Every `queueMicrotask(...)`, every `.then` callback, every awaited continuation. These run before *anything else* — including rendering. A microtask queueing another microtask delays the frame indefinitely.

  3. Step 3

    Possibly a render pass

    Browser calls `requestAnimationFrame` callbacks, runs style/layout/paint. Happens at most once per screen refresh (typically 60Hz = every ~16.67ms). This is when the user actually sees what you did.

  4. Step 4

    Next task runs

    A `setTimeout`, a message event, a UI event. If multiple are ready, the browser picks one, runs it, drains microtasks again, maybe renders again, loops.

This ordering is the single most important fact about scheduling: microtasks block rendering; tasks don't. If you queue a heavy microtask, the paint waits for it. If you queue a heavy task (setTimeout(0), scheduler.postTask), the paint happens first and the work runs after.

Four scheduling primitives, plus the modern ones

Every scheduling decision is "which queue do I put this on?" The practical set:

  • Synchronous (just run it) — blocks everything until done. Fine for cheap work; a disaster for anything over a millisecond or two.
  • queueMicrotask(fn) — runs after current sync code, before rendering. Fine for small cleanup; bad for anything heavy because it blocks the next paint.
  • Promise.resolve().then(fn) — same queue as queueMicrotask. Awaited continuations land here.
  • requestAnimationFrame(fn) — runs just before the next paint. Right for anything that updates DOM in response to an animation frame. Wrong for work that isn't visually tied to the frame.
  • setTimeout(fn, 0) — runs as a macrotask, after the current frame renders. The classic "defer until after paint" pattern.
  • requestIdleCallback(fn) — runs when the main thread is idle, with a deadline object. Older primitive; behaviour varies across browsers; use scheduler.postTask when you can.
  • scheduler.postTask(fn, { priority }) — modern primitive with 'user-blocking', 'user-visible', 'background' priorities. The browser schedules against its own priorities.
  • scheduler.yield() — native primitive for "pause here, let the browser do its thing, continue when it's a good moment." The modern replacement for await new Promise(r => setTimeout(r, 0)) as a yield point.

The big-picture mental model

Three rules that cover most scheduling decisions:

  • If the work updates what the user sees in the current moment (reading layout, applying a style, running an animation step), put it in a requestAnimationFrame callback.
  • If the work can wait until after the user sees the result (an expensive calculation, a non-urgent analytics ping, lazy data fetching), put it in a task (scheduler.postTask({ priority: 'background' }) or setTimeout(0)).
  • If the work needs to happen now but shouldn't block the current batch of updates (reactive library internals, a hook's cleanup bookkeeping), a microtask is fine.

Microtasks are where most new JS developers overuse the primitive. "Promise.resolve().then(doHeavyThing)" feels like deferring work, but it isn't — it runs before the next paint. If the user is waiting for the paint, that's a frame of latency you've invented for no reason.

Time slicing: the real algorithm

The algorithmic pattern that uses this hierarchy well is time slicing: taking a long-running piece of CPU-bound work and breaking it into chunks small enough to fit inside the browser's frame budget (~5ms is a safe target on a 60Hz display), yielding to the browser between chunks.

async function processLargeArray(items) {
  const CHUNK_MS = 5;
  let start = performance.now();
 
  for (let i = 0; i < items.length; i++) {
    doExpensiveWork(items[i]);
 
    if (performance.now() - start >= CHUNK_MS) {
      // Hand the main thread back. Scheduler.yield when available; fall back to setTimeout(0).
      if ("scheduler" in window && "yield" in window.scheduler) {
        await window.scheduler.yield();
      } else {
        await new Promise((r) => setTimeout(r, 0));
      }
      start = performance.now();
    }
  }
}

The total CPU time hasn't changed. What's changed is that the browser gets a chance to handle input and paint in between chunks, so the UI stays responsive. Scrolling continues; a button click registers; a frame actually gets drawn.

This is the shape underneath:

  • React's concurrent rendering (startTransition, Suspense). React yields after each unit of rendering work and resumes when the browser gives it time back.
  • List virtualization (covered in the next lesson). Rendering a million-row list in one go is an obvious failure; rendering only what's on-screen is the virtualization optimisation on top.
  • Progressive hydration. Hydrating one island at a time rather than the whole page, yielding between islands, keeps the page interactive for real users before every last component is ready.
  • Progressive loading of a large dataset. The parse-a-10MB- CSV example above.

All four are the same idea: make long work look short by interleaving it with the browser's own work.

The one measurement that matters

The useful metric for scheduling decisions isn't "how long does the work take?" — it's "how long is the longest contiguous block of main-thread time my code holds?"

The browser considers any main-thread block over 50ms a long task. A page with lots of long tasks has a bad Interaction to Next Paint (INP, covered in the observability lesson) because input handlers can't fire until the long task finishes.

Measure it in DevTools' Performance tab or with the PerformanceObserver API:

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 50) {
      console.warn("long task:", entry.duration, entry.name);
    }
  }
});
 
observer.observe({ type: "longtask", buffered: true });

If something is logging here, it's a candidate for time slicing or for moving to a Worker (the "take it off the main thread entirely" escape hatch the web-workers lesson covered).

When not to reach for a scheduler

Scheduling primitives are tools, not a free performance pass:

  • Don't fragment cheap work. Scheduling overhead is real. A loop over 1000 items doing light work in a few milliseconds total doesn't need slicing; just run it.
  • Don't over-defer. Pushing work to requestIdleCallback means it might not run for seconds, or ever. Only use it for work that's genuinely optional.
  • Don't over-prioritise. Marking everything 'user-blocking' defeats the point; the browser needs to see your work's priority to triage it.
  • Reach for a Worker when the work is CPU-bound and large enough. Image processing, heavy parsing, big algorithms — the scheduler lets you interleave with the main thread, but a Worker lets the main thread not pay for the work at all.

What to remember

  • The event loop runs in an ordered hierarchy: current task → drain microtasks → maybe render (rAF, paint) → next task. Microtasks block the next paint; tasks don't.
  • Scheduling primitives, in rough order: synchronous (cheap), microtask (queueMicrotask, Promise.then), rAF (animation frame), setTimeout(0) / scheduler.postTask (next task), scheduler.yield() (modern yield point), requestIdleCallback (older idle hook).
  • The scheduling decision is "which queue does this work belong on?" — the same shape of question as which data structure to use.
  • Time slicing is the core algorithmic pattern: split long CPU-bound work into chunks (~5ms each), yield to the browser between chunks. The total work is unchanged; the UI stays responsive. React's concurrent rendering, virtualization, and progressive hydration are all variants.
  • The metric that matters is longest contiguous main-thread block. Over 50ms is a "long task" and the browser flags it. Measure with PerformanceObserver({ type: 'longtask' }).
  • Don't fragment cheap work, don't over-defer, don't over-prioritise. Workers are the escape hatch when the work is CPU-bound and large enough to justify going off the main thread entirely.

Check yourself

4 questions · pass 3/4 to unlock Virtualizing a List as an Algorithms Problem

up to 50
  1. 1.In what order do these four jobs run, assuming each is scheduled at roughly the same instant from a synchronous click handler: queueMicrotask(A), setTimeout(B, 0), requestAnimationFrame(C), Promise.resolve().then(D)?

  2. 2.A component click-handler needs to update state, let the browser paint the result, *then* run an expensive 50ms calculation. Which scheduling tool is the right one and why?

  3. 3.The classic 'yield to the browser' pattern for breaking up a 200ms chunk of work (to avoid dropped frames) looks like while (chunk) { await new Promise(r => setTimeout(r, 0)); }. What does the modern Scheduler API offer instead, and why is it better?

  4. 4.Which of these is a genuine *algorithmic* use of the scheduling hierarchy — not just 'wrap it in a timer'?

4 left to answer

Discussion

Sign in to post
Sign in to join the conversation.Sign in

No comments yet. Be the first to say something.