Back to Blog
JavaScriptEvent LoopAsyncFundamentals

JavaScript Event Loop Explained

Understand the JavaScript event loop. From call stack to task queues to microtasks and macrotasks.

B
Bootspring Team
Engineering
December 23, 2020
7 min read

The event loop enables JavaScript's asynchronous, non-blocking behavior. Here's how it works.

The Basics#

1// JavaScript is single-threaded 2// Only one piece of code runs at a time 3 4console.log('1'); // Runs first 5 6setTimeout(() => { 7 console.log('2'); // Runs last (after delay) 8}, 0); 9 10console.log('3'); // Runs second 11 12// Output: 1, 3, 2 13// Even with 0ms delay, setTimeout callback waits

Call Stack#

1// The call stack tracks function execution 2 3function first() { 4 console.log('first'); 5 second(); 6} 7 8function second() { 9 console.log('second'); 10 third(); 11} 12 13function third() { 14 console.log('third'); 15} 16 17first(); 18 19// Call stack progression: 20// 1. first() pushed 21// 2. console.log('first') pushed, executed, popped 22// 3. second() pushed 23// 4. console.log('second') pushed, executed, popped 24// 5. third() pushed 25// 6. console.log('third') pushed, executed, popped 26// 7. third() popped 27// 8. second() popped 28// 9. first() popped 29 30// Stack overflow example 31function recursive() { 32 recursive(); // Eventually causes: Maximum call stack size exceeded 33}

Task Queue (Macrotasks)#

1// Macrotasks include: 2// - setTimeout 3// - setInterval 4// - setImmediate (Node.js) 5// - I/O operations 6// - UI rendering events 7 8console.log('Start'); 9 10setTimeout(() => console.log('Timeout 1'), 0); 11setTimeout(() => console.log('Timeout 2'), 0); 12 13console.log('End'); 14 15// Output: Start, End, Timeout 1, Timeout 2 16 17// One macrotask per event loop iteration 18setTimeout(() => { 19 console.log('Macro 1'); 20 setTimeout(() => console.log('Macro 2'), 0); 21}, 0); 22 23setTimeout(() => console.log('Macro 3'), 0); 24 25// Output: Macro 1, Macro 3, Macro 2 26// Each setTimeout callback is a separate task

Microtask Queue#

1// Microtasks include: 2// - Promise.then/catch/finally 3// - queueMicrotask() 4// - MutationObserver 5// - process.nextTick (Node.js) 6 7console.log('Start'); 8 9Promise.resolve().then(() => console.log('Microtask 1')); 10Promise.resolve().then(() => console.log('Microtask 2')); 11 12console.log('End'); 13 14// Output: Start, End, Microtask 1, Microtask 2 15 16// All microtasks run before next macrotask 17setTimeout(() => console.log('Timeout'), 0); 18 19Promise.resolve().then(() => { 20 console.log('Microtask 1'); 21 Promise.resolve().then(() => console.log('Microtask 2')); 22}); 23 24console.log('Script'); 25 26// Output: Script, Microtask 1, Microtask 2, Timeout

Execution Order#

1console.log('1 - Script start'); 2 3setTimeout(() => { 4 console.log('2 - setTimeout'); 5}, 0); 6 7Promise.resolve() 8 .then(() => { 9 console.log('3 - Promise 1'); 10 }) 11 .then(() => { 12 console.log('4 - Promise 2'); 13 }); 14 15queueMicrotask(() => { 16 console.log('5 - queueMicrotask'); 17}); 18 19console.log('6 - Script end'); 20 21// Output order: 22// 1 - Script start (synchronous) 23// 6 - Script end (synchronous) 24// 3 - Promise 1 (microtask) 25// 5 - queueMicrotask (microtask) 26// 4 - Promise 2 (microtask, queued by previous) 27// 2 - setTimeout (macrotask) 28 29// The algorithm: 30// 1. Execute all synchronous code 31// 2. Process ALL microtasks (including newly added ones) 32// 3. Render (if needed) 33// 4. Execute ONE macrotask 34// 5. Go to step 2

Async/Await Behavior#

1async function foo() { 2 console.log('foo start'); 3 await bar(); 4 console.log('foo end'); // This becomes a microtask 5} 6 7async function bar() { 8 console.log('bar'); 9} 10 11console.log('script start'); 12foo(); 13console.log('script end'); 14 15// Output: 16// script start 17// foo start 18// bar 19// script end 20// foo end 21 22// await is syntactic sugar for: 23function fooDesugared() { 24 console.log('foo start'); 25 return Promise.resolve(bar()).then(() => { 26 console.log('foo end'); 27 }); 28} 29 30// Complex example 31async function async1() { 32 console.log('async1 start'); 33 await async2(); 34 console.log('async1 end'); 35} 36 37async function async2() { 38 console.log('async2'); 39} 40 41console.log('script start'); 42 43setTimeout(() => { 44 console.log('setTimeout'); 45}, 0); 46 47async1(); 48 49new Promise((resolve) => { 50 console.log('promise1'); 51 resolve(); 52}).then(() => { 53 console.log('promise2'); 54}); 55 56console.log('script end'); 57 58// Output: 59// script start 60// async1 start 61// async2 62// promise1 63// script end 64// async1 end 65// promise2 66// setTimeout

Blocking the Event Loop#

1// Bad: Blocking synchronous code 2function heavyComputation() { 3 const start = Date.now(); 4 while (Date.now() - start < 3000) { 5 // Blocks for 3 seconds 6 // No other code can run 7 // UI becomes unresponsive 8 } 9} 10 11// Good: Break up work with setTimeout 12function nonBlockingComputation(items, callback) { 13 const chunkSize = 100; 14 let index = 0; 15 16 function processChunk() { 17 const end = Math.min(index + chunkSize, items.length); 18 19 for (; index < end; index++) { 20 // Process item 21 } 22 23 if (index < items.length) { 24 setTimeout(processChunk, 0); // Yield to event loop 25 } else { 26 callback(); 27 } 28 } 29 30 processChunk(); 31} 32 33// Better: Use requestIdleCallback for non-urgent work 34function processInIdle(items, callback) { 35 let index = 0; 36 37 function process(deadline) { 38 while (index < items.length && deadline.timeRemaining() > 0) { 39 // Process items while we have time 40 index++; 41 } 42 43 if (index < items.length) { 44 requestIdleCallback(process); 45 } else { 46 callback(); 47 } 48 } 49 50 requestIdleCallback(process); 51}

requestAnimationFrame#

1// Runs before repaint (approximately 60fps) 2// Special timing: between microtasks and next frame 3 4function animate() { 5 console.log('Animation frame'); 6 7 // Schedule next frame 8 requestAnimationFrame(animate); 9} 10 11requestAnimationFrame(animate); 12 13// Comparison 14console.log('Start'); 15 16requestAnimationFrame(() => console.log('rAF')); 17setTimeout(() => console.log('Timeout'), 0); 18Promise.resolve().then(() => console.log('Promise')); 19 20console.log('End'); 21 22// Output: Start, End, Promise, rAF, Timeout 23// (rAF may vary based on timing) 24 25// Smooth animations 26let position = 0; 27 28function moveElement() { 29 position += 1; 30 element.style.transform = `translateX(${position}px)`; 31 32 if (position < 300) { 33 requestAnimationFrame(moveElement); 34 } 35} 36 37requestAnimationFrame(moveElement);

Node.js Specifics#

1// Node.js has additional phases 2// Timers -> Pending -> Idle -> Poll -> Check -> Close 3 4// process.nextTick runs after current operation 5// Before any I/O, before microtasks 6 7process.nextTick(() => console.log('nextTick')); 8Promise.resolve().then(() => console.log('Promise')); 9setTimeout(() => console.log('Timeout'), 0); 10setImmediate(() => console.log('Immediate')); 11 12// Output: 13// nextTick 14// Promise 15// Timeout (may vary) 16// Immediate (may vary) 17 18// setImmediate vs setTimeout(fn, 0) 19// In I/O callback: setImmediate always first 20const fs = require('fs'); 21 22fs.readFile('file.txt', () => { 23 setTimeout(() => console.log('Timeout'), 0); 24 setImmediate(() => console.log('Immediate')); 25}); 26// Output: Immediate, Timeout (consistent)

Common Pitfalls#

1// Pitfall 1: Assuming setTimeout(fn, 0) is instant 2for (var i = 0; i < 3; i++) { 3 setTimeout(() => console.log(i), 0); 4} 5// Output: 3, 3, 3 (var is function-scoped) 6 7// Fix: Use let 8for (let i = 0; i < 3; i++) { 9 setTimeout(() => console.log(i), 0); 10} 11// Output: 0, 1, 2 12 13// Pitfall 2: Starvation 14function starve() { 15 Promise.resolve().then(starve); 16} 17starve(); 18// Microtask queue never empties 19// setTimeout callbacks never run 20 21// Pitfall 3: UI freezing 22button.addEventListener('click', () => { 23 heavyTask(); // Blocks UI 24 updateUI(); // User sees nothing until done 25}); 26 27// Better 28button.addEventListener('click', async () => { 29 showLoading(); 30 await new Promise(r => setTimeout(r, 0)); 31 heavyTask(); 32 hideLoading(); 33 updateUI(); 34});

Debugging Tips#

1// Visualize execution order 2function trace(name) { 3 console.log(name); 4 console.trace(); 5} 6 7// Use performance API 8const start = performance.now(); 9// ... code ... 10const end = performance.now(); 11console.log(`Took ${end - start}ms`); 12 13// Check if code is blocking 14const CHECK_INTERVAL = 100; 15let lastCheck = Date.now(); 16 17setInterval(() => { 18 const now = Date.now(); 19 const delay = now - lastCheck - CHECK_INTERVAL; 20 if (delay > 50) { 21 console.warn(`Event loop blocked for ${delay}ms`); 22 } 23 lastCheck = now; 24}, CHECK_INTERVAL);

Best Practices#

Performance: ✓ Avoid blocking synchronous code ✓ Break up heavy computation ✓ Use Web Workers for CPU-intensive tasks ✓ Batch DOM updates Understanding: ✓ Microtasks run to completion ✓ One macrotask per iteration ✓ rAF before repaint ✓ await creates microtask boundaries Patterns: ✓ Use queueMicrotask for urgent async ✓ Use setTimeout(fn, 0) to yield ✓ Use requestIdleCallback for low priority ✓ Use rAF for animations Debugging: ✓ Console.log execution order ✓ Use browser performance tools ✓ Check for stack traces ✓ Monitor event loop lag

Conclusion#

The event loop enables asynchronous JavaScript through task queues. Synchronous code runs first, then all microtasks, then one macrotask, then render if needed. Understanding this helps write performant, non-blocking code and debug timing issues.

Share this article

Help spread the word about Bootspring