8 Scheduling
This chapter explains why asynchronous JavaScript can feel unpredictable even when the code itself is correct. It introduces two distinct problems: Zalgo, where a callback sometimes runs immediately and sometimes later, and a second, unnamed source of chaos caused by different runtimes making different scheduling choices. The key lesson is that asynchronous behavior is not just about “later,” but about which execution context and queue a callback enters.
The chapter then builds a precise model of scheduling around call stacks, promise reactions, jobs, macrotasks, and microtasks. Promises and await are shown to avoid Zalgo by always deferring continuation into a new stack, while their internal reactions are placed on the microtask queue. It also explains how microtasks differ from timers and other runtime-specific queues, why the language specification defines only part of the behavior, and how runtimes such as Node.js, browsers, Deno, Bun, and Workers fill in the rest in their own ways.
Finally, the chapter focuses on practical consequences: microtasks drain until empty, which can starve timers, I/O, and rendering if overused; different environments may order process.nextTick, promises, timers, and immediates differently; and subtle bugs arise when asynchronous code assumes a specific order without making it explicit. The recommended approach is to use the right queue for the right job, avoid unnecessary promise chains for synchronous work, yield deliberately when doing CPU-heavy processing, and rely only on ordering guarantees you actually understand and can count on across runtimes.
The call stack during forEach. The stack grows and shrinks as callbacks execute, but processItems remains on the stack throughout. All callbacks share the same stack context.
The call stack with setTimeout. The stack empties completely before any deferred callbacks run. Each callback executes in a separate stack; processItems is long gone.
A promise holds three internal slots: its current state, its result value, and a list of reactions registered by .then(). Each reaction pairs a callback with the next promise in the chain. When the promise resolves, its reaction callbacks are enqueued as microtasks and drain once the call stack empties.
A visualization of the execution flow. Steps (a), (b), and (c) execute in order as a single task. Once that task is complete, step (d) executes, then finally step (e).
Summary
- Zalgo describes APIs that invoke callbacks sometimes synchronously and sometimes asynchronously. Promises eliminate Zalgo by guaranteeing that the consuming side always runs in a separate call stack. Isfet, a term coined in this book, describes what happens when the specification leaves scheduling details undefined and every runtime fills the gap with its own rules.
- Asynchronous describes where a callback executes, not when. Synchronous callbacks share the caller's stack; asynchronous callbacks run in a completely separate stack after the current one empties.
- A promise is a state machine with three internal slots: [[PromiseState]], [[PromiseResult]], and [[PromiseReactions]]. When a promise settles, its reactions are enqueued as microtasks. The await keyword is mechanically equivalent to .then() and carries the same scheduling costs.
- The ECMAScript specification defines a synchronous language. The job queue for promise reactions, added in ES6, is the only scheduling mechanism the specification has ever defined. Every other queue was invented by platforms and runtimes independently.
- The basic execution cycle is: run one macrotask, drain all microtasks, run the next macrotask. Once draining begins, every microtask runs to completion before anything else happens, including microtasks enqueued by other microtasks. This can cause starvation if the queue never empties.
- Chaining synchronous work through promises creates real performance costs that compound across thousands of concurrent requests. If the work is synchronous, do it synchronously.
- Each runtime adds its own queues. Node.js drains process.nextTick before promise microtasks. Browsers provide requestAnimationFrame and requestIdleCallback. Deno and Bun implement nextTick and setImmediate for compatibility but do not always match Node.js ordering. The same code produces different results across runtimes because each made different implementation choices.
- Context determines order. Running the same Node.js file as CommonJS versus an ECMAScript module can invert callback ordering because the script itself runs as a different type of task.
- To tame cross-runtime differences, make dependencies between operations explicit using await or promise chaining. Use each scheduling API for its intended purpose rather than as a generic deferral mechanism.
- When mixing callback-based APIs with promises, timing mismatches cause subtle bugs. Stream end events, unhandled rejection detection, and queue checkpoint frequency all vary by runtime in ways that surface only when code moves to a new environment.
FAQ
What is Zalgo in JavaScript asynchronous code?
Zalgo refers to an API that sometimes invokes a callback immediately and other times defers it, making behavior unpredictable. This breaks reasoning about execution order because the same code can run in different call stacks depending on runtime state, such as whether data is cached.
Why are promises considered a defense against Zalgo?
Promises guarantee that reactions attached with .then() or await always run in a future call stack, even if the value is already available. That consistent deferral removes the “sometimes sync, sometimes async” behavior that causes Zalgo.
What does it mean when JavaScript code is “asynchronous”?
In this chapter, asynchronous means work does not run in the current execution stack. Instead, it runs in a new stack later, after the current one completes. The key idea is stack separation, not merely “happening later in time.”
What is the difference between synchronous callbacks and asynchronous callbacks?
Synchronous callbacks run inside the caller’s current call stack and share its execution context. Asynchronous callbacks run after the stack has emptied, in a fresh call stack, so they do not share the caller’s try/catch, this, or other stack-based context.
What are promise reactions and how are they scheduled?
When you call .then(), the callback is stored as a reaction record. When the promise settles, those reactions are not run immediately; they are enqueued as microtasks and executed after the current macrotask finishes and the call stack becomes empty.
Why do promises run before timers like setTimeout?
Promise reactions go into the microtask queue, and microtasks are drained before the runtime proceeds to the next macrotask such as a timer. So in code like setTimeout(..., 0) plus Promise.resolve().then(...), the promise callback runs first.
What is the “drain until empty” rule for microtasks?
Once a runtime starts draining microtasks, it keeps running them until the microtask queue is empty. If a microtask schedules another microtask, that new one runs too before the runtime moves on to any other queue.
How can microtasks cause starvation?
If code keeps adding microtasks faster than they are exhausted, other queues never get a chance to run. This can block timers, rendering, and I/O, making the page or server appear frozen even though each individual microtask is “properly asynchronous.”
Why do scheduling APIs behave differently across runtimes?
The JavaScript language specification defines promises and microtasks, but most other scheduling APIs are provided by the host runtime. Node.js, browsers, Deno, Bun, and Workers each implement their own queue architecture, so APIs like process.nextTick, setImmediate, and timers can produce different ordering.
How should you write async code that depends on order?
Make ordering explicit instead of hoping the runtime does what you expect. Use await or direct sequencing when operations must happen in order, use Promise.all when order does not matter, and choose the right queue only when you need a specific guarantee.
JavaScript in Depth ebook for free