1.3. Why JavaScript has one thread
Everyone says "JavaScript is single-threaded." Almost nobody explains why. This is the story of how a shortcut taken in 1995 became a rule the whole web still lives by, and where it can still bite you.
Ten days and a borrowed idea
In May 1995, Brendan Eich had ten days to make web pages scriptable. Among the many things he had to decide was a basic one: when does the code actually run?
He didn't invent an answer. He borrowed one from HyperCard, Apple's friendly scripting tool for non-programmers. In HyperCard you drew cards with buttons on them and attached little scripts that started with on mouseUp. You didn't run the program. The program ran you, calling your script whenever the user clicked.
That was the perfect fit for a language meant to make pages react to clicks, built by one person in a hurry. So JavaScript was never designed as a program that runs. It was designed as a set of handlers that get called.
A traditional program has a main() that sits in a loop, waiting for things to happen and deciding what to do next. JavaScript has no main(). That loop still exists, but it lives inside the browser. The event loop is the main() you didn't write.
This has a quiet consequence. Every time your handler returns, your call stack empties completely. There's nowhere to "stay" between events. So in JavaScript, "later" can only ever mean "here's another function to call later." Callbacks say that directly. Promises wrap it in an object. And async/await is clever machinery for pretending your function paused, when really it ended and a new one started.
The browser lends you its thread
A thread is a single line of execution: one call stack, doing one thing at a time. Programs can have many threads running at once on different CPU cores, sharing memory. That's powerful and also a famous source of bugs. Here's the classic one, in Java:
// Two threads, one account, balance of 100.
if (balance >= n) { // thread A checks: fine
// ← thread B sneaks in here, also checks: fine
balance -= n; // both subtract. Balance is now -100.
}
Nothing in that code is wrong. The operating system simply switched threads at the worst moment. Preventing this is what locks are for, and getting locks right is most of what makes threaded programming hard.
In Netscape, as in browsers generally, the page was most likely drawn and updated on one main thread, even if other work happened elsewhere. That would have been the thread that noticed your click, and so the thread that ran your onclick handler. The browser didn't create a new thread for you. In effect, it lent you the one it had, and took it back when your function returned.
That single fact has two sides.
The good side: nothing can interrupt you. While your code is running, no other JavaScript on the page runs. Not a timer, not another click, not a network response. They all wait. So the same bank-balance code is perfectly safe in JavaScript:
let balance = 100;
function withdraw(n) {
if (balance >= n) {
balance -= n; // nothing can run between these two lines
}
}
That's why you've never written a lock in JavaScript. The browser has been holding the biggest lock in the building for you, for free.
The bad side: nobody else can run either. Try this with a page open:
const end = Date.now() + 5000;
while (Date.now() < end) {} // freeze for five seconds
For five seconds, nothing scrolls, no button responds, no animation moves. Drawing the next frame is just another job waiting in line behind you. Every performance tip you've heard about "not blocking the main thread" is this one fact in disguise.
It's worth being precise here. The browser isn't single-threaded at all. Networking, image decoding and screen compositing all happen on other threads. When you await fetch(url), the download genuinely happens in parallel. What's single-threaded is the moment your code gets to see the result, because that result has to wait in line for its turn.
How a habit became a law
At first, none of this was a promise. It was just how Netscape 2 happened to work.
But developers write code based on what they observe, and what they observed was that scripts were never interrupted. So they stopped worrying about it. Every page and library written over the next few years quietly assumed it. Before long, changing the behavior would have broken everything.
The official history of JavaScript describes the shift in one line: this characteristic of early browsers became a fundamental principle of JavaScript. Nobody voted on it. It was settled by use.
The rule has a name: run to completion. Each unit of work, called a turn (one event handler, one timer callback, one script), always finishes once it starts.
The DOM slams the door
For a couple of years, adding real threads was still possible in theory. What made it impossible wasn't JavaScript itself. It was the web page.
The DOM is full of live views that change as the page changes. el.children isn't a snapshot; it updates in real time. Now imagine another thread adding a child while you're looping over that list. Do you skip one? Visit one twice? And it gets worse: setting a single property can trigger layout, run custom element code and fire observers. Making all of that safe across threads would mean locks everywhere, with the risk of two threads each waiting forever on the other.
Then Microsoft arrived. Its own JavaScript implementation, JScript, shipped in Internet Explorer 3 in 1996, and in 1997 Internet Explorer 4 made nearly every element on the page scriptable. Now there were two big, competing implementations, both built around single-threaded access to the page. Adding thread safety would almost certainly have cost performance, and in a fierce browser war, the first vendor to do it would likely have paid that cost alone. As far as we know, nobody ever tried. From then on, the one-thread model for the DOM looks to have been effectively permanent. Even Servo, a modern engine built specifically for parallelism, runs styling and layout in parallel but still gives each page's scripts a single thread, most likely because the web's existing code depends on it.
Workers: threads, but no sharing
In 2009, browsers finally gave the web real threads, called Web Workers. A worker is a genuine OS thread running on another core, right now:
const w = new Worker("./worker.js");
w.postMessage({ rows: bigArray });
w.onmessage = (e) => console.log(e.data);
This is a good moment to separate two words that get mixed up. Concurrency means several tasks are in progress at once, even if they take turns on one thread. That's what JavaScript had always done. Parallelism means tasks literally run at the same instant on different cores. Workers are parallelism.
But the DOM problem hadn't gone away, so workers got a deal: you get a real parallel thread, but no access to the page, and no shared variables. Workers talk only by passing messages, and each message is copied. (Some objects, like an ArrayBuffer, can be transferred instead, but that moves ownership to the other side rather than sharing it.) Since nothing is shared, there's nothing to fight over, and no lock to forget.
Promises: the language learns to say "later"
Here's something surprising: from its first edition in 1997 until 2015, the JavaScript language specification had no concept of time at all. No event loop, no queue, no callbacks. Event handlers, setTimeout, AJAX and workers were all defined by browsers, not by JavaScript itself.
What finally forced the language to talk about scheduling was Promise, in 2015. A promise has to guarantee that its .then() callback never runs immediately, always runs after the current turn, and runs in the order it was registered. So the language added the idea of a job: a function queued to run after the current turn finishes.
Even then, the language only says what runs and in what order. The browser (or Node) decides when. That's why promise ordering is identical in every engine, while setTimeout timing isn't. How those two queues fit together is the subject of lecture 4.
The run-to-completion rule was so strong by now that it shaped new syntax. When modules arrived, an import that paused mid-script would have broken the rule. So import was designed to be discoverable just by reading the code, letting the browser fetch every dependency before anything runs. That's why import statements are hoisted, why the path must be a plain string, and why loading a module on demand needs import(), which returns a promise.
Shared memory, and the week it vanished
For some jobs, like video filters or physics simulations, copying data to a worker every frame is too slow. So in 2017 JavaScript added SharedArrayBuffer (memory that multiple threads can touch at once) and Atomics (safe operations on it). With it came all the old threading hazards, back again.
Six months later, on 3 January 2018, the Spectre CPU vulnerability was disclosed. Spectre attacks need a very precise clock. Browsers quickly made performance.now() less precise, but that was pointless if a page could build its own clock. And with shared memory, it could:
// in a worker: a homemade nanosecond timer
const counter = new Int32Array(sharedBuffer);
for (;;) counter[0]++;
Within two days, every major browser had turned SharedArrayBuffer off. Chrome brought it back on desktop later in 2018, relying on its Site Isolation feature. The version you can count on everywhere today arrived in 2020 and 2021: Firefox re-enabled it in July 2020 and Chrome tightened its rules in 2021, both only for pages that opt into strict security settings called cross-origin isolation.
Browsers do remove features now and then, but usually after years of deprecation warnings. Pulling one from every browser within days is almost unheard of, and it took a flaw in nearly every modern processor to make it happen.
The twist: the old bug is back anyway
So you have one thread, run to completion, and no locks needed. Safe, right?
Not quite. Run to completion protects a single turn, and an async function isn't one turn. Every await ends the current turn and starts a new one later:
async function withdraw(n) {
if (balance >= n) {
await audit(n); // ← turn ends. Anything else can run now.
balance -= n; // ← new turn. That check might be stale.
}
}
Call this twice quickly and you've recreated the Java bug from the start of this page, in a language with one thread. So treat every await as a point where the world might have changed, and know what you need to re-check after it.
The same thing can happen without await. dispatchEvent(), el.click() and most custom emit() methods call their listeners immediately, right in the middle of your function. If a listener calls back into the code that fired it, you now have two copies of that function running, one nested inside the other. All those if (this.busy) return guards you see in component code? Those are hand-written locks, in the language that was supposed to make locks unnecessary.
So is JavaScript single-threaded?
Sort of. Here's the honest scorecard:
- "JavaScript is single-threaded." Half true. Each page or worker runs one turn at a time, and that guarantee is real. But a page with workers is running JavaScript on several threads.
- "The browser is single-threaded." False. Networking, decoding, drawing and workers all run elsewhere.
- "The event loop is part of JavaScript." False. The language defines jobs; the browser (or Node) owns the loop.
- "
async/awaitruns code in parallel." False. It takes turns on one thread. Any real parallelism happens underneath. - "Single-threaded means no race conditions." Mostly false.
awaitand synchronous re-entry create the same kinds of bugs.
The whole story comes down to this. Eich borrowed a click-and-respond model from a tool for hobbyists. The browser had one thread to lend, and lent it. Developers noticed it was never taken away mid-script and built everything on that assumption. A competing browser arriving with the same model helped lock the design in. And ever since, every new feature, from promises to modules to shared memory, has had to work around a promise nobody ever meant to make: one thread, one turn at a time, lent to you by the page, as long as you give it back quickly.
Sources
- JavaScript: The First 20 Years, Wirfs-Brock and Eich, HOPL IV, 2020 (§3.6 on HyperCard and run to completion, §21.3.4 on modules)
- MDN: Using Web Workers
- V8 blog: A year with Spectre
- web.dev: Making your website "cross-origin isolated"