JavaScript Interview Prep
Asynchronous JavaScript

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 setTimeout callback 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 Promise processing, chatty MutationObserver callbacks, runaway queueMicrotask loops.

In this lesson you'll see how starvation happens, why it freezes the UI, and two proven ways to fix it — yielding with setTimeout and batching with await.

Read the full lesson -> [link]

#JavaScript #EventLoop #Starvation #Performance #InterviewPrep #AsyncJS #WebPerformance


Starvation of Callbacks thumbnail


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:

  1. setTimeout callback -> Callback Queue
  2. starve() creates a resolved Promise -> .then callback enters Microtask Queue
  3. Event Loop drains Microtask Queue -> runs the callback -> it calls starve() again -> adds another microtask
  4. The Microtask Queue is never empty -> Event Loop never gets to the Callback Queue
  5. "I will NEVER execute" is starved — it never prints
  6. 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."

Starvation of Callbacks visual 1


Common Mistakes

  • Using recursive Promise.resolve().then(...) for long-running loops. It feels "non-blocking" but it actually starves rendering and timers.
  • Assuming queueMicrotask is free. Chained microtasks are just as capable of starving the UI as chained .then calls.
  • Fixing starvation with smaller setInterval delays instead of yielding via setTimeout(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?

  1. Avoid recursive Promise chains that create unbounded microtasks. 2. Use setTimeout to 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 using setTimeout or requestAnimationFrame.

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. Synchronous 1 and 4 print first. The Promise microtask 3 drains before the setTimeout macrotask 2.

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.

On this page