← Root to End A root-to-end trace through real Chromium, V8 and libuv source

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.

Frame 0 · the root

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.

base/process/launch_posix.cc · LaunchProcess()Linux/ChromeOS child launch, trimmed

  

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=.

Frame 1 · the layout

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

chrome.exe (no --type)
  • CrBrowserMainUI thread: tabs, address bar, window events
  • Chrome_IOThreadIPC routing to every child process
  • ThreadPool workersdisk, background jobs

Network service

chrome.exe --type=utility --utility-sub-type=network.mojom.NetworkService
  • network.CrUtilityMainDNS, sockets, TLS, HTTP. Our fetch() bytes arrive here

GPU process

chrome.exe --type=gpu-process
  • CrGpuMaintalks to the graphics driver, draws frames

Renderer process: our React tab

chrome.exe --type=renderer …
  • CrRendererMainBlink + V8. Our JavaScript, the DOM, layout and the event loop run here, one task at a time
  • Chrome_ChildIOThreadreceives IPC messages and posts them as tasks to the main thread
  • Compositorscrolling and layer compositing off the main thread
  • ThreadPool workersV8 background compile and GC helpers, decoding
the thread that runs our JSthe process that hosts our tab

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.

Frame 2 · entry points

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.

chrome/app/chrome_exe_main_win.cc · wWinMain()process entry point on Windows, trimmed

  
content/app/content_main.cc · ContentMain() / RunContentProcess()trimmed

  
content/app/content_main_runner_impl.cc · RunOtherNamedProcessTypeMain()trimmed

  

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.

Frame 3 · the answer

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.

content/renderer/renderer_main.cc · RendererMain()trimmed

  

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.

Frame 4 · the loop itself

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.

…/thread_controller_with_message_pump_impl.cc · Run()trimmed

    
base/message_loop/message_pump_default.cc · Run()trimmed

    

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.

…/thread_controller_with_message_pump_impl.cc · DoWorkImpl()trimmed

  

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.

Frame 5 · the bottom of the stack while idle

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.

base/synchronization/waitable_event_win.cctrimmed

    
base/synchronization/waitable_event_posix.cctrimmed

    
base/message_loop/message_pump_default.cc · ScheduleWork()verbatim

  
Frame 6 · the other thread

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.

content/child/child_process.cc · ChildProcess()trimmed

  

The complete wake chain for our fetch reply

NIC → kernel

The network card raises an interrupt. The kernel's TCP stack reassembles the bytes and marks a socket readable.

network process

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.

Chrome_ChildIOThread

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.

PostTask

Enqueues the task and calls ScheduleWork(), then event_.Signal(), then SetEvent or a condition-variable broadcast.

CrRendererMain

Returns from WaitForSingleObject, goes back around for (;;), and DoWork picks the task. The task resolves p1, which queues our .then callback as a microtask.

Frame 7 · inside V8

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.

v8/src/builtins/promise-then.tq · PromisePrototypeThentrimmed

  

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.

v8/src/builtins/promise-abstract-operations.tq · PerformPromiseThenImpltrimmed

  

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.

v8/src/builtins/promise-abstract-operations.tq · FulfillPromisetrimmed

  

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.

v8/src/execution/microtask-queue.cctrimmed

    
blink/…/scheduler/common/event_loop.cctrimmed

    

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.

Frame 8 · everything at once

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.

1 · run one task
2 · drain microtasks
3 · render if due
4 · sleep in kernel
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)

Frame 8½ · our own code

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.

Predict first

Before stepping, type the lines we expect it to print, in order, separated by spaces or commas.

1 · run one task
2 · drain microtasks
3 · render if due
4 · sleep until next task
Our code
JS call stack
Console
Task queue (timers waiting to become tasks)
V8 microtask queue
Promises on the V8 heap

Real engine check: waiting.
Frame 9 · our code

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:

src/User.jsxexample

  

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.

Frame 10 · contrast

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.

node/src/api/embed_helpers.cc · SpinEventLoopInternal()trimmed

    
libuv/src/unix/core.c · uv_run()trimmed

    
Chrome rendererNode.js
Loop startsBefore any JS exists (run_loop.Run() in RendererMain)After our main script finishes (SpinEventLoop)
Loop endsWhen the tab's process is told to quitWhen nothing is pending (uv_loop_alive is 0)
Where it sleepsMain thread waits on an event; the IO thread waits in epoll / IOCPThe main thread itself waits in epoll / kqueue / IOCP (uv__io_poll)
Task orderPrioritized task queues, plus renderingFixed phases: timers, pending, poll, check, close
Frame 11 · verify it ourselves

See these processes and threads on our own machine

What to look atHow
One process per tab or siteChrome's own Task Manager: Shift+Esc on Windows/Linux. Our localhost React tab appears as its own row with a PID.
Why that process existsOpen chrome://process-internals to see the site-isolation decisions that assigned our tab its renderer.
The --type=renderer command lineWindows: 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 namesWindows: 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 timeDevTools → 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 lineAny 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

  1. Predict, then run: console.log(1); setTimeout(()=>console.log(2)); Promise.resolve().then(()=>console.log(3)); console.log(4). Name the C++ function that runs 3 before 2 can be selected.
  2. 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.
  3. 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 called fetch()? Match it to the wake chain above.
  4. 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 a setTimeout that never fires.
  5. Run the same fetch().then() in Node 18+ with node -e. Using uv_run, explain why the process exits on its own after printing.
Exam practice

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.

Self-marks stay on this page only; refreshing clears them.
What does this print? For each line, say whether it ran in the script task, in a microtask, or in a later task.
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).

LineRuns inWhy
A, G, Iscript taskAn async function runs synchronously up to its first await, so G prints before I.
Cmicrotask 1The promise is already fulfilled, so .then queues its callback at once.
Fmicrotask 2queueMicrotask runs after it in queue order.
Hmicrotask 3await null queued the rest of g third.
Emicrotask 4Queued only when C's callback returned and fulfilled the second promise, which happens after H was queued.
BtaskTimer registered during the script.
Dlater taskTimer 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.

A button's click handler runs a loop that takes 3 seconds. Using the renderer's run loop, explain why the page cannot repaint or handle another click during those 3 seconds. Name one thing that may still work.

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.

Distinguish a task from a microtask. Give two sources of each and state exactly when the microtask checkpoint runs.

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.

Rewrite this function using only .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).

Why does a Node.js script with one pending 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).

Reading list

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.

Sources

Original files

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.