Why doesn't your tab freeze when you call an API?
Here's a question that trips up a lot of people, even after they've been writing JavaScript for a while: if JS can only run one line of code at a time, what actually happens while a fetch() request is out there waiting for a response? Does the browser just sit there, frozen, unable to scroll or click or repaint the screen, until the response comes back?
It doesn't. And once you understand why, a whole pile of confusing behavior in JavaScript stops feeling random and starts feeling predictable. This is that explanation, minus the hand-waving.
The one-thread rule
JavaScript runs on a single thread. One thread means one call stack, and one call stack means the engine can only execute one function at a time. There's no built-in way for your JS code to spin up a second thread and run two functions simultaneously, not the way you might in a language with native threading.
If that were the whole story, any slow operation (a network call, a big file read, a multi-second timer) would lock up everything else. No animations, no clicks registering, no scrolling. Anyone who's accidentally written a giant synchronous loop has felt this firsthand. The tab genuinely freezes, because the one thread is busy and nothing else gets a turn until it's done.
So how does fetch() avoid doing that? It doesn't run on your JS thread at all. The browser hands it off.
Handing work off to the browser
The JavaScript engine itself (V8 in Chrome, SpiderMonkey in Firefox, whichever one your browser uses) is genuinely single threaded. But your JS code isn't running in a vacuum. It's running inside a browser, and the browser gives you a bunch of APIs that live outside that single thread: timers, the DOM, fetch, geolocation, and so on. These are usually called the Web APIs, and they're implemented by the browser itself, often backed by their own threads or OS-level mechanisms.
So when you call setTimeout(fn, 3000), here's what's actually happening:
- Your code calls
setTimeout. The JS engine registers this call, hands the timer off to the browser, and immediately moves on to the next line. It does not pause and wait three seconds. - The browser starts a countdown somewhere outside your JS thread.
- Your script keeps running, the page stays responsive, clicks still register.
- Three seconds later, the browser is done timing. It doesn't just barge into your JS thread and run the callback whenever it feels like it. Instead, it places the callback into a queue and waits.
- Only once your JS thread is completely free does that callback actually get pulled off the queue and run.
That last step, the constant checking of "is the thread free yet, and if so, what's waiting," is the event loop. It's not a separate clever piece of magic bolted onto JS. It's closer to a simple, boring loop that just keeps asking the same question over and over: anything on the stack right now? No? Then grab the next thing waiting and run it.
Two different queues, not one
This is the part that catches people off guard, because most explanations gloss over it or mention it too late. There isn't just one queue of "stuff waiting to run." There are (at minimum) two, and they get treated very differently.
The macrotask queue (often just called the callback queue or task queue) holds things like setTimeout and setInterval callbacks, DOM events, and I/O completions.
The microtask queue holds Promise callbacks (.then, .catch, .finally), queueMicrotask(), and a few other spec-defined bits like MutationObserver callbacks.
The rule that matters: after each macrotask finishes, and before the engine grabs the next macrotask, it fully drains the microtask queue. Every single microtask, including any new ones that got added while draining, runs before the event loop even glances at the macrotask queue again.
This is why Promises tend to "cut in line" ahead of timers, even when the timer looks like it should win.
Tracing through an actual example
Theory is fine, but this stuff really clicks once you predict an output and then check yourself. Take this:
console.log('start');
setTimeout(() => {
console.log('timeout');
}, 0);
Promise.resolve().then(() => {
console.log('promise');
});
console.log('end');
Before reading further, guess the order these four lines print in.
Here's what actually happens, step by step:
-
console.log('start')runs immediately. It's synchronous, so there's no queue involved at all, it just executes right where it sits. Printsstart. -
setTimeout(..., 0)gets registered with the browser. Even with a delay of zero, the callback doesn't run now, it goes to the browser's timer system first and its callback lands in the macrotask queue once the (essentially instant) delay is up. -
Promise.resolve().then(...)schedules its callback into the microtask queue. Also not immediate. -
console.log('end')runs immediately, same asstart. Printsend. - Now the call stack is finally empty, and the engine looks for work. It checks the microtask queue first, finds the Promise callback, and runs it. Prints
promise. - Only after the microtask queue is completely empty does the engine move to the macrotask queue and run the timeout callback. Prints
timeout.
Final output: start, end, promise, timeout.
The takeaway that actually matters for writing and debugging real code: a setTimeout with a delay of 0 does not mean "run this next." It means "run this once the current call stack is clear and every pending microtask has been handled." Those are very different guarantees, and mixing them up is a common source of bugs in code that assumes strict execution order.
Why this matters for callbacks and async/await
Before Promises were standard, this same non-blocking model was handled entirely with callbacks: pass a function in, get it called back later when the work is done. That pattern is still everywhere (event listeners, fs.readFile in Node, older APIs), but it has a well known failure mode once you need several async steps to happen in sequence, each depending on the last one's result. You end up nesting callback inside callback inside callback, each one indented a bit further right than the last. People call this callback hell, or the pyramid of doom, because that's genuinely what it looks like on screen, and it makes error handling and control flow painful to follow.
Promises, and later async/await, don't change anything about the event loop itself. They're built on top of it. async/await is really just syntax that makes microtask-queue based code (Promises under the hood) read like straight-line synchronous code, which is a big part of why it replaced deeply nested callbacks for most sequential async logic.
It's not just a browser thing
Node.js runs on this exact same model. There's no browser tab involved, but the idea is identical: a single JS thread, an event loop, and a set of APIs (this time provided by libuv rather than a browser) that handle the actual I/O work like reading a file or querying a database off the main thread. Every incoming HTTP request, every database call, every file read gets treated as an event that eventually lands in a queue and gets handled once the thread is free.
This is exactly why a single Node process can handle thousands of concurrent connections without falling over. It's not because it's secretly multithreaded. It's because none of those connections are sitting there blocking the one thread while they wait on I/O. The moment a request needs to wait on something slow, it gets handed off, and the thread moves on to the next thing that's actually ready to run.
The short version
- JS has one thread and one call stack. Only one thing executes at a time.
- Slow operations (timers, network calls, file I/O) get delegated to the browser or to Node's underlying C++ layer, not run on your JS thread.
- When that work finishes, its callback doesn't run instantly, it gets placed in a queue.
- The event loop's whole job is checking whether the call stack is empty, and if so, pulling the next thing off a queue to run.
- There are two queues that matter most: microtasks (Promises) and macrotasks (timers, events, I/O). Microtasks always get fully drained before the next macrotask runs.
-
setTimeout(fn, 0)means "as soon as possible after everything currently queued," not "immediately."
Once this model is actually in your head, a lot of things that used to feel like arbitrary JavaScript quirks (why your console.log after a fetch() runs before the data arrives, why a zero-delay timeout still runs last, why Node scales the way it does) stop being quirks and start being predictable consequences of a pretty simple system.
We're ArtClick, a web development agency based in Kyoto. We build company websites, WordPress sites, and custom systems — with a focus on sites that are fast, well-designed, and easy to maintain long-term. Learn more at artclickdev.
Top comments (0)