Modern browser architecture (processes and threads)
Modern browser architecture is a multi-process system where a browser process coordinates isolated renderer processes (one per site or tab) and utility processes for networking and graphics, with threads handling tasks inside each process.
Theory
TL;DR
- Browser splits into separate OS processes: browser process (coordinator), renderer process per site/tab, GPU process, network process
- Single-process browsers (IE6 era): one tab crash kills everything. Chrome since 2008: crash stays isolated inside that renderer
- Each renderer runs multiple threads: main thread (JS + DOM), compositor thread (60fps scrolling), raster threads, worker threads
- Site Isolation (Chrome 67+) puts each origin in its own renderer process - added as a direct response to Spectre/Meltdown
- The main thread blocks rendering when it stalls; heavy work belongs in Web Workers
Quick example
// Renderer's main thread - blocking it freezes THIS tab only
while (true) {} // other tabs keep running in their own processes
// Fix: off-load to a worker thread (same renderer process, separate thread)
const worker = new Worker('heavy.js');
worker.postMessage({ task: 'compute' });
worker.onmessage = (e) => console.log(e.data);
// Cross-tab messaging goes through browser process IPC
const bc = new BroadcastChannel('app');
bc.postMessage({ type: 'update' }); // copied, not sharedAfter Spectre, SharedArrayBuffer requires COOP/COEP headers. Without them, data cannot be directly shared between renderer processes - only copied.
Process map
The browser process is the coordinator. It spawns everything else, manages permissions, owns the address bar UI, and controls the lifecycle of all other processes.
Renderer processes handle page content. Chrome runs one per site origin by default (Site Isolation). Each renderer is sandboxed at the OS level: it cannot directly touch the disk or network and must request everything through IPC to the browser process. Chromium uses Mojo for this channel.
The GPU process sits between all renderers and the graphics card. Every renderer sends painted layers there, and the GPU process composites them. That separation is why a GPU crash looks different from a renderer crash.
The network process (isolated since Chrome 88) owns all HTTP, DNS, TLS, cookies, and CORS checks. Before that, networking lived inside the browser process, which meant a TLS bug could reach the main coordinator directly.
Key difference
Single-process browsers shared one address space across every tab. A memory exploit in one tab could read data from any other. Multi-process isolation gives each renderer its own memory, and OS sandboxing blocks direct file or network access. The cost is real: each renderer process adds roughly 100MB of overhead. That is why Chrome has a renderer process limit and merges same-site tabs when memory pressure rises.
When to use
- Heavy JS computation -> Web Worker (main thread stays free for rendering)
- Smooth animations ->
transformandopacityonly (compositor thread, never touches main thread) - Cross-tab communication ->
BroadcastChannelorpostMessage(notSharedArrayBufferwithout COOP/COEP headers) - Debug renderer crashes ->
chrome://crashes/ - Profile cross-process performance ->
chrome://tracing - Memory issues with many tabs ->
chrome://memory-internals
Browser comparison
| Browser | Architecture | Site Isolation | Renderer processes |
|---|---|---|---|
| Chrome | Multi-process | Full (Chrome 67+) | One per site origin |
| Edge | Multi-process (Chromium) | Full | One per site origin |
| Firefox | Multi-process (e10s, since v54) | Partial | Up to 8 by default |
| Safari | Multi-process (WebKit2, since 2010) | Partial | One per tab group |
How it works internally
When you open a tab, the browser process sends the URL to the network process. Once response headers arrive, it decides which renderer process handles the page: a new one for a cross-site navigation, the existing one for same-site. The renderer runs Blink (HTML/CSS engine) and V8 (JS engine) together. Blink sends paint commands to the compositor thread. The compositor splits the page into layers and passes them to the GPU process via Mojo IPC.
That pipeline explains why transform animations do not touch the main thread. They live entirely in the compositor-to-GPU path. V8 has one heap per renderer process, so two google.com tabs can share a renderer process but still cannot access each other's JS variables - each tab context is isolated inside V8.
Common mistakes
Blocking the main thread with a long synchronous loop:
// Wrong - freezes the entire tab: UI, events, rendering all stop
for (let i = 0; i < 1_000_000_000; i++) { /* work */ }
// Fix - chunk with requestAnimationFrame for visual work
function processChunk(i) {
if (i >= 1_000_000_000) return;
// do a slice of work
requestAnimationFrame(() => processChunk(i + 10_000));
}
// Or use a Web Worker for pure computation
const worker = new Worker('compute.js');
worker.postMessage({ start: 0, end: 1_000_000_000 });Expecting SharedArrayBuffer to work without isolation headers:
// Wrong - throws SecurityError in Chrome 68+ without headers
const sab = new SharedArrayBuffer(1024);
window.postMessage(sab, '*');
// Fix - add these to your server response:
// Cross-Origin-Opener-Policy: same-origin
// Cross-Origin-Embedder-Policy: require-corpAnimating properties that force layout on every frame:
/* Wrong - layout recalculated each frame on main thread */
.box { left: 0; animation: move 1s; }
@keyframes move { to { left: 100px; } }
/* Fix - compositor handles this without main thread */
.box { transform: translateX(0); animation: move 1s; }
@keyframes move { to { transform: translateX(100px); } }I once spent a full day chasing dropped frames in a React app. The animation looked correct but DevTools Performance showed main thread paint on every frame. The problem was animating width instead of transform. Switching to transform: scaleX() fixed it instantly.
Assuming console.log covers all processes:
Logs are per-renderer. If you are debugging a Service Worker, the console output goes to a different DevTools context. For cross-process profiling, chrome://tracing with the disabled-by-default-devtools.timeline category is the right tool.
Real-world usage
- Chrome/Edge -> each tab from a different domain runs in an isolated renderer; Puppeteer spawns a full browser instance for E2E tests, and each
newPage()call gets its own renderer - Firefox -> Electrolysis (e10s) gives up to 8 content processes by default; each handles a group of tabs
- React/Vue apps -> virtual scroll lists with 100k rows benefit from Web Workers to keep scrolling smooth while data processes in the background
- Node.js cluster ->
cluster.fork()mirrors how Chrome spawns renderers: parent process coordinates, workers handle connections - PWA/Service Workers -> Service Workers run in a separate thread inside the renderer process and proxy network requests without blocking page JS
Follow-up questions
Q: Why did Chrome choose multi-process over multi-threaded for tab isolation?
A: Threads share the same memory space, so a bug in one thread can corrupt data in another. Separate processes get OS-level memory boundaries and individual sandbox policies. The tradeoff is higher IPC overhead, which Chromium's Mojo system is built to keep low.
Q: What happens on a low-memory device with 20 open tabs?
A: Chrome's browser process monitors memory pressure and starts merging same-site tabs into shared renderer processes. It also discards renderers for background tabs - the user sees "Aw, Snap!" when switching back. The --max-renderer-process-count=4 flag sets a hard cap.
Q: How does V8 fit into the process model?
A: Each renderer process gets one V8 instance. V8 runs on the main thread but can spin up internal worker threads for background compilation. The JS heap is fully isolated per renderer, so objects cannot be shared directly between tabs without explicit copying.
Q: Chrome 88 moved the network stack to a separate process. What changed for developers?
A: Before that, a bug in TLS handling could affect the browser process directly. After the split, the network process is sandboxed independently. For PWAs, fetch() calls from Service Workers now go through the network process even when the renderer is inactive, which reduced unnecessary wake-ups and improved battery life.
Q: Draw the process model for two open tabs: google.com and example.com.
A: Browser process spawns: GPU process, Network process, Renderer1 (origin google.com), Renderer2 (origin example.com). Both renderers talk to the browser process via Mojo IPC. Neither renderer talks directly to the other.
Examples
Offloading heavy computation to a Web Worker
// main.js - runs on the renderer's main thread
const worker = new Worker('worker.js');
worker.postMessage({
numbers: Array.from({ length: 1_000_000 }, (_, i) => i)
});
worker.onmessage = (event) => {
console.log('Sum:', event.data.result); // UI stayed responsive the whole time
};
// worker.js - separate thread, same renderer process
self.onmessage = (event) => {
const sum = event.data.numbers.reduce((acc, n) => acc + n, 0);
self.postMessage({ result: sum });
};The computation runs in a worker thread inside the same renderer process. The main thread never stalls, so the page keeps responding to clicks and input while the work happens in the background.
Cross-tab communication via BroadcastChannel
// tab-a.js (Renderer Process 1 - google.com)
const channel = new BroadcastChannel('notifications');
channel.postMessage({ type: 'NEW_MESSAGE', id: 42 });
// Path: renderer1 -> browser process IPC -> renderer2
// tab-b.js (Renderer Process 2 - same origin google.com)
const channel = new BroadcastChannel('notifications');
channel.onmessage = (event) => {
console.log('Got:', event.data); // { type: 'NEW_MESSAGE', id: 42 }
// No shared memory between processes - the message was copied
};After Spectre, direct memory sharing between renderer processes requires explicit opt-in via COOP and COEP headers. BroadcastChannel copies the message through the browser process, so it works without those headers. The copy overhead is negligible for small payloads.
Compositor-thread animation with no main thread involvement
<style>
.spinner {
width: 40px;
height: 40px;
background: #4285f4;
/* transform and opacity are the two properties
the compositor can animate independently of the main thread */
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
<div class="spinner"></div>Even if the main thread is busy parsing a large JSON response, this animation stays at 60fps. The compositor thread has its own copy of the layer and handles the rotation without asking the main thread for anything.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.