The event loop is fundamental to JavaScript's asynchronous behavior. Here's how it works and why it matters.
The Call Stack#
1// JavaScript is single-threaded
2// One call stack, one thing at a time
3
4function multiply(a, b) {
5 return a * b;
6}
7
8function square(n) {
9 return multiply(n, n);
10}
11
12function printSquare(n) {
13 const result = square(n);
14 console.log(result);
15}
16
17printSquare(4);
18
19// Call stack during execution:
20// 1. printSquare(4)
21// 2. printSquare(4) -> square(4)
22// 3. printSquare(4) -> square(4) -> multiply(4, 4)
23// 4. printSquare(4) -> square(4) (multiply returns 16)
24// 5. printSquare(4) (square returns 16)
25// 6. (printSquare returns, stack empty)Blocking vs Non-Blocking#
1// Blocking code - stops everything
2function blockingOperation() {
3 const start = Date.now();
4 while (Date.now() - start < 3000) {
5 // Blocks for 3 seconds
6 }
7 console.log('Done blocking');
8}
9
10// Non-blocking - uses callbacks
11function nonBlockingOperation() {
12 setTimeout(() => {
13 console.log('Done after timeout');
14 }, 3000);
15}
16
17console.log('Start');
18nonBlockingOperation();
19console.log('End');
20
21// Output:
22// Start
23// End
24// Done after timeout (3 seconds later)Task Queue (Macrotasks)#
1// setTimeout, setInterval, I/O, UI rendering
2// These go to the task queue (macrotask queue)
3
4console.log('1');
5
6setTimeout(() => {
7 console.log('2');
8}, 0);
9
10console.log('3');
11
12// Output: 1, 3, 2
13// Even with 0ms delay, setTimeout is queued
14
15// Each task queue iteration processes ONE macrotask
16setTimeout(() => console.log('timeout 1'), 0);
17setTimeout(() => console.log('timeout 2'), 0);
18setTimeout(() => console.log('timeout 3'), 0);
19
20// Each executes in separate event loop iterationsMicrotasks#
1// Promises, queueMicrotask, MutationObserver
2// Microtasks run AFTER current task, BEFORE next macrotask
3
4console.log('1');
5
6setTimeout(() => {
7 console.log('2 - macrotask');
8}, 0);
9
10Promise.resolve().then(() => {
11 console.log('3 - microtask');
12});
13
14console.log('4');
15
16// Output: 1, 4, 3 - microtask, 2 - macrotask
17// Microtask (Promise) runs before macrotask (setTimeout)
18
19// All microtasks are processed before any macrotask
20Promise.resolve().then(() => console.log('micro 1'));
21Promise.resolve().then(() => console.log('micro 2'));
22Promise.resolve().then(() => console.log('micro 3'));
23
24setTimeout(() => console.log('macro 1'), 0);
25
26// Output: micro 1, micro 2, micro 3, macro 1Event Loop Order#
1// Event loop priority:
2// 1. Execute synchronous code (call stack)
3// 2. Execute all microtasks
4// 3. Execute one macrotask
5// 4. Repeat
6
7console.log('sync 1');
8
9setTimeout(() => {
10 console.log('macro 1');
11 Promise.resolve().then(() => console.log('micro inside macro'));
12}, 0);
13
14Promise.resolve().then(() => {
15 console.log('micro 1');
16 setTimeout(() => console.log('macro inside micro'), 0);
17});
18
19setTimeout(() => console.log('macro 2'), 0);
20
21console.log('sync 2');
22
23// Output:
24// sync 1
25// sync 2
26// micro 1
27// macro 1
28// micro inside macro
29// macro 2
30// macro inside microAsync/Await and the Event Loop#
1// async/await is syntactic sugar over Promises
2// Same microtask behavior
3
4async function asyncFunc() {
5 console.log('async start');
6 await Promise.resolve();
7 console.log('after await');
8}
9
10console.log('1');
11asyncFunc();
12console.log('2');
13
14// Output: 1, async start, 2, after await
15// Code after await is scheduled as microtask
16
17// Multiple awaits
18async function multipleAwaits() {
19 console.log('A');
20 await Promise.resolve();
21 console.log('B');
22 await Promise.resolve();
23 console.log('C');
24}
25
26console.log('1');
27multipleAwaits();
28console.log('2');
29
30// Output: 1, A, 2, B, CqueueMicrotask#
1// Explicitly queue a microtask
2queueMicrotask(() => {
3 console.log('microtask');
4});
5
6console.log('sync');
7
8// Output: sync, microtask
9
10// Use case: Defer work but run before rendering
11function updateUI(changes) {
12 // Batch updates in microtask
13 queueMicrotask(() => {
14 applyChanges(changes);
15 });
16}
17
18// Multiple calls batch naturally
19updateUI({ a: 1 });
20updateUI({ b: 2 });
21// Both applied in same microtask batchrequestAnimationFrame#
1// requestAnimationFrame runs before repaint
2// After macrotasks and microtasks, before paint
3
4console.log('sync');
5
6requestAnimationFrame(() => {
7 console.log('rAF');
8});
9
10setTimeout(() => {
11 console.log('timeout');
12}, 0);
13
14Promise.resolve().then(() => {
15 console.log('microtask');
16});
17
18// Typical output: sync, microtask, timeout, rAF
19// (rAF timing depends on refresh rate)
20
21// Use for animations
22function animate() {
23 updatePosition();
24 requestAnimationFrame(animate);
25}Node.js Event Loop#
1// Node.js has additional phases
2// process.nextTick and setImmediate
3
4// process.nextTick - runs BEFORE microtasks
5process.nextTick(() => console.log('nextTick'));
6Promise.resolve().then(() => console.log('promise'));
7// Output: nextTick, promise
8
9// setImmediate - runs after I/O callbacks
10setTimeout(() => console.log('timeout'), 0);
11setImmediate(() => console.log('immediate'));
12
13// Order depends on context:
14// In main script: either order
15// In I/O callback: immediate always first
16
17const fs = require('fs');
18
19fs.readFile('file.txt', () => {
20 setTimeout(() => console.log('timeout'), 0);
21 setImmediate(() => console.log('immediate'));
22});
23// Output: immediate, timeout (guaranteed)Common Pitfalls#
1// Pitfall 1: Starving the event loop
2async function starve() {
3 while (true) {
4 await Promise.resolve();
5 // Infinite microtask loop - blocks macrotasks!
6 }
7}
8
9// Pitfall 2: Expecting synchronous behavior
10let result = 'initial';
11
12Promise.resolve().then(() => {
13 result = 'updated';
14});
15
16console.log(result); // 'initial', not 'updated'
17
18// Pitfall 3: setTimeout ordering assumptions
19for (let i = 0; i < 3; i++) {
20 setTimeout(() => console.log(i), 0);
21}
22// Output: 0, 1, 2 (as expected with let)
23// With var: 3, 3, 3 (closure issue)
24
25// Pitfall 4: Heavy computation blocking
26function processData(data) {
27 // Breaks up work to not block
28 const CHUNK_SIZE = 1000;
29
30 function processChunk(start) {
31 const end = Math.min(start + CHUNK_SIZE, data.length);
32
33 for (let i = start; i < end; i++) {
34 heavyComputation(data[i]);
35 }
36
37 if (end < data.length) {
38 setTimeout(() => processChunk(end), 0);
39 }
40 }
41
42 processChunk(0);
43}Debugging the Event Loop#
1// Visualize task timing
2function logWithTime(label) {
3 console.log(`${Date.now() % 10000}: ${label}`);
4}
5
6logWithTime('sync 1');
7
8setTimeout(() => logWithTime('macro 1'), 0);
9setTimeout(() => logWithTime('macro 2'), 0);
10
11Promise.resolve()
12 .then(() => logWithTime('micro 1'))
13 .then(() => logWithTime('micro 2'));
14
15queueMicrotask(() => logWithTime('queueMicrotask'));
16
17logWithTime('sync 2');
18
19// Use Chrome DevTools Performance tab
20// to visualize event loop activity
21
22// Check for long tasks
23const observer = new PerformanceObserver((list) => {
24 for (const entry of list.getEntries()) {
25 console.log('Long task:', entry.duration);
26 }
27});
28
29observer.observe({ entryTypes: ['longtask'] });Best Practices#
Understanding:
✓ Microtasks before macrotasks
✓ All microtasks drain before next macro
✓ Heavy sync code blocks the loop
✓ Promises are microtasks
Performance:
✓ Break up heavy computation
✓ Use requestAnimationFrame for animation
✓ Avoid infinite microtask loops
✓ Use Web Workers for CPU-intensive work
Debugging:
✓ Log timing to understand order
✓ Use Performance DevTools
✓ Monitor for long tasks
✓ Test edge cases with timeouts
Conclusion#
The event loop enables JavaScript's asynchronous behavior while remaining single-threaded. Understanding the distinction between macrotasks and microtasks, and how they're processed, helps you write predictable async code and avoid common pitfalls like blocking the event loop.