Part 4: Where JavaScript Actually Runs
What no one teaches you about the JavaScript Event Loop — Part 4 of 8
A puzzle in DevTools
Open any page in your browser. Open DevTools. Click the Performance tab. Hit the record button (the circle). Click around the page for a few seconds. Click around. Stop recording.
You're now looking at a timeline. Across the top is wall-clock time. Below it, colored bars stack into rows: yellow bars labeled "Scripting," purple bars labeled "Rendering," green bars labeled "Painting," gray bars labeled "System," and wide expanses of pale color labeled "Idle."
Three things to notice:
First: the yellow Scripting bars are small. They appear in bursts when your code runs. They're a few milliseconds wide, sometimes less.
Second: the gray Idle is enormous. Even when you're actively clicking around, the timeline is mostly idle. Your tab spends the vast majority of its life doing nothing.
Third: zoom into a Scripting bar. You'll see entries like "Function Call," "Compile Code," "Garbage Collection," "Minor GC," "Recalculate Style." These entries aren't your JavaScript. These are V8's operations on your JavaScript — V8 parsing your code, V8 compiling hot functions, V8 running garbage collection on the heap that holds your objects.
Three questions:
When that yellow Scripting bar appears, what is the CPU physically executing? Is it your JavaScript text? Compiled machine code? Something else?
What thread is doing the executing? What process is that thread in?
When the bar ends and the gray Idle begins, where does the thread go? It's not running JavaScript. Is it gone? Asleep?
You've been told "JavaScript runs in the browser." That sentence is a closed door. By the end of this part, the door is open and you can name each component standing behind it: the renderer process, the main thread, V8 the library, the bytecode interpreter, the JIT-compiled machine code, the OS thread's stack with V8 frames laid out inside it. Every layer is concrete.
What you'll hold by the end
After Part 4:
The Chrome multi-process architecture: which processes exist, what each does, why.
The renderer process is where one tab's JavaScript runs.
What V8 is structurally: a C++ library, compiled into Chrome's renderer binary. Not a separate program.
Inside the renderer: many threads, of which one — the main thread — runs JavaScript.
V8 doesn't "run JavaScript." V8 runs C++ that interprets JS bytecode, or JIT-compiled machine code V8 manufactured at runtime. JavaScript text is never directly executed.
The "JavaScript call stack" is V8-format frames sitting inside the OS thread's stack.
The ECMAScript spec's "execution context" and V8's "frame" are the same logical thing, two vocabularies.
The network process owns sockets; tabs talk to it via IPC.
Chrome IPC uses Mojo, which on Linux is Unix domain sockets watched by epoll — the same mechanism from Part 3, used between processes.
What's out of scope:
The event loop algorithm itself (Part 5 — the next post).
V8 internals at depth: Ignition bytecode format, JIT tier-up mechanics, hidden classes, inline caches, deoptimization. These are large enough to warrant their own series, which I'll write separately. Part 4 covers V8 at the architectural level only.
Mojo IPC protocol details (interface descriptions, capability passing, type marshaling).
Specific Chromium subsystems (RenderingNG, the Skia graphics library, V8's GC algorithm).
Service workers and dedicated workers (touched briefly; Part 7 deep-dives Web Workers).
Chrome is many processes, not one
When you open Chrome, you're not launching one program. You're launching a constellation of cooperating processes.
The major types:
The browser process (one — the master). Owns the address bar, the bookmark bar, the window chrome, the tab strip. It's the process you started when you clicked the Chrome icon. It coordinates everything else.
Renderer processes (many — typically one per tab, sometimes more). Each renders one tab's content: parses HTML, computes CSS, builds the DOM, runs JavaScript, paints pixels. This is where your code lives.
The GPU process (one). Handles all communication with the GPU. Compositing happens here.
The network process (one). Handles all network I/O — DNS lookups, TCP connections, TLS handshakes, HTTP/2/3, cookies, credentials.
Utility processes (several, as needed). Spawned for specific tasks: audio service, video decoding, storage service, printing.
You can see this for yourself. Open chrome://process-internals in your address bar. On the Frame Trees subpage, you'll see a list of renderer processes, each with a process ID, hosting one or more frames. On macOS you can also open Activity Monitor and search for "Chrome" — you'll see one row per process, distinguished by the --type= flag (--type=renderer, --type=gpu, --type=utility, etc.). On Windows: Task Manager → Details tab. On Linux: ps aux | grep chrome or pgrep -af chrome.
Why this multi-process design? Three reasons, all important:
Security. This is called site isolation. Each website is rendered in its own process, with its own address space. A site cannot read another site's memory — not its DOM, not its JavaScript variables, not its credentials. If site A's renderer is compromised by a JavaScript-level exploit that breaks out of V8's sandbox, the attacker is still in a separate process from site B's renderer; they can't reach across the process boundary to steal site B's data. The network process holds cookies and authentication credentials; a compromised renderer can't extract them, because it has to ask the network process via IPC and the network process gates what each renderer can request.
Cross-origin iframes get their own renderer process too — those are the OOPIFs (out-of-process iframes) you might have noticed in chrome://process-internals if you looked closely. An ad embedded in a page runs in a different process from the page itself.
Stability. A renderer crash takes down one tab. The browser, the other tabs, the network process, the GPU process — all keep running. You see the "Aw, Snap!" page in the one crashed tab and everything else continues. Pre-multi-process Chrome would have brought down the entire browser.
Responsiveness. The browser process — the one that handles the address bar and tab switching — stays responsive even when a renderer is hung. That's why you can close a frozen tab from the tab strip when its content is unresponsive: the close action goes to the browser process, which then kills the frozen renderer. The browser process is never blocked by JavaScript.
A picture of the architecture:
(Plus utility processes for audio, storage, etc., not shown.)
This picture matters because it answers a question that comes up implicitly throughout the series: when JS does network I/O, where does the work actually happen? The answer is: not in the tab's process. The tab sends a request via IPC to the network process, and the network process does the work. We'll trace this in detail later in this part.
The renderer process — where JavaScript lives
Zoom into one renderer process. This is the process for one tab.
A renderer process has many threads. Typically 20 to 40 in a non-trivial tab. You can count them on Linux: find a Chrome renderer's PID, then run ls /proc/<pid>/task/ | wc -l. Each entry is one thread.
The threads break down into roles:
The main thread (also called the renderer main thread in Chromium docs). This is the thread that runs your JavaScript and touches the DOM. It's where the event loop runs. Everything we've been describing for three parts has been happening on this one thread.
The compositor thread. Handles smooth scrolling and CSS animations. It's separate from the main thread so the page can keep scrolling even while the main thread is busy running JavaScript. The compositor thread sends drawing commands to the GPU process.
Raster threads (typically a small pool of 2-4). Turn vector drawing commands (from the compositor) into actual pixel bitmaps for layers.
V8 background threads. V8 does some work off the main thread to avoid blocking it:
Garbage collection has concurrent phases that mark/sweep on background threads while the main thread keeps running.
JIT compilation is done on background threads. When V8 decides to optimize a hot function, the compilation happens in the background, so the main thread isn't paused during compile time. When the compiled code is ready, it's installed and the next call uses it.
The IO thread. Handles incoming IPC messages from other processes. When the browser process sends an IPC ("here's a click event for your tab"), it arrives on this thread, which then posts it to the main thread's queue. The IO thread is small and fast; it doesn't do significant work itself.
Audio thread. If the page is using Web Audio, audio processing happens here on a real-time-priority thread.
Worker threads. If your code uses Web Workers, each worker runs on a separate thread inside this same renderer process. Workers have their own V8 isolate (we'll meet isolates in a moment) and their own JavaScript environment.
Critically: only the main thread runs your JavaScript. The other threads exist, they're real OS threads doing real work in parallel, but they don't run your code. Their jobs are infrastructure that the main thread depends on — but the JS you write only runs on the main thread.
This is what "JavaScript is single-threaded" means with the full specifics: exactly one thread per renderer process — the main thread — runs your JavaScript, touches the DOM, and runs the event loop. Many other threads exist for compositing, rasterizing, GC, JIT, and I/O, but they don't run your JS.
The renderer process is one address space (one page table, recall Part 2). All these threads share that memory. They communicate via shared data structures protected by locks — internal to Chromium's C++. The user-facing model (the JS layer) hides all of this and presents the main thread as the single execution context.
V8 — structurally
V8 is not a separate program.
This is the part of the model that surprises most JS developers, and it's the load-bearing reframe of Part 4. The mental model "V8 runs alongside Chrome" is wrong, and replacing it makes everything downstream simpler.
When Chrome's source code is built, V8's source is compiled and linked into Chrome's binary. The renderer process binary already contains V8's code when it starts. There is no separate V8 process to launch, no IPC between Chrome and V8, no thread that "is the V8 thread." V8 is a C++ library, like any other library. When the renderer process needs JavaScript executed, Chrome's C++ code makes ordinary function calls into V8's code.
For years I imagined V8 as a separate engine running alongside Chrome — like a JavaScript machine sitting next to the browser. The mental picture changed everything when I realized V8 is a library that Chrome's C++ calls into. There is no V8 thread, no V8 process. There is only Chrome's renderer process, with V8's code compiled into it, sitting and waiting to be called.
What V8 provides as a library:
A JavaScript engine. Parser (turns text into AST), bytecode compiler (turns AST into Ignition bytecode), interpreter (runs bytecode), JIT compilers (turn hot bytecode into native machine code).
A heap. Where JavaScript objects live. Separate from the OS thread's stack. Managed by V8's garbage collector.
A microtask queue. Required by the ECMAScript spec for Promise reactions. One queue per V8 isolate. Drained at points the host (Chrome) explicitly invokes.
An embedder API. Functions Chrome calls to evaluate scripts, call functions, read and write JavaScript values, install C++ callbacks visible to JS, etc. The API is in
v8.hif you want to read it.
An isolate in V8 terminology is one independent V8 instance, with its own heap, its own microtask queue, its own GC state. A renderer process has one main isolate for the page's JavaScript, and additional isolates for each Web Worker. Isolates don't share memory; they're like processes-within-V8.
Here's the address space picture for a renderer process:
Everything in one address space. Chrome's code, V8's code, V8's heap, all the threads' stacks. Function calls between Chrome and V8 are ordinary function calls — push some arguments, jump to the function's address. No process boundary, no marshaling, no IPC.
The mental shift this enables: V8 doesn't own anything in the threading model. V8 is invoked. When Chrome's event loop decides to run a JavaScript task, the main thread's C++ code calls into V8, V8 does its work (interpreting, compiling, executing), V8 returns. From the CPU's perspective, RIP wandered from Chrome's C++ into V8's C++ (and possibly into V8-generated machine code in the heap) and back. The thread is still the main thread; the call stack is still on the main thread's stack; the event loop is still Chrome's responsibility.
What V8 actually does with JavaScript
V8 receives JavaScript as text. CPUs don't execute text. So between "your .js file" and "running program," V8 does several transformations. Here's the pipeline at the architectural level:
Walk this with me. When the browser loads a <script> tag:
V8's parser reads the JavaScript text and produces an AST — a tree of JS objects representing the code's structure. The AST is data, sitting in the V8 heap.
V8 compiles the AST to bytecode. Bytecode is V8's own instruction format, not CPU machine code. Each bytecode instruction is a single byte (an opcode) plus operands. The bytecode lives in the heap. The opcodes are V8-specific (load, store, add, call, etc.), and there's a Wikipedia-readable list if you're curious; the format isn't a stable public interface.
V8 executes the bytecode through Ignition — V8's bytecode interpreter. Ignition is C++ code. It's essentially a giant switch statement over opcodes, with one C++ handler per opcode. So when bytecode says "add the values in slots 0 and 1, store in slot 2," Ignition's C++ runs the corresponding adding code, and the CPU is executing Ignition's compiled C++.
While running, V8 counts how often each function is called and how often each loop iterates. Functions called many times are "hot." Loops iterating many times are "hot."
For hot code, V8 invokes one of its JIT compilers:
Sparkplug: a fast non-optimizing baseline compiler. Produces machine code quickly, with minimal optimization. The function gets a small speedup just from skipping interpretation.
Maglev: a mid-tier compiler with simple optimizations. Faster compilation than TurboFan, faster output than Sparkplug.
TurboFan: V8's heavy-duty optimizing compiler. Does inlining, type specialization, escape analysis, lots more. Slow to compile, fast output.
The compilers produce actual x86-64 (or ARM64) machine code. Real instructions that the CPU can execute directly without interpretation. The machine code is written into the V8 heap, in a region marked executable. V8 then patches call sites to jump into the compiled code directly.
The compiled machine code may make type-stability assumptions — for example, "this function has always been called with two numbers, so I'll assume the parameters are numbers and skip the type-check overhead." If at runtime an assumption breaks (someone passes a string), V8 deoptimizes: throws away the compiled code, falls back to running the function via Ignition, and may try to recompile later with different assumptions.
So at any given microsecond, the CPU running "your JavaScript" is actually executing one of these:
Ignition's compiled C++ (the interpreter). The CPU's
RIPpoints into Chrome's renderer binary, specifically into the section that contains V8's Ignition code.JIT-compiled machine code sitting in the V8 heap. The CPU's
RIPpoints into the heap, at machine code V8 produced at runtime.Chrome's compiled C++ when V8 calls back into Chrome (e.g., during a DOM operation, when JS calls
element.appendChild()).Kernel code briefly during syscalls.
There is no fourth thing called "JavaScript executing." Your JavaScript exists as text (the source) and as data (the AST and bytecode in the heap). It is never directly executed by the CPU. The CPU executes V8 — V8's C++ or V8-generated machine code — that produces the effects your JavaScript would have if a CPU could somehow read it directly.
This is what "JavaScript is interpreted / JIT-compiled" actually means. The CPU doesn't read your source. V8 reads your source and either (a) walks the bytecode while running its C++ interpreter, or (b) produces machine code that mimics what your JavaScript would do, and lets the CPU run that machine code. Either way, the CPU is executing V8.
Hold this for the rest of the series. Whenever we say "JavaScript runs," what's literally happening is V8 is running. Either Ignition C++ interpreting bytecode, or JIT-compiled machine code, or runtime function calls into V8. The CPU never "reads JavaScript."
The deep mechanics — what Ignition's bytecode looks like in detail, exactly what TurboFan does during compilation, how hidden classes and inline caches work, when deoptimization triggers, how V8's garbage collector works — are large enough to warrant their own series. I'll write that separately, as the V8 internals series. For our story (the event loop), the architectural fact that V8 manufactures C++ behavior and machine code at runtime is enough.
The "JavaScript call stack" — what it physically is
You've heard "the call stack." DevTools shows it. The ECMAScript specification talks about the "execution context stack." Tutorials say "JavaScript pushes a frame onto the call stack when a function is called." All of these phrases describe the same thing.
Let me make it concrete.
A thread has one stack — recall Part 2. It's a region of virtual memory, typically 8 MB, in the thread's process address space. When the thread is on a CPU, the CPU's RSP register points somewhere into this region. Function calls push frames (return addresses, local variables, saved registers) onto it. Returns pop them off.
When Chrome's renderer-process main thread is doing work, its single 8 MB stack contains all of the following at various times:
C++ frames from Chrome's own code — event loop iteration, DOM operations, layout, etc.
C++ frames from V8's runtime code — the parser, compilers, garbage collector, embedder API.
V8-format JS frames for each currently-executing JavaScript function.
When Chrome's event loop calls into V8 to run a script, the stack might look like this:
The OS sees one stack — one continuous region of memory. The frames sitting on it have different layouts:
C++ frames follow the standard x86-64 calling convention. Compiler-generated frame pointers, register save areas, etc.
V8 JS frames follow V8's own format. They contain pointers to the function being executed, the receiver (
this), the arguments, slots for local variables, and a pointer to the previous JS frame so V8 can walk the JS call chain. The layout differs slightly between Ignition interpreter frames and JIT-compiled frames (TurboFan frames have inlined call frames in some cases), but they all look like "frames stacked atop other frames" to the OS.
V8 knows which sections of the OS stack belong to it because the boundary between C++ and JS is marked when V8 is entered (by the call to v8::Function::Call or similar). When V8 wants to walk the JS call stack — for example, to give DevTools a stack trace — it walks from the current stack pointer up, recognizes its own frame format, and stops when it hits a non-V8 frame (a C++ frame from Chrome). DevTools' "Call Stack" panel is the result of this walk: it shows only the V8 JS frames, hiding the C++ frames between them.
"Stack empty" — a phrase from event loop tutorials — means: V8 has returned from its outermost JavaScript execution. The C++ frame v8::Function::Call(script) has returned. Control is back in Chrome's C++ event loop code. There are no V8-format frames anywhere on the stack. The thread is ready to dequeue the next task and call into V8 again.
I'd been treating "the call stack" as a JavaScript thing — like JavaScript had its own stack somewhere. It doesn't. There's one stack per thread. V8 lays out its frames inside it. The "JavaScript call stack" you see in DevTools is V8's interpretation of which frames belong to your JS code. The stack is the OS's; the frames are V8's.
On execution contexts. If you've read ECMAScript-flavored materials (tutorials, the spec, deeper JS books), you've encountered the term execution context. The spec defines an execution context as a record with fields like LexicalEnvironment, VariableEnvironment, Function, Realm, and so on. The spec says there's an "execution context stack" that pushes when a function is called and pops when it returns.
The execution context (spec language) and the V8 frame (implementation language) are the same logical thing.
The spec describes "an execution context with a LexicalEnvironment field"; V8 implements that as a frame on the OS stack with a pointer to a heap-allocated environment record. The spec says "push the new execution context onto the stack"; V8 implements that as pushing a JS frame onto the OS stack. The spec stack and the V8 frame chain are different vocabularies for the same physical thing.
One subtle implementation detail worth knowing. The LexicalEnvironment and closure scope chain don't actually live in the V8 frame on the stack — they live on the heap. Why? Because closures can outlive their function call. If a function returns a closure that captures local variables, those captured variables need to survive after the function's frame is popped from the stack. So the frame holds a pointer to an environment record on the heap; the environment holds the actual captured bindings. Frames die when the function returns; environments live as long as anything still references them.
This is why JavaScript closures don't crash when the enclosing function returns. The frame goes away, but the environment record sits on the heap, kept alive by the closure's hidden [[Environment]] slot pointing at it.
For async functions — when you await, the function's state is migrated from a (now-popped) stack frame to a heap object that V8 saves as the "suspended state." When the awaited Promise resolves and the function resumes, V8 builds a fresh frame from that heap state. This is why await is so cheap memory-wise: no frame sits around blocking stack space. The state is on the heap, lightweight, until needed.
That's enough V8 internals for Part 4. The point: the call stack is concrete. It's frames in a region of the OS thread's stack. V8 manages the frame layouts. The spec's "execution context stack" describes the same thing in different vocabulary.
How JavaScript reaches the outside world — the network case
We've now mapped where JavaScript runs. Time to use the map.
Take a piece of JavaScript that reaches outside the renderer process:
fetch('/data').then(response => response.json()).then(data => {
console.log(data);
});
This crosses process boundaries. Let me trace what happens, in detail.
Step 1: V8 is executing the script. The CPU is running V8 (either Ignition interpreting bytecode or JIT-compiled machine code from the script). V8 encounters the fetch call.
Step 2: fetch is not a JavaScript function. It's a binding — a function name visible to JS that's implemented in Chrome's C++. When JS calls fetch, V8's runtime looks up the binding and calls the C++ implementation, with the URL string as an argument. The CPU's RIP is now in Chrome's C++ code, no longer in V8's territory.
Step 3: Chrome's C++ fetch implementation does several things:
Validates the URL.
Allocates a fresh Promise object in V8's heap (so JS can
.thenandawaitit). The Promise is initially in state PENDING.Generates a unique request ID for this fetch (an integer).
Stores
{request_id → promise_reference}in a map inside Chrome's C++ memory.Sends an IPC message to the network process: "please make this HTTP request; my request ID is N."
Returns the Promise object to V8.
Step 4: V8 receives the Promise and returns it to the JS code. The fetch call has returned. To JS, the function call took microseconds. No network operation has happened yet from this process's perspective.
Step 5: JS continues. .then(...) is called on the Promise. Internally, this attaches a callback to the Promise's reaction list. The Promise is still PENDING. The script eventually finishes its synchronous part and returns up the stack. V8 returns. Control is back in Chrome's event loop. The main thread now has nothing to do; it goes to sleep in epoll_wait, registered with its IPC channels and any open sockets.
Meanwhile, in the network process:
Step 6: The IPC arrives. The network process's main thread (running its own event loop) wakes up. It reads the IPC: "request ID N, please fetch this URL." The network process allocates or reuses a TCP socket. (Connection pooling exists; if a connection to the host is already open, reuse it. Otherwise create one with socket(), then connect().) It performs the TLS handshake if HTTPS. It sends the HTTP request bytes.
Step 7: The network process's main thread now has nothing more to do for request N. It returns to its event loop and may handle other requests. Its epoll instance watches:
The TCP socket's fd (for the response).
The IPC channels to all tabs (for more requests).
The thread sleeps in epoll_wait. Zero CPU.
Step 8: The remote server processes the request and sends a response. Packets arrive at the user's machine. The NIC asserts an IRQ. The kernel processes the packets. The bytes are appended to the network process's TCP socket's receive buffer. The socket's wait queue is walked. It contains an epoll hook installed by the network process. The hook fires: adds the fd to the network process's epoll ready list, wakes any thread in the epoll's wait queue.
Step 9: The network process's main thread wakes from epoll_wait. Reads the bytes from the TCP socket. Parses the HTTP response. Constructs a response message — headers, body, status. Sends an IPC back to the original tab: "response for request ID N, here's the data."
Step 10: The IPC arrives at the tab's renderer process. Specifically: it lands on the renderer's IPC channel fd, which is registered with the renderer's epoll instance.
The tab's main thread may have been sleeping in epoll_wait (if it had nothing else to do) or running other JavaScript (if some other event was being handled). Two cases:
If sleeping: the IPC arrival → IPC fd's wait queue is walked → epoll hook fires → adds fd to ready list, wakes our thread. Our thread wakes from
epoll_wait.If busy: the wait queue walk finds no thread waiting (we're not in
epoll_waitright now). The fd is added to the ready list. The event sits there. When our current task ends and we next callepoll_wait(0), we'll see the event.
Either way, eventually Chrome's event loop on the main thread receives the IPC event. It reads the bytes. It identifies the request ID N. It looks up the Promise in its map (the one stored in Step 3).
Step 11: Chrome's C++ queues a task: "resolve Promise(N) with this response data."
Step 12: The event loop picks up the task. V8 is invoked. V8 marks the Promise as FULFILLED with the response data. Resolving the Promise queues microtasks for any .then callbacks attached to it (in our case, response => response.json()).
Step 13: After the task ends, the microtask queue drains. V8 runs the first .then callback with the response. That callback calls response.json(), which returns a new Promise (parsing happens in the background, often). The next .then is attached to that Promise, and the cycle continues until all the user's .then callbacks have fired and the data is logged.
Visualized:
A fetch involves at minimum two processes, two epoll instances, two main threads, two trips across IPC, and one trip across the actual network. The JavaScript main thread is asleep for most of the wall-clock time.
This explains a lot:
Why fetch is asynchronous in JavaScript. It can't return data immediately because the data isn't even being fetched by the tab's process. Another process is fetching it, on its own time, then sending the result back.
Why the JS thread can do other things during a fetch. After firing off the IPC, the main thread is free. It returns to the event loop, may process other tasks, and eventually sleeps in
epoll_waitwaiting for the IPC response among other events.Why a
while(true){}after a fetch blocks the response from being processed. The IPC reply will physically arrive at the tab process. The IPC fd will become readable. The wait queue will be walked, the epoll hook will fire, the event will be added to ready list. But the main thread is busy running the loop. The event sits in ready list, just like the click in Part 3. The fetch response is in the kernel's buffers, waiting for our thread to get back toepoll_wait. If the loop never ends, the response is never processed.
Mojo — the IPC layer, briefly
Chrome's IPC system has a name: Mojo. It's the layer above raw OS IPC primitives that Chrome uses for all process-to-process communication.
Architecturally, Mojo provides message pipes. Each message pipe is a bidirectional channel between two endpoints. Each endpoint has a handle held by one process. Each process registers its endpoint with its event loop's epoll instance.
On Linux, a Mojo message pipe is built on top of a Unix domain socketpair (created with socketpair(AF_UNIX, SOCK_STREAM, 0)). The two endpoints are the two halves of the socketpair. Each side has a file descriptor; reading and writing happen via standard read/write syscalls.
Messages carry:
Data: integers, strings, byte arrays, structs.
Handles: file descriptors (for sharing access to specific kernel objects), references to shared memory regions, and references to other Mojo message pipes (so processes can pass channels around). This is Chrome's capability system: a process can only do what it has handles to.
Higher-level Mojo features — interface definition files (.mojom files), serialization, version negotiation, the Service Manager — are all built on top of these message pipes. For our purposes (the event loop story), the architecturally important fact is:
A Mojo channel is a file descriptor watched by epoll. Bytes flow through. The same wake-up mechanism from Part 3 applies. When IPC arrives, the receiving process's main thread wakes via epoll, just like any other I/O. There's nothing magic about cross-process communication at the event-loop level. It's just another fd.
The full implementation is in Chromium's mojo/ directory. The interfaces, the serialization, the capability passing — that's its own world. For the event loop story, "fd + epoll" is enough.
Putting the picture together
Take a step back. Part 4 has built a layered picture:
A Chrome instance is many processes.
One of them — the renderer process — is where one tab's JavaScript lives.
The renderer has many threads. The main thread runs JavaScript.
The main thread runs V8, a C++ library compiled into the renderer binary.
V8 runs JavaScript by parsing it, compiling it to bytecode, interpreting that bytecode with Ignition, and JIT-compiling hot functions to machine code (Sparkplug, Maglev, TurboFan).
The CPU executes V8's C++ or V8's generated machine code; it never executes JavaScript text directly.
The OS thread has one stack; V8 lays out JS-format frames inside it. The "call stack" you see in DevTools is the V8 frames within the OS stack.
The execution context from ECMAScript spec language and the V8 frame from implementation language are the same logical thing.
When JavaScript needs to talk to the outside world (network, GPU, audio, storage), the request goes via IPC to another process, which handles the real I/O. The tab's main thread waits via epoll for the response IPC.
This is the map of where JavaScript runs and how it relates to the operating system substrate from Parts 1-3. Every operation your JavaScript can do bottoms out somewhere in this picture: in V8's interpreter, in JIT-compiled machine code, in a Chrome C++ binding, or in an IPC to another process that handles it on the user's behalf.
A myth to bust: "V8 is the JavaScript engine and runs the event loop."
V8 has a microtask queue, because ECMAScript requires one for Promises. That's the extent of V8's involvement in scheduling. The event loop — the algorithm that decides which task to run next, when to drain microtasks, when to render — is the host's responsibility. In the browser, that's Chrome's C++. In Node.js, that's libuv. The same JavaScript code can produce different behavior across these hosts because the loop is different. V8 is a function library that the host calls into; the host owns the loop. This is the most important architectural fact for understanding why JavaScript behaves the way it does at runtime.
Cliffhanger
We now know:
Where JavaScript runs (V8 in the renderer process's main thread)
What "running" means (V8's C++ or V8's JIT output)
What the call stack physically is (V8 frames in the OS thread's stack)
How the tab process reaches the outside world (Mojo IPC = Unix domain socket + epoll)
But we still don't have the event loop algorithm. We've been saying "the event loop picks a task" and "the event loop drains microtasks" for four parts without specifying exactly how. What's in the queue, structurally? What order do things run in? When does rendering happen? Why does Promise.resolve().then(f) run before setTimeout(f, 0)?
The HTML specification defines this precisely. It's an algorithm — eight numbered steps that an event loop performs on every iteration. It's the algorithm that produces the entire observable behavior of asynchronous JavaScript. It connects everything we've built so far — the main thread, V8, the call stack, the IPC layer — into a single repeatable cycle.
Part 5 is the algorithm.
Next: Part 5 — The Event Loop, Mechanically.
