How JavaScript's Event Loop Actually Works
How JavaScript runs 1000 async tasks on a single thread. The call stack, task queue, microtask queue, and Web APIs — visualized.
Neural Download
Installing mental model for event loop.
Here's a question. What order do these three lines print?
Console log "first." Set timeout with zero milliseconds, log "second." Promise resolve then log "third."
Most people guess first, second, third. But the actual output is first, third, second. The one with zero delay prints after the promise.
By the end of this video, you'll know exactly why.
JavaScript has one thread. One. It can only do one thing at a time. And it uses a call stack to keep track of what it's doing.
Every time you call a function, it goes on the stack. When the function returns, it comes off the stack. Simple.
But here's the problem. What happens when a function takes five seconds to run? A massive loop, a heavy calculation, whatever.
Everything stops. The entire page freezes. Clicks don't work. Scrolling is dead. Animations hang. Because JavaScript can't do anything else until that function comes off the stack.
The UI thread is frozen. One slow function, and the whole experience is broken.
So how does JavaScript handle thousands of concurrent operations with just one thread? It doesn't. Not alone.
JavaScript the language is single-threaded. But it doesn't run alone. It runs inside a runtime, and the runtime gives it superpowers.
The browser provides Web APIs. Timers, network requests, DOM events. These aren't part of the JavaScript language. They're provided by the runtime environment.
When you call set timeout, JavaScript doesn't sit there counting milliseconds. It hands the timer to the browser and moves on. The call stack is free. JavaScript keeps executing the next line of code.
Meanwhile, the browser counts down independently. When the timer fires, the callback doesn't jump straight onto the call stack. That would be chaos. Instead, it enters a waiting room. The task queue.
And now here's the star of the show. The event loop.
The event loop watches the call stack and the task queue. When the call stack is empty, it grabs the next callback from the task queue and pushes it onto the stack.
Check if the stack is empty. If yes, dequeue. Push. Repeat. Forever. But that's not the full story. There's another queue. A faster queue. And it changes everything.
This is why set timeout with zero doesn't mean "run now." It means "run as soon as the call stack is empty." If the stack is busy, that zero millisecond callback waits. It could be ten milliseconds. It could be five seconds. The delay is a minimum, not a guarantee.
Now remember our opening puzzle? First, third, second. Set timeout went to the task queue, we know that now. But why did the promise callback jump ahead?
Because promises don't use the task queue. They use a separate queue. The microtask queue. And it has VIP access.
After every task finishes on the call stack, the event loop doesn't immediately check the task queue. It checks the microtask queue first. And it drains the entire microtask queue before moving on.
Every single microtask runs before any task gets a turn. Promise dot then? Microtask. Mutation observer? Microtask. These always jump the line.
So in our puzzle, "first" prints immediately. Set timeout registers its callback in the task queue. Promise dot resolve schedules its callback in the microtask queue. The script finishes. The event loop checks microtasks first. "Third" prints. Then it checks the task queue. "Second" prints.
First. Third. Second. Not a bug. By design.
And this is also how async await works under the hood. When you write "await," you're really writing a promise dot then. The function pauses at that point, and its continuation gets scheduled as a microtask when the promise settles. The call stack is free to do other work.
Async await is just syntactic sugar over promises. Promise reactions are microtasks. The abstraction is beautiful, but underneath, it's the same two queues.
Let's zoom out and see the complete cycle.
Step one. The event loop picks a task from the task queue and runs it on the call stack. Click handlers, timer callbacks, network responses. One task at a time.
Step two. Once the call stack is empty, drain the entire microtask queue. Every promise callback, every mutation observer. All of them. If a microtask schedules another microtask, that runs too. The queue must be completely empty before moving on.
Step three. If it's time to repaint the screen, the browser runs request animation frame callbacks, recalculates styles, reflows layout, and paints pixels. This usually happens around sixty times per second, but only between tasks, never in the middle of a microtask drain.
Then back to step one. Pick the next task. Repeat forever.
This is the full event loop. Tasks. Microtasks. Render. Tasks. Microtasks. Render.
And now you can predict most async behavior. Let's test it. Here's a challenge.
Console log A. Set timeout, log B. Promise resolve then log C. Request animation frame, log D. Promise resolve then log E. Console log F.
What's the order?
A and F print immediately. They're synchronous. C and E are microtasks, they drain next. Then B and D fire. B is a task, D is a request animation frame callback that runs at the next paint. In practice you'll almost always see A, F, C, E, B, D.
If you got that right, you understand the event loop. Not as a vague concept. As a mental model you can reason with.
Every click you've ever made, every fetch request, every animation on every website you've ever visited. All orchestrated by this invisible loop, running billions of cycles, on billions of devices, right now.
Cognitive architecture... updated.
