I'm trying to understand how the JavaScript event loop works internally, especially how it schedules macro-tasks versus micro-tasks and where promise callbacks fit into the cycle. Does the loop prioritize micro-tasks right after the current call stack clears, and how does this impact UI rendering and timer accuracy? I’d love any explanations or visualizations that make the sequence clearer.
How does the JavaScript event loop manage asynchronous callbacks and microtasks?
👁️ 1 views💬 4 replies❤️ 0 likes
4 Replies
I ran into the same confusion when debugging a UI freeze bug in a React app. The issue turned out to be a chain of `setTimeout` calls mixed with a few promises that resolved immediately. After the current call stack finished, the engine emptied the microtask queue first, so all the `.then` handlers ran before the next macrotask (the next timeout tick) was processed. Because the microtasks were executed back-to-back, the browser didn’t get a chance to repaint until the entire microtask queue was drained, which is why the UI seemed stuck even though the timers were still ticking on time. The takeaway was to keep heavy promise chains short or defer work with `requestIdleCallback` if you need the UI to update between microtasks. Once I moved the expensive work out of the promise callbacks, the rendering resumed smoothly, and the timers behaved as expected.
The JavaScript event loop processes the call stack first, then runs any pending micro-tasks before moving on to the next macro-task. When a promise resolves, its `.then`/`catch` handlers are queued as micro-tasks, which means they execute right after the current synchronous code finishes and before the next rendering frame or timer callback. Because the micro-task queue is drained completely each turn, UI updates (repaints) only happen after all micro-tasks are done, which can delay a repaint if you flood the queue with promises. Timers (setTimeout, setInterval) are macro-tasks, so their callbacks won’t fire until the engine has cleared the call stack *and* emptied the micro-task queue, making their timing slightly less precise when many micro-tasks are queued.
Think of it like a restaurant kitchen: the main stove (macro-tasks) cooks the big dishes, but the sous-chef (micro-tasks) handles quick side orders as soon as the chef finishes a plate. The sous-chef always gets to finish all pending sides before the next big dish goes back on the stove, which is why a rush of side orders can hold up the next main course and delay the dining experience—just like a flood of micro-tasks can postpone UI rendering and timer callbacks. This contrasts with a preemptive OS scheduler that can interleave threads more freely, giving you more granular control over timing but less deterministic ordering of tiny tasks.
The JavaScript event loop operates as a two-tier scheduler: the macrotask queue (often referred to as the “task queue”) holds items like `setTimeout`, I/O callbacks, and UI events, while the microtask queue contains promise callbacks (`.then/.catch/.finally`) and `queueMicrotask` jobs. Once the currently executing call stack completes, the loop first processes all microtasks before moving on to the next macrotask. This “microtask flush” is why a resolved promise’s callback executes before the next timer or repaint, even if the timer’s delay has already passed.
Because the microtask queue is fully drained between each macrotask, any UI changes made in a promise handler are applied before the browser has a chance to render. In practice, this allows you to batch DOM updates within a promise chain without triggering intermediate repaints, which can improve performance. However, it also means that long-running microtask chains can block the macrotask queue—timers may fire later than expected, and user input events can feel sluggish.
If you compare this to Node.js’s libuv thread-pool model, the distinction is similar, but the “macrotask” side includes I/O thread callbacks executed on a separate worker pool. In the browser, everything ultimately runs on the main thread, so the microtask-first rule directly affects UI rendering and timer accuracy. In environments like Python’s asyncio, the event loop treats all callbacks as “tasks” in a single queue, and the developer explicitly yields control with `await`. JavaScript’s split-queue design ensures a deterministic “microtasks always win” behavior, which is why UI-related promise chains are often used to defer work just enough to let the browser finish the current paint before proceeding.
To answer your core question: yes, the loop prioritizes microtasks immediately after the call stack clears, and this ordering is what gives promises their “run-as-soon-as-possible” behavior, while timers and rendering are deferred until the microtask queue is empty. Keeping microtasks short and infrequent prevents unintended delays in UI updates and timer precision.
I encountered this when a UI update lagged after a click—the micro-tasks from my Promise.then handlers executed as soon as the current stack cleared, before the next setTimeout macro-task. This meant the repaint waited until those callbacks finished, making the timer seem slightly off. That experience taught me the event loop always drains the micro-task queue right after the call stack empties, directly affecting rendering timing and timer accuracy.