JavaScript8 min read
JavaScript Event Loop Explained: Microtasks vs Macrotasks
JavaScript event loop explained with clear examples: the call stack, task queue, microtasks vs macrotasks, promises, async/await and how rendering fits in.
The JavaScript event loop is how a single-threaded language runs asynchronous code. JavaScript executes one thing at a time on the call stack; when the stack is empty, the event loop first runs every pending microtask (promise callbacks, queueMicrotask, code after await), gives the browser a chance to render, then picks the next macrotask (a timer, a click handler, a network callback) from a task queue. That one rule explains almost every "why does this log in that order" question.
I have taught React to more than 15 students, and the event loop is the topic where things suddenly click, usually after one good example. So this post is built around examples you can paste into a browser console.
Key takeaways
- JavaScript runs on one call stack. Nothing interrupts a function while it runs.
- Macrotasks (tasks) include
setTimeout,setInterval, DOM events,MessageChanneland I/O callbacks. The loop runs one task at a time. - Microtasks include promise reactions,
awaitcontinuations,queueMicrotaskandMutationObservercallbacks. The whole microtask queue drains after each task. - Rendering happens between tasks, never in the middle of one. Long tasks freeze the page.
awaitdoes not block anything. It splits a function into pieces that resume as microtasks.
The call stack, Web APIs and queues
Four pieces make up the model:
- The call stack. Where your functions actually run. Calling a function pushes a frame; returning pops it.
- Host APIs. Timers,
fetch, DOM events and so on are provided by the browser or Node, not by the JavaScript engine. They do their work outside the stack. - The task queue (macrotasks). When a timer fires or a user clicks, the host queues a task.
- The microtask queue. When a promise settles, its
.thencallbacks are queued here.
The loop itself, simplified from the HTML spec, looks like this:
// Pseudocode, not real code
while (true) {
const task = pickOldestTask() // e.g. a timer or click callback
if (task) run(task)
while (microtaskQueue.length > 0) {
runNextMicrotask() // microtasks can queue more microtasks
}
if (timeToRender()) {
runRequestAnimationFrameCallbacks()
styleLayoutPaint()
}
}The initial run of your script counts as a task too. That detail matters for the next example.
Microtasks vs macrotasks: the classic ordering question
console.log('1: script start')
setTimeout(() => {
console.log('5: timeout')
}, 0)
Promise.resolve().then(() => {
console.log('3: promise 1')
}).then(() => {
console.log('4: promise 2')
})
console.log('2: script end')Output:
1: script start
2: script end
3: promise 1
4: promise 2
5: timeoutStep by step:
- The script is the current task. It logs
1, hands the timer to the browser, queues the first.thenas a microtask, and logs2. - The script task ends. Now the loop drains microtasks:
promise 1runs, which resolves the next promise and queuespromise 2, which also runs, because the loop keeps draining until the queue is empty. - Only then does the loop pick the next task: the timer callback.
Here is the difference in one table:
| Macrotask (task) | Microtask | |
|---|---|---|
| Examples | setTimeout, setInterval, DOM events, MessageChannel, I/O |
.then/.catch/.finally, await continuation, queueMicrotask, MutationObserver |
| How many run per loop turn | One | All of them, including ones added during draining |
| Can the browser render in between? | Yes, between tasks | No, not until the queue is empty |
| Typical use | Defer work and let the page breathe | Run something right after the current code, before anything else |
How async/await fits into the event loop
async/await is syntax over promises, so it follows the microtask rules. Everything before the first await runs synchronously. Everything after it resumes later as a microtask.
async function load() {
console.log('B: inside load, before await')
await null
console.log('D: after await')
}
console.log('A: start')
load()
console.log('C: end')Output:
A: start
B: inside load, before await
C: end
D: after awaitPeople are often surprised that B logs before C. Calling an async function starts running it immediately; it only yields at the first await. Even await null (or awaiting an already resolved promise) yields, because the continuation is always scheduled as a microtask.
A common real-world consequence:
async function save(data) {
setLoading(true)
await api.post('/items', data) // the function pauses here
setLoading(false)
}While the request is in flight, the call stack is empty and the page stays responsive. await pauses one function, not the thread.
Sequential vs parallel awaits
Because each await waits for its promise before moving on, this runs requests one after another:
const user = await getUser(id)
const posts = await getPosts(id) // starts only after getUser finishesIf the calls do not depend on each other, start them together:
const [user, posts] = await Promise.all([getUser(id), getPosts(id)])This is not an event loop trick; it is just starting both promises before awaiting. It is also one of the most common performance fixes I make in code review.
Rendering, requestAnimationFrame and long tasks
The browser can only paint between tasks. If a click handler runs a 300 ms loop, nothing repaints for 300 ms: no spinner, no hover state, no typed character. That is exactly what the Interaction to Next Paint (INP) metric measures, and I cover fixes in Core Web Vitals for React apps.
button.addEventListener('click', () => {
status.textContent = 'Working...'
heavySyncWork() // blocks for a while
status.textContent = 'Done'
})The user never sees "Working...". Both text changes happen inside one task, and the browser paints only after the task ends.
The fix is to yield, so the browser gets a chance to paint:
function yieldToMain() {
if (globalThis.scheduler?.yield) {
return scheduler.yield()
}
return new Promise((resolve) => setTimeout(resolve, 0))
}
button.addEventListener('click', async () => {
status.textContent = 'Working...'
await yieldToMain() // let the browser paint
heavySyncWork()
status.textContent = 'Done'
})scheduler.yield() is supported in Chromium-based browsers but is not Baseline yet, so feature-detect it and fall back to setTimeout. Note that the fallback must be a macrotask. Yielding with await Promise.resolve() does nothing for rendering, because the microtask runs before the browser can paint.
requestAnimationFrame callbacks run right before the browser paints a frame, which makes them the right place for visual updates such as animations or measuring then mutating the DOM.
Microtask starvation: how to freeze a page with promises
Because the loop drains the microtask queue completely, a microtask that keeps queueing more microtasks never lets the loop move on:
function forever() {
queueMicrotask(forever)
}
forever() // the tab hangs: no timers, no clicks, no paintThe same loop with setTimeout would not freeze the page, because each callback is a separate task and the browser can render and handle input between them. You rarely write this on purpose, but recursive promise chains that process a huge array can behave the same way. Chunk big jobs across tasks instead.
The Node.js event loop is a bit different
Node.js uses libuv and organizes the loop into phases. The main ones in order:
| Phase | What runs |
|---|---|
| timers | setTimeout and setInterval callbacks whose time has passed |
| pending callbacks | Some deferred system callbacks |
| poll | New I/O events, most I/O callbacks |
| check | setImmediate callbacks |
| close callbacks | e.g. socket.on('close') |
One version note: the Node docs say that since Node.js 20 (libuv 1.45.0), timers run only after the poll phase, which changed some timer and setImmediate interactions compared with older guides. Microtasks still drain between callbacks. Node also has process.nextTick, which has its own queue that runs before promise microtasks:
// CommonJS script run with node
setTimeout(() => console.log('timeout'), 0)
setImmediate(() => console.log('immediate'))
Promise.resolve().then(() => console.log('promise'))
process.nextTick(() => console.log('nextTick'))
console.log('sync')You will see sync, then nextTick, then promise. The order of timeout and immediate in the main module is not guaranteed; it depends on how fast the process starts. Inside an I/O callback, setImmediate always runs first. In ES modules, the ordering of nextTick and promises at the top level can differ, because module evaluation itself happens inside promise machinery. The practical rule: do not write code that depends on these fine-grained orderings.
The Node.js event loop guide covers the phases in more depth.
Why React developers should care
React apps are full of event loop behavior even if you never think about it:
- State updates are batched. Since React 18, updates inside promises, timeouts and native event handlers are batched automatically, so three
setStatecalls after anawaitcause one render. - Effects usually run after paint.
useEffectcallbacks normally run after the browser has had a chance to paint (React may run them earlier when the render was caused by a click or keypress), whileuseLayoutEffectruns synchronously before paint (which blocks it). Many effect bugs come from expecting the wrong timing, and my post on useEffect mistakes goes through them. - Transitions keep input responsive.
startTransitionmarks an update as non-urgent so React can yield between rendering chunks and handle the next keystroke first.
Common event loop mistakes
- Thinking
setTimeout(fn, 0)runs immediately. It runs after the current task and all microtasks. Browsers also clamp nested timers (after five levels of nesting) to at least 4 ms, and background tabs throttle timers further. - Using
await Promise.resolve()to "let the UI update". It does not yield to rendering. Use a macrotask orscheduler.yield(). - Forgetting that code before the first
awaitis synchronous. Heavy work there blocks the caller. - Awaiting inside a loop when the requests are independent. Use
Promise.all. - Unhandled rejections. A promise that rejects with no
.catchortry/catchstill runs its microtasks, but the error surfaces as a globalunhandledrejectionevent (and in Node, by default, crashes the process).
FAQ
What is the event loop in JavaScript?
It is the mechanism that lets single-threaded JavaScript handle asynchronous work. When the call stack is empty, the event loop runs all queued microtasks, lets the browser render if needed, then takes the next task from a task queue and runs it.
Do promises run before setTimeout?
Yes. Promise callbacks are microtasks, and the microtask queue is fully drained after the current task finishes, before the next task such as a setTimeout callback runs. That is why Promise.resolve().then() logs before setTimeout(fn, 0).
Does await block the event loop?
No. await pauses only the current async function. The rest of the function is scheduled as a microtask when the awaited promise settles, and the call stack is free to run other code in the meantime.
Is setTimeout(fn, 0) really zero milliseconds?
No. It means run as soon as possible after at least 0 ms, as a new task. It waits for the current task and all microtasks, and browsers clamp deeply nested timers to a minimum of about 4 ms.
Wrapping up
Remember one sentence: run one task, drain all microtasks, maybe render, repeat. With that, promise ordering, await behavior and frozen UIs stop being mysteries. If you are preparing for interviews, this topic comes up constantly, so pair it with my list of senior React developer interview questions.
- #javascript
- #event-loop
- #async
- #promises
Keep reading
Web8 min read
PWA Offline Support: Build an Installable Web App
Build a PWA with offline support: web app manifest, service worker caching strategies, a custom install prompt and the iOS caveats that still catch people out.
Read articleReact7 min read
10 Custom React Hooks Worth Having (TypeScript Examples)
10 custom React hooks in TypeScript: useDebounce, useLocalStorage, useMediaQuery, useOnClickOutside, useInterval and more, built for React 19 and the compiler.
Read articleReact8 min read
React 19 Hooks: use, useActionState, useOptimistic Explained
React 19 hooks explained with TypeScript examples: use, useActionState, useOptimistic and useFormStatus, plus the gotchas that trip people up in real forms.
Read article