When âLaterâ Keeps Getting Delayed: Understanding Macrotask Starvation in JavaScript
Macrotask starvation occurs when macrotasks like timers or events are delayed indefinitely because higher-priority workâespecially continuous microtasks or long-running executionâprevents the event loop from reaching the macrotask queue.
Thereâs a quiet promise behind something like setTimeout.
You schedule it.
And you expect it to run⌠soon.
Not immediatelyâbut eventually.
And most of the time, it does.
Until it doesnât.
The Expectation of âLaterâ
When you write:
setTimeout(() => {
console.log("Hello")
}, 0)It feels like youâre saying:
âRun this right after everything else.â
But thatâs not how JavaScript understands it.
Instead, it places this callback into a queue.
A queue that waits.
A queue that depends on timing, priority, and the state of the system.
The Place Where It Waits
Macrotasks live in their own space.
Timers.
User events.
Network callbacks.
They donât run immediately.
They wait for the event loop to reach them.
And that only happens after two things:
- the current execution finishes
- all microtasks are processed
Only then does the next macrotask get a chance.
When That Chance Never Comes
Macrotask starvation happens when this moment is continuously delayed.
Not because the macrotask is missing.
But because something else always goes first.
The Usual Suspect
Microtasks.
They run before macrotasks.
And more importantlyâ
they must finish completely before the system moves on.
If microtasks keep arriving:
function loop() {
Promise.resolve().then(loop)
}
loop()Then the system never leaves the microtask phase.
And the macrotask queue is never touched.
Your timer is still there.
Waiting.
But it never runs.
A More Subtle Case
Even without infinite loops, starvation can still appear.
If microtasks are constantly scheduled in burstsâ
each cycle filled with more immediate workâ
macrotasks can be delayed far longer than expected.
Not forever.
But long enough to feel broken.
The Illusion of Delay
What makes macrotask starvation confusing is that nothing is technically wrong.
The timer was scheduled correctly.
The delay has passed.
The callback is ready.
But readiness is not execution.
Execution depends on opportunity.
And opportunity depends on the event loop progressing.
Not Just About Microtasks
Heavy synchronous work can also contribute.
If each task takes too longâ
or tasks are chained continuouslyâ
the system spends too much time handling current work,
and not enough time moving forward.
Macrotasks remain in the background.
Waiting for a gap that never appears.
A Question of Timing
JavaScript does not guarantee when a macrotask will run.
It guarantees only that:
It will run when the system reaches it
And if something prevents thatâ
the delay grows.
Silently.
A Subtle Lesson
Macrotask starvation reveals something important:
Scheduling is not execution.
You can ask for something to run.
But you cannot force the system to choose it next.
That choice belongs to the event loop.
Letting Tasks Breathe
The solution is not to avoid macrotasks.
But to avoid overwhelming the system before it reaches them.
Be mindful of microtask chains.
Avoid endless scheduling.
Allow the loop to complete its cycle.
Because every task needs its turn.
A Final Thought
Macrotasks represent the outside world entering your application.
User input.
Timers.
Responses.
And when they are delayed, the system feels unresponsive.
Not because it is brokenâ
but because it is too busy to listen.
And sometimes, performance is not about doing things fasterâ
but about making sure everything gets a chance to run.