From the power button to .then()
We already know processes, threads, syscalls and scheduling. This page uses exactly those ideas to follow one fetch().then() through the actual code that runs it: the process that hosts our React tab, the C++ thread that runs our JavaScript, the exact line that starts its event loop, the system call it sleeps in, and the V8 builtin that queues our callback.
Every excerpt below is copied from the current source of Chromium, V8, libuv or Node (fetched October 2026) and trimmed with // … where unrelated code was removed. File paths link to the originals at the bottom of the page. Highlighted lines are the ones the explanation hinges on.
The OS gives Chrome a process
Everything starts the way any program does. When we launch Chrome, the kernel creates a process: a fresh virtual address space, a handle table, and one thread whose instruction pointer starts at the executable's entry point. On Windows that entry point is wWinMain in chrome.exe. Nothing about events, promises or JavaScript exists at this point.
Chrome then becomes many processes. It does this with the same primitives we studied: CreateProcess on Windows, fork() plus execvp() on Linux. The browser process launches itself again, as chrome.exe --type=renderer …, and the command-line flag decides which main the child runs.
On Linux, renderers are usually forked from a pre-warmed zygote process rather than through this exact path, and on Windows the sandbox broker creates the renderer under a restricted token. The idea is identical: the browser process asks the kernel for a new process, then that child picks its role from --type=.
Chrome's processes, and which thread is our tab
Each box is a separate OS process with its own address space. Inside, each item is an OS thread. The thread names are the strings Chrome passes to SetName or base::Thread. Two of them appear verbatim in the excerpts on this page: CrRendererMain and Chrome_ChildIOThread.
Browser process
CrBrowserMainUI thread: tabs, address bar, window eventsChrome_IOThreadIPC routing to every child processThreadPool workersdisk, background jobs
Network service
network.CrUtilityMainDNS, sockets, TLS, HTTP. Our fetch() bytes arrive here
GPU process
CrGpuMaintalks to the graphics driver, draws frames
Renderer process: our React tab
CrRendererMainBlink + V8. Our JavaScript, the DOM, layout and the event loop run here, one task at a timeChrome_ChildIOThreadreceives IPC messages and posts them as tasks to the main threadCompositorscrolling and layer compositing off the main threadThreadPool workersV8 background compile and GC helpers, decoding
So "JavaScript is single-threaded" means precisely this: our code only ever runs on CrRendererMain, which is one OS thread in one renderer process. The process itself has many threads. Our fetch is performed by a different process entirely. Web Workers get their own threads, each with its own event loop.
From wWinMain to RendererMain
The renderer child starts in the same executable as the browser. The path down to the renderer-specific code is a chain of ordinary function calls: the Windows entry point loads chrome.dll, which calls ContentMain, which reads --type and dispatches through a table of function pointers.
That table is the whole answer to "how does one executable become a renderer": process_type == "renderer" means call RendererMain. The browser process, which has no --type, takes a different branch into BrowserMain instead.
The line that starts our tab's event loop
This is the main thread of the renderer process. Read it top to bottom and we watch the event loop get assembled: name the thread, initialize Blink, build a scheduler around a message pump, create the RenderThreadImpl (which also starts the IO thread), engage the sandbox, and finally call run_loop.Run().
That call does not return until the tab's process shuts down. Every task this renderer ever runs, including parsing our HTML, executing main.jsx, and every React render, happens inside it, deeper on this thread's C++ stack.
Note MessagePumpType::DEFAULT. The renderer main thread's pump does not watch sockets or pipes itself. It sleeps on a plain event and is woken by other threads. We will see why that matters in the IO thread section.
Inside run_loop.Run()
RunLoop::Run hands off to the thread's controller, which calls pump_->Run(this). Here is the pump: an actual for (;;). Strip out the metrics and the busy-wait heuristic and it is four lines: do work, if there's more do it again, otherwise do idle work, otherwise sleep until signaled or until the next delayed task is due.
delegate->DoWork() is where a task, in the HTML-spec sense, actually runs. The scheduler chooses the next task from several prioritized queues (input, loading, timers, and others), runs it, then calls DidRunTask. The comment in the source says that last call can process microtasks. That is the microtask checkpoint we traced earlier, happening right after each task.
Two details worth keeping. First, DoWork runs tasks in batches of up to 8 ms before going back to the pump. Second, task_execution_allowed is set to false while a task runs, which is why one task can never start inside another. A while(true){} in a click handler never returns from RunTask, so the for (;;) above never comes back around to anything else: no other task, no paint, no input.
How the loop sleeps without burning CPU
When there is no work, the pump calls event_.TimedWait(remaining_delay). On Windows that is a kernel event object and WaitForSingleObject, so the scheduler takes the thread off the CPU. On Linux and macOS, Chrome builds the same thing from a mutex and a condition variable, the textbook construction.
Waking it is the mirror image: any thread that posts a task to the main thread ends up calling MessagePumpDefault::ScheduleWork(), which calls event_.Signal(), which on Windows is SetEvent.
Who signals that event: Chrome_ChildIOThread
The main thread's pump is DEFAULT; the IO thread's pump is IO. An IO pump blocks in the kernel's readiness mechanism, epoll on Linux and an IO completion port on Windows, waiting on the pipes that connect this renderer to the browser and network processes. When a message arrives, the IO thread wakes, decodes the Mojo message, and posts a task to the main thread. Posting is what calls ScheduleWork and signals the event from the previous section.
The complete wake chain for our fetch reply
The network card raises an interrupt. The kernel's TCP stack reassembles the bytes and marks a socket readable.
Its thread wakes from epoll or IOCP, reads the socket, decrypts TLS, parses HTTP, and sends the response over a Mojo pipe to our renderer.
Wakes from its own epoll or IOCP wait on that pipe, decodes the Mojo message, and posts a task to the main thread's loading queue.
Enqueues the task and calls ScheduleWork(), then event_.Signal(), then SetEvent or a condition-variable broadcast.
Returns from WaitForSingleObject, goes back around for (;;), and DoWork picks the task. The task resolves p1, which queues our .then callback as a microtask.
What .then(), resolving, and the microtask queue actually are
V8 implements Promise.prototype.then as a builtin written in Torque, V8's own language for builtins, which compiles to machine code. It allocates the result promise and calls PerformPromiseThenImpl.
This is the branch we watched in the simulator. If the promise is still pending, .then stores a PromiseReaction on it, and nothing is queued. That is the "stapling" step. If the promise has already settled, it skips straight to EnqueueMicrotask.
When the network task later resolves p1, FulfillPromise flips the state and hands the stored reactions to TriggerPromiseReactions. That morphs each reaction object in place into a job task and pushes it onto the microtask queue.
The microtask queue itself is a C++ ring buffer whose capacity doubles when full. A checkpoint drains it completely, including any microtasks queued while draining, which is why a chain of resolved promises all run back to back with no paint in between.
Ownership, confirmed by the code: V8 owns the microtask queue; Blink decides when to drain it (EventLoop::PerformMicrotaskCheckpoint); Chromium's base library owns the task loop and the sleep. The V8 comment above notes that checkpoints happen whenever script or microtask scopes exit, not only once per task.
The full trace, with both stacks visible
This is the simulator from before with one addition: the real C++ stack of CrRendererMain at each step, bottom frame first. Watch how our JS frames only ever appear on top of DoWork, and how an idle loop's stack ends in a kernel wait.
What it is. A step-through of one fixed program. The JS side follows the event-loop rules; the C++ stack column is assembled from the Chromium functions excerpted above.
Checked against. Not run against Chrome. The output order is the standard one the playground below checks against real engines, but the C++ frames are a teaching reconstruction, not a captured stack.
Not covered. Real task priorities, rendering steps, input events, and anything not in this one program.
main.jsx
JS call stack
Elsewhere (IO thread / network process)
Task queue
V8 microtask queue
Promises on the V8 heap
Console
CrRendererMain C++ stack (bottom → top)
Playground: write it, predict it, step through it
The trace above follows one fixed program. Here we write our own. The playground runs a model of the event loop that follows the spec's promise jobs and the "one task, then every microtask" rule, and shows the queues at each step. It covers a subset of JavaScript: console.log, setTimeout, queueMicrotask, Promise.resolve, new Promise, .then, .catch, async/await, functions, arrow functions and variables. Rejections, if and loops are left out.
The model is not taken on trust. Every time the code changes, the page also runs it for real in a Web Worker, using the V8 in this browser, and compares the printed lines. A test suite in the folder checks the same thing against Node.
What it is. A small interpreter for a subset of JavaScript that follows the spec's promise jobs and the one-task-then-all-microtasks rule.
Checked against. Node 22 on 18 programs (verify/compare-loop-with-node.js), and live: every program also runs in this browser's real engine, and the bar under the playground says whether the output matched.
Not covered. Rejections, if, loops, template strings, process.nextTick, timer clamping, rendering, and real task priorities. The model reports these instead of guessing.
Before stepping, type the lines we expect it to print, in order, separated by spaces or commas.
Our code
JS call stack
Console
Task queue (timers waiting to become tasks)
V8 microtask queue
Promises on the V8 heap
Where our React app sits in all of this
With npm run dev, Vite serves an index.html containing <script type="module" src="/src/main.jsx">. Module scripts are deferred, so after parsing finishes, one task on CrRendererMain executes main.jsx. Inside that task, createRoot(...).render(<App />) runs. React then schedules its work, and effects run in later turns of the same loop.
A typical data-loading component maps directly onto the trace:
The setUser call inside the callback runs during a microtask checkpoint. React batches the update and re-renders, and the new DOM reaches the screen at the next render opportunity of the loop, which is phase 3 in the simulator. The ignore flag exists because the component can unmount while p1 is still pending: the promise lives on the heap regardless, and its callback would still run later.
Node.js: same V8, a different loop
Node embeds the same V8, so .then and the microtask queue are the identical Torque code above. The task loop is different: libuv's uv_run, with named phases. Node runs our entire script first and only then spins the loop, and the loop exits by itself once uv_loop_alive reports nothing left to wait for. That is why a Node script ends but a tab does not.
| Chrome renderer | Node.js | |
|---|---|---|
| Loop starts | Before any JS exists (run_loop.Run() in RendererMain) | After our main script finishes (SpinEventLoop) |
| Loop ends | When the tab's process is told to quit | When nothing is pending (uv_loop_alive is 0) |
| Where it sleeps | Main thread waits on an event; the IO thread waits in epoll / IOCP | The main thread itself waits in epoll / kqueue / IOCP (uv__io_poll) |
| Task order | Prioritized task queues, plus rendering | Fixed phases: timers, pending, poll, check, close |
See these processes and threads on our own machine
| What to look at | How |
|---|---|
| One process per tab or site | Chrome's own Task Manager: Shift+Esc on Windows/Linux. Our localhost React tab appears as its own row with a PID. |
| Why that process exists | Open chrome://process-internals to see the site-isolation decisions that assigned our tab its renderer. |
The --type=renderer command line | Windows: Process Explorer (Sysinternals), or in PowerShell Get-CimInstance Win32_Process -Filter "Name='chrome.exe'" | Select ProcessId, CommandLine. Linux: ps -eo pid,args | grep type=renderer. |
| Thread names | Windows: Process Explorer → Properties → Threads. Linux: cat /proc/<pid>/task/*/comm. Linux truncates names to 15 characters, so Chrome_ChildIOThread shows as Chrome_ChildIOT. |
| Tasks and microtasks in time | DevTools → Performance → record while our fetch runs. On the Main track, each grey Task bar is one RunTask, and Run Microtasks blocks appear inside them. |
| The 50 ms long-task line | Any Task bar over 50 ms is marked with a red corner. That is a task that kept the for (;;) from coming back around. |
Predict, then check
Exercises
- Predict, then run:
console.log(1); setTimeout(()=>console.log(2)); Promise.resolve().then(()=>console.log(3)); console.log(4). Name the C++ function that runs3before2can be selected. - Put
const t = Date.now(); while (Date.now() - t < 2000) {}in a button's click handler, record a Performance trace, and click. Find the single 2-second Task and explain why scrolling still worked or didn't. Hint: the compositor thread. - In the Performance trace of our
fetch, find the Task in which the response handler runs. Is it a separate Task from the one that calledfetch()? Match it to the wake chain above. - Write
Promise.resolve().then(function loop(){ Promise.resolve().then(loop) }). Predict what happens to the page, using the rule that a checkpoint drains until the ring buffer is empty. Then check with asetTimeoutthat never fires. - Run the same
fetch().then()in Node 18+ withnode -e. Usinguv_run, explain why the process exits on its own after printing.
Exam-style questions, with model answers
Five questions on the event loop. Write our answer on paper first, then open the model answer and mark ourselves honestly. The marks follow the usual pattern of university theory exams; they are not copied from any real paper.
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve()
.then(() => { console.log("C"); setTimeout(() => console.log("D"), 0); })
.then(() => console.log("E"));
queueMicrotask(() => console.log("F"));
const g = async () => { console.log("G"); await null; console.log("H"); };
g();
console.log("I");Output: A G I C F H E B D (checked in the playground model and in Node).
| Line | Runs in | Why |
|---|---|---|
| A, G, I | script task | An async function runs synchronously up to its first await, so G prints before I. |
| C | microtask 1 | The promise is already fulfilled, so .then queues its callback at once. |
| F | microtask 2 | queueMicrotask runs after it in queue order. |
| H | microtask 3 | await null queued the rest of g third. |
| E | microtask 4 | Queued only when C's callback returned and fulfilled the second promise, which happens after H was queued. |
| B | task | Timer registered during the script. |
| D | later task | Timer registered during C, so it is due after B. |
Marking: 1 for A G I, 2 for C F H E in that order, 1 for B before D, 1 for the reasons.
The click handler is one task. DoWork calls RunTask, and it does not return until our handler returns. The for (;;) in MessagePumpDefault::Run only picks the next task, runs the microtask checkpoint, or reaches a rendering opportunity between tasks, so for 3 seconds nothing else on CrRendererMain can run: no input tasks, no timers, no paint.
What may still work: scrolling of already painted content, because the compositor thread handles it off the main thread (unless a non-passive wheel or touch listener forces it to wait for the main thread). Input still reaches the renderer through Chrome_ChildIOThread; it simply queues until the task ends.
Marking: 2 for "one task runs to completion, the loop cannot interleave", 1 for naming where (RunTask / DoWork / the pump), 1 for the compositor.
A task is a unit the event loop picks one at a time from its task queues. A microtask is a job in V8's microtask queue; the queue is drained completely at each checkpoint, including microtasks queued during the drain.
Task sources: timer callbacks (setTimeout), network responses, user input events, postMessage. Microtask sources: promise reactions (.then, .catch), the continuation after await, queueMicrotask, MutationObserver callbacks.
The checkpoint runs after each task finishes (Chromium reaches it from DidRunTask), and also whenever the JavaScript stack empties after a callback, for example between two event listeners of one click.
Marking: 1 for the definitions, 1 for task sources, 1 for microtask sources, 1 for the timing.
.then (no async/await). Then describe what happens to load's stack frame at the await, and what wakes it up again.async function load() {
console.log("start");
const r = await fetch("/a");
console.log("got", r.status);
return r.status;
}function load() {
console.log("start");
return fetch("/a").then(r => {
console.log("got", r.status);
return r.status;
});
}At await, load returns its promise to the caller and its frame is popped off the call stack. V8 keeps the rest of the function in a suspended generator-like object and stores a PromiseReaction on the fetch promise; nothing is queued while the request is in flight.
The network process receives the response, sends it over Mojo to the renderer's IO thread, which posts a task to CrRendererMain. That task fulfills the fetch promise, TriggerPromiseReactions queues our continuation as a microtask, and at the checkpoint after that task load resumes with r.
Marking: 2 for a correct rewrite that returns the status, 2 for "frame popped, reaction stored, nothing queued", 2 for the wake chain (task fulfills promise, microtask resumes).
setTimeout stay alive and exit right after the timer fires, while a browser tab never exits on its own?Node runs our script first and then calls uv_run in SpinEventLoop. uv_run loops only while uv_loop_alive reports active handles or requests. The pending timer is an active handle, so the loop sleeps in epoll_wait until it is due, runs it, finds nothing left alive, returns, and the process exits.
A renderer's loop is started by run_loop.Run() in RendererMain before any page script exists, and it only returns when the browser process tells the renderer to quit. An empty queue just means the thread sleeps waiting for the next task.
Marking: 2 for Node (uv_run, loop alive), 2 for the browser (run_loop.Run until told to quit).
Everything this page cites, by depth
The small numbered markers in the text open the relevant reading right where we need it. Here is all of it in one place: Start here for a first pass, Go deeper for the full treatment, and Primary sources for the specifications, papers and man pages the code implements.
Original files
- content/renderer/renderer_main.cc
- content/app/content_main.cc
- content/app/content_main_runner_impl.cc
- chrome/app/chrome_exe_main_win.cc
- base/process/launch_posix.cc
- base/message_loop/message_pump_default.cc
- base/task/sequence_manager/thread_controller_with_message_pump_impl.cc
- base/synchronization/waitable_event_win.cc
- base/synchronization/waitable_event_posix.cc
- content/child/child_process.cc
- blink/…/scheduler/common/event_loop.cc
- v8/src/builtins/promise-then.tq
- v8/src/builtins/promise-abstract-operations.tq
- v8/src/execution/microtask-queue.cc
- node/src/api/embed_helpers.cc
- libuv/src/unix/core.c
- HTML spec: event loops
- Chromium design doc: multi-process architecture
- Chromium docs: threading and tasks
- Jake Archibald, "In The Loop" (talk)
Chromium, V8 and libuv/Node excerpts are © their respective authors and used under their BSD and MIT licenses. Thread names in the process map that do not appear in the excerpts above are the usual ones in current Chrome builds and can differ between versions and platforms.