JS / TS

The JavaScript Event Loop, Explained Without the Jargon

3 min read

If you've ever stared at a setTimeout that fired "late," or a Promise that resolved in an order you didn't expect, you've met the event loop — you just weren't formally introduced. Most explanations bury it under spec language. Here's the version I wish someone had given me fifteen years ago.

What the event loop actually is

JavaScript runs your code on a single thread. One thing at a time, always. The event loop is the mechanism that makes a single-threaded language feel like it's doing ten things at once: it's a coordinator that decides what runs next once the current piece of work finishes.

That's the whole idea. Everything else is detail about the queues it coordinates.

The call stack: a single chef in a kitchen

Picture a kitchen with exactly one chef. Every function you call is an order ticket. The chef works one ticket at a time, start to finish — no pausing halfway to start another. That pile of in-progress tickets is the call stack: call a function, a ticket goes on top; the function returns, the ticket comes off.

The important consequence: while the chef is busy, nothing else happens. No clicks are handled, no renders occur. If one ticket takes ten seconds, the whole restaurant waits.

function chopOnions() {
  /* ... */
}
function makeSoup() {
  chopOnions(); // stack: makeSoup → chopOnions
}
makeSoup(); // one ticket at a time, until the stack is empty

Web APIs: the helpers in the back room

So how does setTimeout work if there's only one chef? Because the timer isn't the chef's job. When you call setTimeout, fetch, or add an event listener, the browser (or Node) hands that waiting work to its own machinery — the back room. The chef moves on immediately.

When the timer finishes or the response arrives, the back room doesn't interrupt the chef. It writes a new ticket and puts it in a queue.

The task queue and the microtask queue

Here's where the interesting bugs live. There are (at least) two queues:

  • The task queue (or macrotask queue): callbacks from setTimeout, setInterval, and most events.
  • The microtask queue: Promise callbacks (.then, await continuations) and queueMicrotask.

The event loop's rule is strict: when the call stack empties, drain the entire microtask queue first, then take one task from the task queue, then drain microtasks again, and so on. Promises always cut in line ahead of timers.

console.log('1');
 
setTimeout(() => console.log('2'), 0);
 
Promise.resolve().then(() => console.log('3'));
 
console.log('4');
// Output: 1, 4, 3, 2 — the microtask (3) beats the "0ms" timer (2)

That setTimeout(fn, 0) doesn't mean "run now." It means "queue this as a task" — and it still waits for the current code and all microtasks to finish.

A real bug this explains

A classic support ticket: "My click handler fires out of order." The setup was a button whose handler kicked off a data refresh, and a "loading" flag that got set inside a .then():

button.addEventListener('click', () => {
  refreshData().then(() => {
    isLoading = false; // microtask — runs later
  });
  isLoading = true;
  updateSpinner(); // reads isLoading = true, correct...
});

The bug appeared when someone "optimized" a synchronous cache path to return a resolved Promise. Developers assumed the .then() ran immediately for cached data — it doesn't. Even an already-resolved Promise schedules its callback as a microtask, so isLoading = true and the spinner update always run first, and the spinner flashed on every cached hit. Once you know microtasks always wait for the current stack to finish, the behavior is obvious instead of haunted.

Key takeaways

  • JavaScript is single-threaded; the event loop schedules what runs when the stack is empty.
  • setTimeout(fn, 0) queues a task; it never runs immediately.
  • Promise callbacks are microtasks and run before the next task — even for already-resolved Promises.
  • Long synchronous work blocks everything: input, rendering, timers. (When you genuinely need heavy computation, that's what Web Workers are for.)
  • When async order surprises you, ask one question: which queue is this callback in?

Found this useful?

Share

I post shorter takes on javascript & typescript and frontend leadership on LinkedIn — follow along there, or get in touch if you're working on something related.