Starvation, jank, and scheduling: keeping the loop responsive
Run-to-completion means one long task can block every render and every click until it finishes, so this closing lesson is the practical toolkit for chunking work, yielding correctly, and using the browser's schedulers to keep the loop responsive — and the point where every idea from this whole module ties together.
Starvation, jank, and scheduling: keeping the loop responsive
Everything in this module comes back to one sentence from lesson 1: a task runs to completion, and nothing else — no other task, no rendering, no input handling — happens until it does. That's what makes the event loop predictable. It's also exactly what makes a single slow function dangerous: if it runs long enough, it starves everything else waiting behind it. This lesson is about what "long enough" means, why the obvious fixes half-work, and what actually keeps a main thread responsive when there is real work that has to happen on it.
The problem, restated precisely
A long task is the industry's own name for this: any task that occupies the main thread for more than 50 milliseconds. Cross that line and you're not just slow — the browser can't render a frame, can't run a queued message or click handler, can't do anything else, for however much longer the task keeps running. The user experience of this is called jank: a frozen page that suddenly jumps, a click that seems to do nothing for half a second and then fires late.
The metric that measures exactly this is INP — Interaction to Next Paint — the time from a user's interaction (a click, a keypress) to the frame that visibly reflects it. INP is bad precisely when the main thread's stack is occupied by something else at the moment the interaction's task gets queued: the click handler has to wait in line behind whatever long task got there first, and the user watches that wait. Every strategy in this lesson exists to keep that wait short.
Strategy 1: break work into chunks and yield — but yield the right way
The obvious fix for a long loop is to stop doing it all in one task: split the work into pieces and let the loop run something else between pieces. But how you hand control back matters enormously, and this is the single most commonly-missed point in this whole area.
// Wrong: yields to the microtask queue, which does NOT let the browser render.
async function processAllWrong(items) {
for (const item of items) {
doWork(item);
await Promise.resolve(); // resolves immediately — a microtask, not a task
}
}Recall lesson 4: the microtask queue is fully drained before the loop is even allowed to consider rendering. Awaiting an already-resolved promise queues a microtask, and that microtask runs at the very next checkpoint — which is still before any frame gets a chance to paint. The loop between iterations never actually reaches the render step. From the outside this looks exactly as blocking as the original synchronous loop, just with extra ceremony.
// Right: yields to a macrotask, which lets rendering and input happen in between.
function yieldToMain() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function processAllCorrect(items) {
for (const item of items) {
doWork(item);
await yieldToMain(); // a real task boundary — the loop can render here
}
}setTimeout enqueues a genuine task, not a microtask. Returning control to the event loop at a task boundary means the current task actually ends — the stack empties, microtasks drain, and only then, per the rendering lesson, does the browser get a chance to run pending input handlers and produce a frame, before the next chunk's task begins. The difference between these two versions is entirely about which queue you yield to — and it's invisible in a code review unless you know to look for it.
Strategy 2: the Scheduler API
Yielding via setTimeout works but is a blunt instrument — it has no concept of priority and is subject to browser timer clamping. The Scheduler API is the purpose-built alternative:
// Run work with an explicit priority.
scheduler.postTask(() => renderChart(data), { priority: 'user-visible' });
// Inside a long-running function, yield mid-task and resume
// at a chosen priority instead of running to completion.
async function processLargeList(items) {
for (const item of items) {
doWork(item);
if (navigator.scheduling.isInputPending()) {
await scheduler.yield(); // hand control back now, resume after
}
}
}scheduler.postTask(fn, { priority }) schedules fn as a task with one of three priorities: user-blocking (things like responding to a click, run ahead of everything else), user-visible (the default — visible but not blocking an interaction), and background (prefetching, analytics, anything that can wait). scheduler.yield() is the modern replacement for the setTimeout trick above: it yields to the loop the same way, but the continuation is resumed as a prioritized task instead of an ordinary timer callback. navigator.scheduling.isInputPending() lets a long-running loop check, cheaply, whether the user has produced input that's waiting to be handled — so you can choose to yield only when it would actually help, rather than yielding on a fixed schedule regardless of whether anything is waiting.
Strategy 3: requestIdleCallback for work that can wait
For low-priority work — analytics batching, warming a cache, precomputing something nobody's looking at yet — requestIdleCallback schedules a callback to run only during idle periods between frames, after rendering and higher-priority tasks are done:
requestIdleCallback((deadline) => {
while (deadline.timeRemaining() > 0 && tasksLeft.length) {
doLowPriorityWork(tasksLeft.pop());
}
});The deadline argument tells you how much idle time is actually left in this slot, so the callback can keep working until the browser needs the thread back for something more urgent, then stop cleanly rather than overrunning into the next frame.
Strategy 4: the cleanest fix — get off the main thread entirely
Chunking and scheduling both accept the same constraint: the work still runs on the main thread, just in smaller, better-timed pieces. For genuinely heavy computation, the better answer is often lesson 7: move it to a Web Worker instead. A worker has its own thread, its own stack, its own event loop — nothing it does can block the main thread's rendering or input handling at all, because it isn't competing for the same thread in the first place. Chunking is for work that must stay on the main thread (it touches the DOM, or the cost of coordinating with a worker outweighs the benefit); a worker is for work that doesn't need to.
Synthesis: the whole module, one thread at a time
Every lesson in this module has been building toward this picture:
- One thread runs everything, one task at a time, forever.
- The call stack is where a single task actually executes, and its emptying is the loop's one trigger to move on.
- Tasks and the task queue hold the pending entry points — timers, events, I/O completions — waiting for their turn.
- Microtasks are a higher-priority queue, fully drained at a checkpoint after every task, before the loop moves on.
- Rendering is slotted into the loop at a specific point too — after the checkpoint, before the next task — which is exactly why a microtask-only yield can't let a frame through.
- Promises and async/await are ergonomics on top of the microtask queue: a settle schedules a microtask, and
awaitsplits a function into microtask-scheduled continuations. - Web Workers are the one real escape — a second thread with its own complete loop, reachable only through copied messages.
- And this lesson is the toolkit for the cases where the work has to stay on the one thread anyway: chunk it, yield it correctly, prioritize it, or move it off-thread if you can.
The same fight, everywhere in this curriculum
Keeping a main thread's event loop free is not a JavaScript-specific concern — it's the same fight fought in every corner of this site, in different clothes. Animation's frame budget is this exact deadline stated in milliseconds. The browser's compositor thread exists so certain animations keep hitting that deadline even when the main thread is busy — the same "give it its own thread" move as a Web Worker. Canvas rendering can be pushed onto an OffscreenCanvas inside a worker for the same reason. WebAssembly run inside a worker keeps heavy compute off the main thread entirely. Web Audio's own dedicated render thread, and the AudioWorklet that runs custom DSP on it, exist because audio glitches the instant it has to wait on whatever the main thread happens to be doing. Every one of these is a specific answer to the same imperative this module has been building toward from its very first sentence: one thread, one task at a time — so protect that thread's time like the scarce resource it is.
Where the curriculum goes from here
This closes out the event loop module, but the model you now have — one thread, a stack, task and microtask queues, a loop that never stops asking "what's next" — is the substrate every other track on this site assumes. Carry it into the animation, canvas, WebAssembly, and Web Audio tracks, and you'll keep finding the same shape underneath: something has to happen fast, on a deadline, without blocking anything else, and the fix is always some variation on what these eight lessons just built from scratch.
Go deeper
- web.dev — Interaction to Next Paint (INP) — The metric this lesson defines, including how it's measured and the thresholds Chrome's own team uses to grade responsiveness.
- MDN — Prioritized Task Scheduling API — The full scheduler.postTask / scheduler.yield contract, priorities, and browser support this lesson's examples rely on.
- MDN — requestIdleCallback — The idle-callback deadline object and timeRemaining() this lesson's low-priority example uses.
- web.dev — Optimize long tasks — The 50ms long-task threshold and chunking/yielding strategies from the browser performance team's own guidance.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- What is the industry definition of a 'long task,' and what does INP actually measure?
- Why does awaiting an already-resolved promise fail to let the browser render a frame between loop iterations, while awaiting a setTimeout succeeds?
- What are the three priority levels scheduler.postTask supports, and what is scheduler.yield() for?
- What does navigator.scheduling.isInputPending() let a long-running loop do that a fixed yield schedule cannot?
- When is moving work to a Web Worker a better fix than chunking it on the main thread, and when is chunking the only option?
- Restate, in one sentence per lesson, how each of the eight lessons in this module builds on the one before it.
- Name two other tracks on this site (outside the event loop module) that solve the exact same 'keep the main thread free' problem, and how each does it.