Beyond Code & Karma · JavaScript Internals

The Event Loop

The invisible engine running your JavaScript — explained through cosmic order, karma, and code.

Interactive Deep Dive

JavaScript is Single-Threaded.
So how does it do… everything?

Before we touch the Event Loop, you need to feel this truth in your bones: JavaScript executes one line at a time, on a single thread. No parallelism. No multitasking in the traditional sense. Yet your browser downloads files, handles clicks, runs timers, and renders animations simultaneously. How?

The Secret: JavaScript doesn't do all this alone. It offloads async work to the Web APIs (provided by the browser or Node.js runtime), and the Event Loop is the orchestrator that decides when the results come back into your code.
Call Stack
The place where your JavaScript actually runs. Functions are pushed on top and popped off when they return. LIFO — Last In, First Out.
Web APIs / C++ APIs
setTimeout, fetch, DOM events — these live outside the JS engine (in browser or Node runtime). They run concurrently and send callbacks when done.
Task Queues
Two queues: the Macrotask Queue (setTimeout, setInterval, I/O) and the Microtask Queue (Promises, queueMicrotask, MutationObserver).
Event Loop
The eternal loop that checks: "Is the Call Stack empty? Yes? Drain all microtasks first. Then pick the next macrotask." Repeat. Forever.

The Cosmic Order of Brahma, Vishnu & Shiva

Hindu Mythology Parallel

In Hindu cosmology, the universe runs on a perfect, never-ending cycle — Srishti (Creation), Sthiti (Preservation), Samhara (Dissolution). Brahma creates, Vishnu maintains, Shiva dissolves. Then it begins again. Eternally.

The Event Loop is JavaScript's cosmic cycle:

Brahma — Web APIs
Creates the async tasks — timers fire, network responds, events trigger.
Vishnu — Event Loop
Preserves order. Watches the Call Stack. Decides what runs when. Maintains dharma.
Shiva — Call Stack Pop
When a function completes (dissolves), it's popped from the stack. Space for the next cycle.

"Just as the cosmic cycle is eternal and maintains the order of the universe — the Event Loop is eternal and maintains the order of your application."

The Classic Gotcha Example

This is the code that broke a million developer brains. Predict the output — then scroll down for the truth.

event-loop-demo.js
// What is the output order?

console.log('1️⃣  Script start');

setTimeout(() => {
  console.log('4️⃣  setTimeout (macro)');
}, 0);

Promise.resolve()
  .then(() => console.log('3️⃣  Promise .then (micro)'))
  .then(() => console.log('3b 🔸 Chained .then (micro)'));

queueMicrotask(() => {
  console.log('3c 🔹 queueMicrotask (micro)');
});

console.log('2️⃣  Script end');

// OUTPUT:
// 1️⃣  Script start
// 2️⃣  Script end
// 3️⃣  Promise .then (micro)
// 3c 🔹 queueMicrotask (micro)
// 3b 🔸 Chained .then (micro)
// 4️⃣  setTimeout (macro)
Key Insight: Even though setTimeout has 0ms delay, it's a macrotask. The entire microtask queue (all Promise callbacks) drains completely before any macrotask runs. Every. Single. Time.

The Rules of Dharma — How the Event Loop Decides

Execute the current script (synchronous code)

All synchronous code runs immediately, top to bottom. console.log, variable assignments, function calls — all pushed and popped from the call stack right now.

Drain the Microtask Queue completely

Once the call stack is empty, ALL pending microtasks run — one by one. If a microtask enqueues another microtask, that runs too. This loop continues until the microtask queue is truly empty.

Pick ONE Macrotask from the Task Queue

The Event Loop picks the oldest waiting macrotask (setTimeout callback, I/O callback, etc.), pushes it to the call stack, and runs it.

Drain the Microtask Queue again

After every single macrotask, the entire microtask queue drains again before the next macrotask. This is the loop. Go to step 3. Forever.

Watch It Execute Live

Step through a real async code scenario. Watch each function move through the Call Stack, Microtask Queue, and Macrotask Queue.

Event Loop Simulator
⏸ Ready
🧠 Call Stack
Microtask Queue
Macrotask Queue
Waiting to start simulation...

The Nuances That Senior Devs Know

This is where the 10,000 basic videos stop. Here's what actually matters at scale.

Yes. If your microtask queue never empties — for example, a Promise .then() that always enqueues another microtask — the macrotask queue never gets a turn. No rendering. No user events. The browser freezes.

microtask-starvation.js
// 🚨 This will FREEZE your browser
function infiniteMicrotasks() {
  Promise.resolve().then(infiniteMicrotasks);
}
infiniteMicrotasks();
// The macrotask queue (rendering, clicks) never runs!

Rule of Karma: What you create in the microtask queue, you must also drain. Infinite promises = infinite suffering.

Both schedule a microtask. But queueMicrotask(fn) is more explicit, doesn't create a Promise object, and doesn't swallow errors. Promise.resolve().then(fn) has overhead — a full Promise object is created. For high-performance hot paths, queueMicrotask is lighter.

Pro tip: Node.js has process.nextTick() which actually runs before the microtask queue. It's a special internal queue — native node callbacks that have even higher priority than Promises.

The browser Event Loop is specified by the HTML spec. Node.js uses libuv, a C++ library, which adds more phases. Node's loop has:

PhaseWhat runsNotes
timerssetTimeout, setInterval callbacksAfter min delay
pending callbacksDeferred I/O callbacks from prev cycleError callbacks, etc.
idle, prepareInternal libuv use onlyRarely relevant
pollNew I/O events, network, file systemMain "work" phase
checksetImmediate() callbacksAfter poll phase
close callbackssocket.on('close', ...)Cleanup

process.nextTick() and Promise microtasks run between every phase. setImmediate() runs after the poll phase (roughly equivalent to setTimeout(fn, 0) but more predictable in Node).

requestAnimationFrame() is neither a microtask nor a standard macrotask. It runs at the render step, which happens after macrotasks but before the browser paints the next frame (~16ms for 60fps). The order in browsers is:

Macrotask
Drain Microtasks
requestAnimationFrame
Browser Paint 🎨
Next Macrotask

This is why rAF-based animations are smooth — they're synchronized with the browser's natural render cycle, unlike setTimeout-based animations which can drift.

async/await is syntactic sugar over Promises — but there's subtlety. Each await creates a suspension point. The code after await becomes a microtask callback. Understanding this matters for performance.

async-microtasks.js
async function fetchData() {
  console.log('A');            // sync — runs now
  await Promise.resolve();   // suspension point
  console.log('B');            // microtask — runs after stack clears
  await fetch('/api');       // suspension — waits for network
  console.log('C');            // microtask after network resolves
}

fetchData();
console.log('D');  // sync — runs BEFORE B and C

// Output: A → D → B → C

Any synchronous task that runs longer than 50ms is a "Long Task" (as defined by the Chrome team). Since JS is single-threaded, a Long Task blocks: rendering, user input handling, and all queued tasks.

Solutions: Break long tasks with setTimeout(fn, 0) (yields back to the event loop), scheduler.yield() (new browser API), Web Workers (true parallelism for CPU-heavy work), or chunking with requestIdleCallback for low-priority work.
yield-control.js
// Modern way to yield back to the browser
async function processLargeArray(items) {
  for (let i = 0; i < items.length; i++) {
    processItem(items[i]);
    // Yield every 50 items to stay responsive
    if (i % 50 === 0) {
      await new Promise(res => setTimeout(res, 0));
    }
  }
}

Macro vs Micro — The Complete Cheat Sheet

Task Type API / Source Queue Priority Drains
Promise .then/.catchPromiseMicrotaskHighAfter each task/microtask
async/await (after await)async functionsMicrotaskHighAfter each task/microtask
queueMicrotaskqueueMicrotask()MicrotaskHighAfter each task/microtask
MutationObserverDOM MutationsMicrotaskHighAfter each task/microtask
setTimeout / setIntervalWeb API / libuvMacrotaskNormalOne per event loop turn
setImmediateNode.js onlyMacrotask (check)NormalAfter poll phase in Node
I/O callbacksfs, net, etc.MacrotaskNormalOne per event loop turn
DOM click/keydown eventsBrowser EventsMacrotaskNormalOne per event loop turn
requestAnimationFrameBrowser RenderingRender TaskRender-syncedBefore browser paint
process.nextTickNode.js onlynextTick QueueHighest (Node)Before Promises in Node

The Karma of Callbacks

The Karma Connection

In Hindu philosophy, Karma is not just "what goes around, comes around" — it's the cosmic principle that every action has a consequence, deferred to the right time. You perform an action now. The fruit of that action (Karma-phala) arrives later — when the conditions are right.

Sound familiar? Every time you write:

karma.js
fetch('/api/karma').then(result => {
  // This is the Karma-phala — the fruit of your async action
  // It comes back when the universe (runtime) is ready
  console.log('The fruit of our async action:', result);
});

You performed an action (fetch). The karma (callback) was stored in the Web API realm. When the time came (network responded), the Karma-phala (result) was placed into the Microtask Queue. The Event Loop (dharma) ensured it was delivered at the right time — not too early, not too late.

"Do not be attached to the result of your async action. Let the Event Loop handle the fruits in its own time." — Bhagavad Gita, interpreted for JavaScript developers 🙏

Event Loop Trivia

Three questions. No cheating. Your karma is watching.

Question 1
What is the output order of: console.log('A'), setTimeout(()=>log('B'),0), Promise.resolve().then(()=>log('C')), console.log('D')?
Question 2
In Node.js, which runs first: process.nextTick() or Promise.resolve().then()?
Question 3 — 🌶️ Spicy
You have a Promise chain: Promise.resolve().then(A).then(B). And separately: Promise.resolve().then(C). What is the execution order of A, B, C?

The Six Truths of the Event Loop

One Thread. One Stack.
JavaScript is single-threaded. The call stack processes one frame at a time. Concurrency is an illusion created by the Event Loop + Web APIs.
Microtasks Always Win
The entire microtask queue drains after every task and after every microtask. They always run before the next macrotask. Always.
One Macro at a Time
The Event Loop picks exactly ONE macrotask per iteration. Then drains microtasks. Then picks the next. This is why UI stays responsive (in theory).
Block = Death
Any synchronous work over 50ms blocks everything — rendering, user input, queued tasks. The Event Loop can only help if you give it a chance to run.
Browser ≠ Node.js
Node.js has extra loop phases (via libuv) and a special nextTick queue. Don't assume browser behavior maps 1:1 to Node. Know your runtime.
Dharma of the Loop
Like cosmic order, the Event Loop maintains dharma — ensuring every callback gets its rightful turn, at the rightful time, in the rightful order.