Skip to the chapter
←JavaScriptGGroundworkJSJavaScript
JavaScript/Beginner/B5
5 / 41 written chapters
/

The cover
  1. B1Setup & mental model
  2. B2Execution context
  3. B3var, let & const
  4. B4Scope
  5. B5Single thread
  6. B6In the browser
  7. B7Types & values
  8. B8Strings & Math
  9. B9Operators & flow
  10. B10Functions
  11. B11Objects & arrays
  12. B12this
  13. B13Closures
  14. B14DOM & events
  15. B15Callbacks & promises
  16. B16Errors & tools
  17. B17Guided project
  18. ★Cheat page
  1. I1Scope & functions, properly
  2. I2Objects deeply
  3. I3Prototypes & OOP
  4. I4Async, properly
  5. I5Modules & tooling
  6. I6Regex & dates
  7. I7Error handling
  8. I8Real-time connections
  9. I9Offline & storage
  10. I10Browser APIs
  11. I11Modern JS
  12. ★Cheat page
  1. A1Engine & memory
  2. A2Advanced async
  3. A3Metaprogramming
  4. A4Types & data
  5. A5Patterns & architecture
  6. A6Performance
  7. A7Security
  8. A8Ecosystem
  9. A9Testing
  10. A10Observability
  11. A11Interview bank
  12. A12Build it yourself
  13. ★Cheat page

JSB5 · Part 1 · Beginner

One thread, one stack

Why JavaScript can only do one thing at a time — and how it still does ten.

10 min read1 diagram8 sections2 exercises
5 of 41

In this chapter

  • One thread means one call stack
  • Why only one thread?
  • What blocking actually means
  • So how does anything happen at once?
  • The event loop, in one sentence

and 3 more

about 10 min
08On this page
  1. One thread means one call stack
  2. Why only one thread?
  3. What blocking actually means
  4. So how does anything happen at once?
  5. The event loop, in one sentence
  6. Two consequences that will bite you
  7. When you really do need a second thread
  8. What to carry forward

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.

The call stack — push, push, throw, unwind
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:

  1. running your JavaScript
  2. reacting to input — clicks, typing, scroll
  3. 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:

  1. Hand off. Your code calls setTimeout(cb, 1000). The runtime starts a timer on its own thread and setTimeout returns immediately. Your stack keeps going.
  2. Wait, elsewhere. The timer counts down off your thread. The network waits off your thread. Your thread is not involved and does not care.
  3. 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.
The handoff — where async work actually goes
Your thread — the call stack
The runtime's threads — not JavaScript
Callback queue — waiting for an empty stack
Console

YOUR THREAD the call stack one frame at a time run to completion RUNTIME THREADS timers · network disk · DOM events genuinely parallel CALLBACK QUEUE finished work, waiting its turn hand off the slow job → done → queue it event loop: moves one across, and only when the stack is EMPTY Nothing ever jumps the queue. A busy stack delays every callback behind it.
One thread, plus a runtime that is not shy about using several.

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

StepWhat runsCall stackMicrotask queueTask queueConsole
1console.log('start')mainemptyemptystart
2setTimeout(...) calledmainemptyemptystart
3timer handed to the host; returns immediatelymainemptytimer counting, off-threadstart
4Promise.resolve().then(...) called — already resolved, reaction queued nowmain[log 'promise done']timer countingstart
5console.log('end')main[log 'promise done']timer countingstart, end
6script finishes — stack emptyempty[log 'promise done']timer countingstart, end
7event loop drains the microtask queueemptyemptytimer countingstart, end, promise done
8timer expires, callback moved from task queue to stackemptyemptyemptystart, 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 a Promise.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.

02Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 8 testsRead a stack trace back into a stackbeginner
  • 8 testsChunk a long job so it stops blockingbeginner
All 35 for this level →
B5

Finished reading?

0 of 41 chapters read in this series

→Next stop

  1. B4Previous · B4Scope
  2. B5you are here
  3. Next · B6Inside the browserFrom a script tag to pixels on screen — and where your code sits in that.8 min read

01On this page

8 sections · 0%

  1. 01One thread means one call stack
  2. 02Why only one thread?
  3. 03What blocking actually means
  4. 04So how does anything happen at once?
  5. 05The event loop, in one sentence
  6. 06Two consequences that will bite you
  7. 07When you really do need a second thread
  8. 08What to carry forward

[] chapters · t top