Starvation of Callbacks
When Microtasks Hog the Event Loop
LinkedIn Hook
The fact that microtasks beat macrotasks is a feature — until it becomes a bug.
Here is code that freezes an entire browser tab:
function starve() { Promise.resolve().then(() => { starve(); }); } setTimeout(() => console.log("I will NEVER run"), 0); starve();That
setTimeoutcallback never executes. Not in 1 second. Not in 10 minutes. Never. Because every microtask schedules another microtask, the Microtask Queue is never empty, and the Event Loop never reaches the Callback Queue. Rendering, input, timers — all starve.This is the dark side of the priority model you learned in Lesson 3.4, and it shows up in real code: recursive
Promiseprocessing, chattyMutationObservercallbacks, runawayqueueMicrotaskloops.In this lesson you'll see how starvation happens, why it freezes the UI, and two proven ways to fix it — yielding with
setTimeoutand batching withawait.Read the full lesson -> [link]
#JavaScript #EventLoop #Starvation #Performance #InterviewPrep #AsyncJS #WebPerformance
What You'll Learn
- Why an infinite chain of microtasks freezes the browser tab
- Two patterns for yielding control back to macrotasks safely
- How the microtask/macrotask priority rule plays out in real code
The Priority Problem
Starvation happens when microtasks keep generating more microtasks, preventing the Event Loop from ever reaching the Callback Queue. Since the Event Loop drains ALL microtasks before picking a macrotask, an infinite chain of microtasks will starve macrotasks forever.
Think of it like an emergency room where ambulances keep arriving non-stop. Regular patients (macrotasks) in the waiting room never get seen because the ER prioritizes emergencies. If ambulances keep calling more ambulances, regular patients starve.
The Starvation Problem
// WARNING: This will freeze your browser!
// DO NOT RUN THIS
function starve() {
Promise.resolve().then(() => {
console.log("Microtask");
starve(); // adds another microtask -> infinite loop
});
}
setTimeout(() => {
console.log("I will NEVER execute");
}, 0);
starve();
What happens:
setTimeoutcallback -> Callback Queuestarve()creates a resolved Promise ->.thencallback enters Microtask Queue- Event Loop drains Microtask Queue -> runs the callback -> it calls
starve()again -> adds another microtask - The Microtask Queue is never empty -> Event Loop never gets to the Callback Queue
"I will NEVER execute"is starved — it never prints- The browser tab becomes unresponsive
Real-World Starvation Scenario
// A more realistic scenario: recursive promise processing
function processItems(items) {
if (items.length === 0) return Promise.resolve();
return Promise.resolve().then(() => {
console.log(`Processing: ${items[0]}`);
return processItems(items.slice(1)); // recursive microtask
});
}
setTimeout(() => {
console.log("UI Update — Waiting to run...");
}, 0);
// If this array is very large, the setTimeout callback is starved
processItems(["a", "b", "c", "d", "e"]);
Output:
Processing: a
Processing: b
Processing: c
Processing: d
Processing: e
UI Update — Waiting to run...
With only 5 items, starvation is brief. But imagine 100,000 items — the UI would freeze because the setTimeout (and any rendering) can't happen until the microtask chain completes.
How to Prevent Starvation
Solution 1: Use setTimeout to yield to the macrotask queue
function processItemsSafe(items, index = 0) {
if (index >= items.length) return;
console.log(`Processing: ${items[index]}`);
// Use setTimeout to give macrotasks a chance to run
setTimeout(() => {
processItemsSafe(items, index + 1);
}, 0);
}
setTimeout(() => {
console.log("UI Update — I get a chance to run between items!");
}, 0);
processItemsSafe(["a", "b", "c", "d", "e"]);
Output:
Processing: a
UI Update — I get a chance to run between items!
Processing: b
Processing: c
Processing: d
Processing: e
By using setTimeout instead of Promise.resolve(), each processing step becomes a macrotask. The Event Loop can interleave other macrotasks (like UI updates) between them.
Solution 2: Batch processing with periodic yielding
async function processBatched(items, batchSize = 100) {
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
// Process batch synchronously
batch.forEach(item => {
/* process item */
});
// Yield to event loop after each batch
await new Promise(resolve => setTimeout(resolve, 0));
console.log(`Processed batch ${i / batchSize + 1}`);
}
}
This processes items in chunks, yielding control after each batch so the Event Loop can handle UI updates, timers, and other macrotasks.
Microtask Queue vs Callback Queue — The Complete Picture
// This demonstrates the priority difference clearly
console.log("=== Script Start ===");
// Macrotask 1
setTimeout(() => {
console.log("Timeout 1");
// Microtask inside Macrotask
Promise.resolve().then(() => {
console.log("Promise inside Timeout 1");
});
}, 0);
// Macrotask 2
setTimeout(() => {
console.log("Timeout 2");
}, 0);
// Microtask 1
Promise.resolve().then(() => {
console.log("Promise 1");
});
// Microtask 2
Promise.resolve().then(() => {
console.log("Promise 2");
});
console.log("=== Script End ===");
Output:
=== Script Start ===
=== Script End ===
Promise 1
Promise 2
Timeout 1
Promise inside Timeout 1
Timeout 2
Key insight: After "Timeout 1" executes, the Event Loop checks the Microtask Queue before going to the next macrotask. So "Promise inside Timeout 1" runs before "Timeout 2."
Common Mistakes
- Using recursive
Promise.resolve().then(...)for long-running loops. It feels "non-blocking" but it actually starves rendering and timers. - Assuming
queueMicrotaskis free. Chained microtasks are just as capable of starving the UI as chained.thencalls. - Fixing starvation with smaller
setIntervaldelays instead of yielding viasetTimeout(fn, 0). The queue priority rule is the problem, not the delay value.
Interview Questions
Q: What is starvation of callback functions?
Starvation occurs when microtasks continuously generate more microtasks, preventing the Event Loop from ever reaching the Callback Queue. Since the Event Loop must drain all microtasks before picking a macrotask, an infinite microtask chain will block macrotasks (setTimeout, DOM events, UI rendering) indefinitely.
Q: How can you prevent microtask starvation?
- Avoid recursive Promise chains that create unbounded microtasks. 2. Use
setTimeoutto break work into macrotasks, allowing the Event Loop to interleave other tasks. 3. Use batched processing — process items in chunks and yield control between batches usingsetTimeoutorrequestAnimationFrame.
Q: Can microtask starvation freeze the browser?
Yes. Since microtasks run before rendering and before any macrotasks, an infinite microtask loop will prevent the browser from updating the UI, handling user input, or executing any timers. The tab becomes completely unresponsive.
Q: Can a microtask add more microtasks? What happens if it does infinitely?
Yes — microtasks can enqueue more microtasks, and the Event Loop will keep draining them all before returning to macrotasks. An infinite chain starves every macrotask (timers, DOM events, rendering) and freezes the tab.
Q: Predict the output: console.log(1); setTimeout(() => console.log(2), 0); Promise.resolve().then(() => console.log(3)); console.log(4);
1, 4, 3, 2. Synchronous1and4print first. The Promise microtask3drains before thesetTimeoutmacrotask2.
Q: Interview Rapid-Fire — How many microtasks drain per tick vs how many macrotasks?
All microtasks drain per tick (until the queue is truly empty, including newly enqueued ones). Exactly one macrotask runs per tick. That asymmetry is the direct cause of starvation.
Quick Reference — Cheat Sheet
STARVATION — QUICK MAP
Cause:
microtask schedules another microtask
-> Microtask Queue never empties
-> Event Loop never reaches Callback Queue
-> setTimeout / DOM events / render all starve
Symptoms:
UI freezes
clicks/scroll don't respond
setTimeout callbacks never fire
tab eventually flagged "unresponsive"
Fixes:
1) yield with setTimeout(fn, 0)
(each step becomes a macrotask)
2) batch + await new Promise(r => setTimeout(r, 0))
(process N items, yield, repeat)
3) for animation-heavy work, use requestAnimationFrame
Rule of thumb:
loops over big arrays via Promise.then -> DANGER
loops over big arrays via setTimeout(0) -> SAFE (slow but fair)
Previous: Microtask Queue -> The Priority Lane Next: Callbacks & Callback Hell -> Taming Nested Async
This is Lesson 3.5 of the JavaScript Interview Prep Course — 14 chapters, 87 lessons.