Part 2: What "Single-Threaded" Actually Means
What no one teaches you about the JavaScript Event Loop — Part 2 of 8
A new puzzle
In Part 1, an infinite loop froze the page. The click vanished. We left with a question: what does "JavaScript is single-threaded" actually mean, mechanically?
Here's a sharper version of the same puzzle. Open two browser tabs.
In Tab A, paste this in the console:
while (true) {}
Tab A is now frozen solid. The page won't respond. Switch to Tab B — any other tab in the same browser. Scroll. Type into a search box. Click links.
Everything works.
This should be strange. Tab A is doing something pathological — a tight infinite loop. It's using the CPU as hard as it can. By the naive picture, "the CPU is busy with Tab A's loop," so the rest of your computer should slow down. But Tab B is fine. Your music keeps playing. Your mouse cursor still moves smoothly. Your code editor in another window responds instantly.
Whatever Tab A is hogging, it's not the CPU as a whole. It's something much more specific.
Three questions:
What exactly is Tab A hogging that Tab B doesn't have access to?
Who decided that Tab A and Tab B shouldn't share whatever it is?
And the philosophical pebble in the shoe: every JavaScript tutorial says "JavaScript is single-threaded." But a thread isn't a JavaScript concept. JavaScript the language doesn't define threads. So whose thread is it, where did it come from, and why does JavaScript only get one?
By the end of this post, you'll be able to answer all three with mechanism — not vocabulary.
What you'll hold by the end
After Part 2:
A thread is a kernel-level data structure with a saved state. The CPU is the agent that runs from a thread's saved state.
A process is a group of threads with their own isolated memory.
The OS, despite being itself a program, supervises other programs without needing its own CPU — via hardware interrupts and a privilege bit in the CPU.
"JavaScript is single-threaded" is a precise claim about one specific thread in one specific process, not a claim about the whole computer.
What this part does not cover:
How the thread sleeps when it has nothing to do (Part 3 — and this is where the click puzzle finally cracks open).
The
epollmechanism that lets one thread wait on many things at once (Part 3).V8, where JavaScript actually runs (Part 4).
The event loop itself (Part 5).
A reframe before we start
You probably picture a thread as something that does the running. A line of execution moving through your code, like a runner on a track. That's how thread is usually introduced.
This picture is upside down, and it's the source of more event-loop confusion than any other single intuition. Let me state the right framing before introducing the mechanism:
A thread is data. The CPU is the agent. The OS is the orchestrator that moves thread data onto and off of the CPU.
The thread doesn't run. The CPU runs from the thread's saved state. When the OS swaps threads, the CPU keeps doing exactly what it was doing — fetch, decode, execute — but the state it's executing from changed. Nothing inside the thread is alive. The thread is a record sitting in kernel memory, like a paused video frame. The "movement" comes from the CPU reading and writing that record.
If this feels wrong right now, hold it loosely. The mechanism we're about to walk through is what makes the reframe true.
A thread, mechanically
On Linux, a thread is a struct called task_struct. It lives in kernel memory. You can read its definition in the Linux source at include/linux/sched.h — the real struct has hundreds of fields, but here are the ones that matter for our purposes, simplified:
struct task_struct {
pid_t pid; // thread ID
long state; // RUNNING | READY | SLEEPING | ...
struct thread_struct thread; // saved CPU register values
// includes:
// - saved instruction pointer (RIP/PC)
// - saved stack pointer (RSP/SP)
// - saved general-purpose registers
// - flags
// - FPU/SIMD state
void *stack; // pointer to this thread's stack region
// (usually 8 MB of virtual memory)
struct mm_struct *mm; // pointer to this thread's address space
// (shared with sibling threads in the
// same process; we'll meet this soon)
struct files_struct *files; // open file descriptors
int prio; // scheduling priority
struct task_struct *parent;
// ... many more fields ...
};
Read this carefully. A thread is not a thing that runs. A thread is a record of how to resume running. It contains:
An instruction pointer — the address of the next instruction the CPU should fetch when this thread is on the CPU. When the thread isn't running, this value is saved in the struct.
Saved register values — what the CPU's registers held the last time this thread was running. When the OS wants to put this thread back on the CPU, it loads these values back into the actual CPU registers.
A stack — a region of virtual memory (typically 8 MB on Linux) where the thread's call frames, local variables, and return addresses live.
Scheduling metadata — what state the thread is in, its priority, how much CPU time it's accumulated.
Pointers to its process — which memory layout, which open files, which permissions.
Here's the model in a picture:
Three threads, one CPU. Only one thread's state is currently loaded into the CPU's registers. The others are records sitting in kernel memory.
When the OS decides to switch which thread is on the CPU, it:
Saves the current CPU register values into the currently running thread's struct.
Picks another thread (one in state
READY).Loads that thread's saved register values into the CPU registers.
Lets the CPU keep doing fetch-decode-execute — but now
RIPpoints into the new thread's code, andRSPpoints at the new thread's stack.
The CPU has no idea anything happened. From the CPU's perspective, it just kept doing its eternal cycle. It was reading instructions from one memory region; now it's reading from another. The "thread switch" is bookkeeping done by OS code that ran briefly between the two.
This is why I called the thread "data" and the CPU "the agent." The thread is what the CPU is reading from. Swap the data, and the same agent now appears to be running a different program. The agent didn't change. The agent doesn't know.
Etymology note. The word thread comes from "thread of execution," a phrase used in computer science papers in the 1960s to describe one continuous path through a program. The metaphor was a thread weaving through code — at any point, you could follow one strand from start to finish. The word stuck. It's worth knowing that "thread" originally described what we'd now call the trace of an execution (the path), not the struct (the saveable state). Modern usage conflates them. When we say "thread" in this post, we mean the struct.
(Windows uses a slightly different naming: the thread struct is called KTHREAD in the kernel and ETHREAD in the executive layer. Same idea. ARM CPUs use PC instead of RIP for the instruction pointer, and SP instead of RSP for the stack pointer. Same idea. The specifics differ; the mechanism is universal.)
A process, mechanically
A thread is the unit the OS schedules. A process is something different: a process is a container that one or more threads live inside.
The key thing a process provides is memory isolation. If process A and process B are running on the same machine, process A cannot read process B's memory. They can both be using the address 0x400000, and the bytes they see at that address will be completely different — because each process has its own view of memory.
Mechanism: every process has its own page table. The page table is a kernel data structure that maps virtual addresses (what the program sees) to physical addresses (actual RAM cells). When the CPU is running process A, the CPU's memory accesses go through process A's page table. When the OS switches to process B, it swaps the CPU's page-table pointer, and now memory accesses go through process B's page table.
The CPU has a special register holding the current page-table pointer. On x86, it's called CR3 (control register 3). When the OS switches processes, it executes a single instruction to update CR3, and instantly the CPU is operating in a different memory universe.
(Both processes share the same physical RAM chip. They just see different parts of it via their respective page tables.)
So a process is:
An address space (a page table mapping virtual to physical memory)
A set of open files (the
files_structwe saw intask_struct)Permissions, environment variables, etc.
One or more threads that all share the above
Threads in the same process share memory. Two threads in process A both see process A's address space — they can write to the same variables. (This is also why multi-threaded programming needs synchronization: two threads can race to update the same memory.)
Threads in different processes do not share memory. Process A's threads have one address space; process B's threads have another. They can't see each other's variables, even with the same address.
Etymology note. Page table is named after the unit it maps. Memory is divided into fixed-size chunks called pages (typically 4 KB on x86 systems). The table maps each virtual page to a physical page. The word "table" is historical — the actual data structure is a multi-level tree on modern systems (because flat tables would be too big), but the name stuck from the 1960s. So "page table" really means "tree-structured map from virtual pages to physical pages." If you only remember one thing about the name: pages are the unit, and the structure maps virtual pages to physical pages.
Back to the puzzle (partial)
Now we can half-answer the opening puzzle.
When Chrome opens Tab A and Tab B, it creates two separate OS processes. Not threads — processes. Each has its own page table. Each has its own threads. Tab A's threads cannot see Tab B's memory.
The infinite loop in Tab A runs on one specific thread in Tab A's process. That thread is now perpetually executing while (true) {}. The OS still schedules this thread onto a CPU core periodically — and every time it does, the thread eats its time slice doing exactly nothing useful. But this is happening inside Tab A's process. Tab B's process is unaffected. Tab B's threads keep getting their time slices and using them normally.
You can verify this on your own machine right now. In Chrome, open a new tab and navigate to chrome://process-internals. You'll see a "Frame Trees" page listing all the renderer processes. Each tab is generally one process. (Chrome calls this site isolation — cross-origin frames sometimes get their own processes too, for security. The number of renderer processes is often higher than the number of tabs.)
On macOS, open Activity Monitor. On Windows, Task Manager → Details tab. On Linux, run ps aux | grep chrome. You'll see one row per Chrome process. Each is a separate task_struct-set with its own address space. The browser is not one program — it's a constellation of cooperating processes.
So part of the answer to "what is the while(true) hogging" is now clear: it's hogging a thread inside Tab A's process. The hogging doesn't reach Tab B because they don't share threads or memory.
But this is only part of the story. We still haven't explained how the OS can fairly let multiple programs run, given there are only a few CPU cores. And we haven't explained the deeper question: how does the OS supervise programs at all, given that the OS is itself a program?
Those next.
Time-slicing
Your laptop has, let's say, 8 CPU cores. Right now there are probably 300 processes running on it. (Run ps aux | wc -l on Linux/Mac to count yours.) That's about 40x as many processes as cores. And each process has several threads — so there are likely a few thousand threads.
A CPU core can only execute one thread at a time. So 8 cores can only run 8 threads simultaneously. Where does the apparent parallelism come from?
The OS rotates threads onto cores rapidly. It runs Thread A on Core 1 for a few milliseconds, then swaps in Thread B, runs it for a few milliseconds, then swaps to Thread C. Each thread gets brief turns. From the user's perspective, it looks like everything is running concurrently — because the swapping is fast enough that no thread has to wait long.
Mechanism. The OS configures a hardware timer to fire periodically. On Linux, this is typically every 1 to 4 milliseconds, depending on kernel configuration (CONFIG_HZ — 250 Hz means every 4ms, 1000 Hz means every 1ms; modern desktops are usually 1000 Hz). Every time the timer fires, the CPU drops what it's doing and runs an OS function called the scheduler. The scheduler decides:
Has the current thread used up its time slice? If yes, save its state and pick another runnable thread.
Has a higher-priority thread woken up? If yes, switch to it.
Otherwise, keep running the current thread.
Then the scheduler returns, and either the same thread resumes or a new thread starts running.
The actual Linux scheduler (called CFS, the Completely Fair Scheduler) is more sophisticated than "round-robin every 1ms." It tracks how much CPU each thread has consumed, favors threads that haven't run recently, and adjusts priorities. The source is in kernel/sched/fair.c in the Linux source tree if you want to read it. But the principle is what matters here: threads take turns, controlled by a periodic timer interrupt, and which one runs next is the scheduler's decision.
This solves the math problem of "many threads, few cores." With 1000 timer interrupts per second and quick decisions about who runs next, the OS can keep every runnable thread making progress without any thread waiting too long.
But — and this is the question that should be bothering you — the OS is itself a program. How does it run the scheduler if the CPU is busy running Tab A's while(true)? Where does the OS itself live? When is it on the CPU?
But wait — the OS is a program too. When is it actually running?
Stop and notice something.
We just described the OS "running the scheduler every 1ms." But the OS isn't magic. The OS is a program — its code is bytes in RAM, just like Tab A's code. To run, its RIP has to be pointing at its instructions, and the CPU has to be in fetch-decode-execute mode on those bytes.
If the OS is a program, then while Tab A's while(true) is running, the OS is not running. The CPU is busy fetching Tab A's instructions, decoding Tab A's instructions, executing Tab A's instructions. There's no second CPU. There's no third agent watching from the side. So when the timer fires, what physically happens to make the OS start running?
This was, for me personally, the question that made everything else click. I'd been imagining the OS as a separate watcher, somehow always alive in the background. The reality is much weirder and much more elegant: the OS isn't always running. The OS gets invoked, repeatedly, by hardware events. And while it isn't invoked, your program owns the CPU completely.
The mechanism has two parts: interrupts and privilege levels.
Interrupts
The CPU has physical wires going to other hardware components. These wires are called interrupt lines. The timer chip on your motherboard has one. The keyboard controller has one. The mouse has one. The network card has one. So does every other piece of hardware that wants the OS's attention.
When the timer chip wants to fire (say, every 1ms), it raises the voltage on its interrupt line. The CPU is hardwired to respond to this. It can't ignore it. It can't be programmed not to listen. The wire goes high → the CPU drops what it's doing.
Specifically, when an interrupt line fires:
The CPU finishes its current instruction (so we don't leave things half-done).
The CPU saves the current
RIPvalue somewhere safe.The CPU looks up a memory address in a special table called the IDT (Interrupt Descriptor Table). The IDT has entries indexed by interrupt number — entry 32 is for the timer, entry 33 might be the keyboard, etc.
The CPU sets
RIPto whatever address was in that IDT entry.The CPU continues fetch-decode-execute from the new
RIP.
The address in the IDT entry was set by the OS at boot. It points to OS code. So when the timer fires, the CPU automatically jumps from your program's code into the OS's interrupt handler. The OS handler is now running. Briefly.
When the handler finishes, it executes a special instruction called IRET (Interrupt Return). This restores RIP to wherever it was before the interrupt, and the CPU continues from where your program left off.
The OS isn't "running in the background." The OS is sleeping (so to speak) between interrupts, doing nothing. The CPU is fully owned by your program. When the wire goes high, control yanks into the OS, briefly. The OS does its bookkeeping. Then control yanks back out.
This is the answer to "how does the OS supervise without its own CPU." It doesn't supervise continuously. It supervises in bursts, triggered by hardware. The hardware forces the CPU into OS code at fixed intervals (timer) and on demand (other devices). Between forces, the user program runs unchecked.
Etymology note. Interrupt Descriptor Table — "interrupt" because it's the mechanism for interrupting the CPU, "descriptor" because each entry describes (with permissions and a handler address) how to respond, "table" because it's an array indexed by interrupt number. The IDT lives at a fixed address in kernel memory; the CPU is told where via a special instruction called LIDT (Load IDT) executed during OS boot. The structure dates back to the Intel 80286 (1982), refined in the 80386 (1985).
The Linux interrupt handlers are in arch/x86/kernel/idt.c and the assembly entry points are in arch/x86/entry/entry_64.S. If you want to see exactly what code the CPU jumps to when an interrupt fires, that's where to look. Note that line numbers shift between kernel versions — the file paths are stable, but if you want exact lines, check against your specific kernel version.
Privilege levels
There's a second part of the mechanism: the CPU has a privilege bit (actually two bits, but only two of the four values matter on modern systems).
Kernel mode (called ring 0 on x86): the CPU can do anything. Execute any instruction, access any memory, talk to any hardware.
User mode (called ring 3 on x86): the CPU is restricted. Some instructions are forbidden — and if user code tries them, the CPU raises an exception (which is itself an interrupt, jumping into OS code to handle the violation). Some memory ranges are unreadable. Direct hardware access is forbidden.
Your program — Tab A, Tab B, your editor, everything — runs in user mode. The OS runs in kernel mode.
When an interrupt fires, the CPU automatically switches to kernel mode while jumping to the IDT handler. The OS handler now has full power. When the handler returns via IRET, the CPU automatically switches back to user mode. Your program resumes with its restricted privileges.
This is how the OS is in charge despite being a program. It's not in charge by being bigger or more important — it's in charge by being the only thing that runs in the privileged mode. Your program literally cannot do certain things even if you wrote machine code to try. The CPU itself refuses. The OS, running in ring 0, can do those things.
Etymology note. Kernel is from the metaphor of "the central part of a seed or nut." The OS's most-privileged code is its kernel — the inner, protected core. The term predates Unix; Multics used it in the 1960s; Unix popularized it in the 1970s. Today every major OS has a kernel — Linux, the Windows NT kernel, the XNU kernel underlying macOS. Each is the privileged ring-0 part of the OS, distinct from the user-mode utilities that run on top.
Myth bust: "The OS is always running in the background, watching what programs do."
It isn't. The OS is a program. It runs only when invoked, and it's invoked by hardware interrupts and by user programs explicitly asking for OS services (via syscalls — which we'll meet properly in Part 3). Between invocations, your program owns the CPU completely. The illusion of constant supervision comes from the timer interrupt firing about a thousand times per second. The OS gets thousands of brief turns to do its bookkeeping. That's frequent enough to feel like a watcher, but mechanically it's a series of small interventions, not a continuous presence.
A first-person admission
I spent years thinking the OS was a kind of background presence — always there, watching, supervising. The reality is that the OS is no more "always running" than your text editor is. It runs in bursts, triggered by hardware. The illusion of constant supervision is just timer-interrupt frequency: about a thousand interventions per second, each lasting microseconds. When I finally accepted "the OS is just a program with extra privileges, invoked by hardware," everything about how it relates to user code became reasonable.
The context switch, end to end
We now have all the pieces. A context switch — moving the CPU from running one thread to another — looks like this:
The timer wire fires.
CPU finishes its current instruction. Saves the current
RIPto a kernel stack. Looks upIDT[32]. Loads the address found there intoRIP. Switches to kernel mode.CPU is now executing the OS's timer-interrupt handler.
The handler saves the rest of the current thread's CPU registers (general-purpose registers, flags, FPU state) into the thread's
task_struct.The handler calls the scheduler (
schedule()in Linux, defined aroundkernel/sched/core.c).The scheduler picks the next thread to run. It walks the list of
READYthreads, applies its fairness algorithm, picks one.If the new thread is in a different process, the scheduler swaps the page-table pointer (
CR3on x86).The scheduler loads the new thread's saved register values from its
task_structinto the actual CPU registers.The scheduler executes
IRET. CPU switches back to user mode. The CPU'sRIPnow holds whatever the new thread's savedRIPwas.The new thread is running. From its perspective, no time passed — it's resuming exactly where it left off, whenever that was.
Total time: typically a few microseconds on modern x86 hardware. The exact number depends on cache state, whether page tables were swapped, etc.
The thread that got swapped out is now in state READY (or SLEEPING if it gave up the CPU voluntarily — Part 3). It will wait in kernel memory until the scheduler picks it again.
"JavaScript is single-threaded"
Now the phrase has machinery behind it.
A Chrome tab is a process. That process has many threads. Real number, observable: typically a few dozen threads per tab.
What does each thread do?
One thread is the main thread (also called the renderer main thread in Chromium docs). This is the thread that runs your JavaScript and manipulates the DOM.
A separate compositor thread handles scrolling and animations smoothly, so the page can still scroll even if main is busy.
Several raster threads turn drawing commands into pixel bitmaps.
A GPU thread sends rasterized layers to the GPU process.
Several V8 background threads for garbage collection and JIT compilation (compiling hot JS to machine code).
Audio threads for Web Audio.
IO threads for filesystem and IndexedDB operations.
And more.
When tutorials say "JavaScript is single-threaded," they mean something very specific:
Within one tab's renderer process, exactly one thread — the main thread — runs your JavaScript and touches the DOM. Two pieces of your JS code never execute simultaneously on different cores. The rest of the threads in the process exist but they don't run your JS.
This is a design choice, not a language constraint.
Why this choice? The DOM is a complex mutable graph. If two threads could mutate it concurrently, you would need locks on every operation — every appendChild, every property assignment. Developers writing JS would have to reason about race conditions: "what if another thread is reading this element while I'm modifying it?" This is famously difficult to get right. The decision: avoid the problem entirely. One thread touches the DOM. No locks. No races. Slower for CPU-heavy work, but far simpler for the developer.
A confusion worth surfacing: Strictly, "JavaScript is single-threaded" is not a fact about JavaScript the language. The ECMAScript specification doesn't mention threads at all. Single-threadedness is a property of how every JavaScript host — browsers, Node.js, Deno — happens to run JS. Different hosts could theoretically choose differently. None do, for the DOM reason above (and equivalent reasons in Node, where shared state would create the same race-condition problem).
JavaScript itself is "single-threaded" only in the sense that there's no Java-style new Thread() API. ECMAScript provides no way to spawn threads from inside JS code. To get parallelism, you have to use the host's primitives — Web Workers in the browser, worker_threads in Node. Both of these spawn actual OS threads, with separate JS environments, communicating via messages instead of shared memory. (Workers can share memory via SharedArrayBuffer, but that's an opt-in mechanism with explicit synchronization primitives — not the default.) Workers are real threads, hidden inside an API that prevents you from accidentally creating race conditions on the DOM.
For a long time I thought of "single-threaded" as a property of JavaScript the language. It isn't. The ECMAScript specification doesn't mention threads at all. Single-threadedness is a choice made by every JavaScript host, for one specific reason: nobody wants to put locks on the DOM. That insight reframed everything for me — JavaScript isn't single-threaded; the hosts that run it happen to give it one thread.
Closing the puzzle (partially) and what's left
Back to Tab A and Tab B. We can now answer the three opening questions:
What is Tab A's
while(true)hogging? The main thread of Tab A's renderer process. That one thread is stuck in the loop. Every time the scheduler gives it a turn on a CPU core, it spends the entire time slice inwhile(true). Nothing else runs on that thread.Why doesn't this affect Tab B? Tab B is a separate process. It has its own threads, including its own main thread. The scheduler distributes CPU time across all runnable threads. Tab B's main thread continues to get turns. Tab A's pathological thread also gets turns, but they're wasted.
Why does the rest of your computer keep working? Same reason. The OS schedules thousands of threads. The runaway loop is one greedy thread. The other thousands keep getting scheduled.
So the loop "hogs" only one thread in one process. Everything else continues. This is what "single-threaded" actually means in practice — it means a runaway computation in one tab can't take down anything else.
But — and this is the cliffhanger — we still haven't fully solved the click puzzle from Part 1.
When Tab A is not running pathological code, when it's just sitting idle — say, a tab open to a static page, no JavaScript actively running — what is its main thread doing? It can't be looping forever like Tab A's while(true); idle tabs use no measurable CPU. Open your task manager. An idle tab uses 0% CPU. Zero.
If the thread isn't running, where is it? Threads don't disappear when they're idle — they're still listed in task_struct records in the kernel. They have a state. What state?
There's a state we haven't discussed: sleeping. A thread can ask the OS, "I have nothing to do. Wake me when something happens." The OS then sets the thread's state to SLEEPING and stops scheduling it. The thread consumes zero CPU. When the something happens — a click, a network response, a timer expiring — the OS wakes the thread, sets it to READY, and eventually schedules it again.
This is the mechanism by which an idle tab uses zero CPU but still responds to clicks. It's the mechanism by which await fetch(url) doesn't burn cycles while waiting for a response. It's the mechanism on which every event loop in every modern runtime is built.
And the way the thread asks "wake me when any of these things happens, where the list of things might have thousands of entries" — that's the engineering trick that took the industry until the 2000s to get right. It has a name. It has a syscall. It is, mechanically, the foundation of every server, every browser tab, every Node.js process you've ever run.
That's Part 3.
What you should hold from Part 2
Three things, only:
A thread is data, not an agent. A
task_struct(or equivalent) in kernel memory, with saved register values and a stack. The CPU runs from this state; the OS swaps threads by saving and loading these structs.A process is a memory boundary. Each process has its own page table. Threads inside a process share memory; threads in different processes don't. A Chrome tab is a process.
The OS runs in bursts, not continuously. Triggered by hardware interrupts (timer, keyboard, network, etc.) and by syscalls. The CPU's privilege bit (ring 0 vs ring 3) is what makes the OS in charge — it's the only thing that runs with full hardware access. Between bursts, your program owns the CPU.
The puzzle isn't fully solved. We know now that Tab A's runaway loop only affects Tab A's main thread. We don't yet know what an idle thread is — what state it's in, how it gets woken up, how the click in Part 1 found its way back to the listener. Part 3 is where that resolves.
Next: Part 3 — Where Threads Sleep.
