B5 · Part 1 · Beginner
One thread, one stack
Why JavaScript can only do one thing at a time — and how it still does ten.
08On this page
One thread means one call stack
A thread is one worker following one list of instructions. Give a program two threads and two instructions genuinely happen at the same instant, on two CPU cores. JavaScript gets one. Everything else in this chapter follows from that single fact.
That one worker keeps its place using a call stack: a pile of half-finished function calls. Call a function and a frame is pushed on top — its arguments, its local memory, and the line to return to. Return, and the frame pops off. Whatever is on top is what is running right now; everything under it is waiting for the thing above to finish.
You have already seen this pile printed out, upside down. It is called a stack trace.
Call stack — last one listed is on top
Console
The stack has a size limit, and recursion with no exit finds it in milliseconds:
let depth = 0;
function dive() { depth++; dive(); }
try { dive(); } catch (e) {
console.log(e.constructor.name); // RangeError
console.log("frames before it broke:", depth);
}
Run it. "Maximum call stack size exceeded" is not a mysterious browser complaint — it is this pile hitting the ceiling, a few thousand frames up. The exact number moves with the engine and with how much each frame has to hold.
Why only one thread?
This looks like a limitation to apologise for. It is closer to a trade that was made deliberately, and the reason is sitting on the page in front of you: the DOM.
Imagine two threads running your code at once. One is appending a row
to a table; the other is deleting the table's parent. One is halfway
through reading element.children while the other splices
an element out of it. Every language that allows this hands you the
same toolbox to survive it — locks, mutexes, semaphores — and with it
the family of bugs that comes free: deadlocks, race conditions, and
state that is correct only if you remembered to lock it.
JavaScript was built in 1995 to make web pages interactive, for people who were not systems programmers. Giving it one thread removed that entire category of problem by construction:
-
You never write a lock. There is no
synchronized, no mutex, no atomic counter in everyday JS. - Nothing changes underneath you. Between two lines of your function, no other JavaScript can run. An object is never half updated when you read it.
- Run-to-completion. Once a function starts, it finishes before anything else gets a turn — including a click that arrived in the middle of it.
Worth being precise about what is single-threaded here: your JavaScript is. The browser absolutely is not — it has threads for networking, for parsing, for compositing, for timers, and often a whole process per tab. You get one thread to run code on; the machinery around it is busy on several.
Say it like this → "One thread is the
price of a DOM you can touch without locks. JavaScript traded
parallelism for a world where nothing changes between two of your own
lines."
What blocking actually means
The bill for that trade arrives when one function takes too long. Three things share that single thread, and they take strict turns:
- running your JavaScript
- reacting to input — clicks, typing, scroll
- rendering — style, layout, paint
While a function of yours is on the stack, the other two cannot happen. Not "are slowed down" — cannot happen. The page does not repaint. The button does not depress. The spinner you started does not spin, because spinning it is rendering, and rendering is waiting for you.
console.log("before");
const stop = Date.now() + 800;
while (Date.now() < stop) {} // 800ms of pure blocking
console.log("after");
// In a real page, every click during that gap is frozen —
// and then all of them fire at once the moment it ends.
Anything over about 50ms in one go is officially a "long task" — long enough for a person to feel the page stop responding. That number is measured directly by real-user monitoring tools, covered in Performance.
So how does anything happen at once?
Here is the part that sounds like a contradiction and is not. JavaScript is single-threaded. Your app still loads three APIs while animating a menu and running a timer. Both are true because of one fact people skip:
The whole trick The slow work is not done by
JavaScript.
setTimeout, fetch, file reads,
timers and DOM events are not part of the language — they are
host APIs handed to you by the browser or Node, implemented in
C++ or Rust, running on their own threads. Your thread only
hands the job over and takes the result back.
So an async call is a three-part relay, not a wait:
-
Hand off. Your code calls
setTimeout(cb, 1000). The runtime starts a timer on its own thread andsetTimeoutreturns immediately. Your stack keeps going. - Wait, elsewhere. The timer counts down off your thread. The network waits off your thread. Your thread is not involved and does not care.
- Hand back. When it is done, the runtime cannot just run your callback — that would mean two things on one stack. So it puts the callback in a queue and waits for your stack to empty. The event loop does the moving.
Your thread — the call stack
The runtime's threads — not JavaScript
Callback queue — waiting for an empty stack
Console
The event loop, in one sentence
When the call stack is empty, take the next thing off the queue and push it on. That is the whole loop. It cannot interrupt you, it cannot run two callbacks at once, and it never gets a turn while your code is still on the stack.
One refinement matters from day one: there are two queues, not one.
Promise reactions go into the microtask queue, timers and
events into the task queue, and the loop drains microtasks
completely before touching a single task. That is why a
promise callback beats a setTimeout(..., 0) every time —
there is a step-by-step demo of exactly that ordering back in
Setup & mental model, and
the full rules in
Async, properly.
console.log('start');
setTimeout(() => console.log('timeout done'), 0);
Promise.resolve().then(() => console.log('promise done'));
console.log('end');
Dry run: sync code, a timer, and a promise racing for the thread
| Step | What runs | Call stack | Microtask queue | Task queue | Console |
|---|---|---|---|---|---|
| 1 | console.log('start') | main | empty | empty | start |
| 2 | setTimeout(...) called | main | empty | empty | start |
| 3 | timer handed to the host; returns immediately | main | empty | timer counting, off-thread | start |
| 4 | Promise.resolve().then(...) called — already resolved, reaction queued now | main | [log 'promise done'] | timer counting | start |
| 5 | console.log('end') | main | [log 'promise done'] | timer counting | start, end |
| 6 | script finishes — stack empty | empty | [log 'promise done'] | timer counting | start, end |
| 7 | event loop drains the microtask queue | empty | empty | timer counting | start, end, promise done |
| 8 | timer expires, callback moved from task queue to stack | empty | empty | empty | start, end, promise done, timeout done |
setTimeout(..., 0) does not mean "run next" — it means
"queue this once the stack is empty," and the microtask queue always
drains completely before the loop even looks at the task queue. That
ordering is why the promise callback wins even though it was queued
after the timer was started.
Two consequences that will bite you
setTimeout's delay is a minimum, not a promise
setTimeout(fn, 100) means "not before 100ms". If the
stack is busy at 100ms, your callback waits — the queue has no power
to interrupt. Ask for 0 and you still do not get 0:
const t0 = Date.now();
// The timer expires in ~0ms. Remember when its callback actually runs.
const waited = new Promise((resolve) => {
setTimeout(() => resolve(Date.now() - t0), 0);
});
const stop = Date.now() + 300;
while (Date.now() < stop) {} // hog the thread for 300ms
console.log("blocking done");
console.log("asked for 0ms, actually waited", await waited, "ms");
The timer expired almost instantly. The callback still could not run for 300ms, because the stack was busy — and browsers additionally clamp nested timers to about 4ms, plus much harder throttling in background tabs.
You can starve the loop from the inside
Because microtasks drain completely before anything else, a microtask that queues another microtask forever never gives the loop a chance to reach rendering or events. The tab freezes with its CPU pinned at 100%, and no error is thrown:
// Do not run this one. It freezes the tab.
function spin() { Promise.resolve().then(spin); }
spin();
A plain setTimeout(spin, 0) loop does not do this — tasks
yield between turns, so rendering and clicks still get in. The
difference between those two loops is the clearest test of whether
someone actually understands the two queues.
When you really do need a second thread
Async solves waiting — for a network, a disk, a timer. It does nothing for work. Wrapping a 3-second calculation in a promise does not move it off the thread; it still blocks for 3 seconds, just with extra syntax.
For actual computation there is a real answer: a Web Worker is a genuine second thread with its own stack and its own memory.
// main.js — stays responsive the entire time
const worker = new Worker("heavy.js");
worker.postMessage({ rows: 2_000_000 });
worker.onmessage = (e) => render(e.data);
// heavy.js — a separate thread. No document, no window.
onmessage = (e) => postMessage(crunch(e.data));
The catch is the same one the single-thread design was protecting you from: a worker cannot touch the DOM, and it does not share variables with you. You communicate by copying messages across. That restriction is not an oversight — it is what keeps the no-locks guarantee true. (This site's own code playground runs your exercises in a worker, which is why an infinite loop in an exercise does not freeze the page you are reading.)
| Problem | Reach for |
|---|---|
| Waiting on network, disk, a timer, user input | async — promises, await, callbacks |
| Heavy computation: parsing, image work, crypto, big loops | a Web Worker |
| A long job that must touch the DOM | split it into chunks and yield between them |
Say it like this → "JavaScript runs on
one thread, but it does not do the waiting. It hands slow jobs to the
runtime, keeps running, and the event loop pushes each finished
callback back onto the stack the moment the stack is free."
What to carry forward
- One thread, one stack. A stack trace is that stack, printed.
- One thread is the price of a lock-free DOM — and the reason nothing changes between two of your lines.
- Async is not parallel JavaScript. It is your thread delegating and collecting later.
- A callback cannot run until the stack is empty, so long functions delay everything — including rendering.
- Real parallelism for real work means a Worker.
Interview answer, one sentence
"One thread means one call stack, so a long synchronous task blocks everything, rendering included; asynchronous APIs hand the waiting to the host and the event loop feeds their callbacks back once the stack is empty, and CPU-heavy work belongs in a Web Worker."
Before you move on
- Read a stack trace as a printed call stack, and say which frame was running when the error was thrown.
- Explain why one thread lets you write DOM code with no locks — what specifically would break with two.
- Trace the console output order for a snippet mixing a synchronous log, a
setTimeout(fn, 0), and aPromise.then. - Explain why
setTimeout(fn, 0)does not mean "run immediately," in terms of the call stack and the task queue. - Say when async is the wrong tool and a Web Worker is the right one — and why wrapping a slow loop in a promise does not fix it.
Opens in the editor: write it, run it, and check it against real tests.
All 35 for this level →Finished reading?
0 of 41 chapters read in this series