cf.completefrontend
My progress
FUNDAMENTALS · MODULE 2

Inside the browser

How a modern browser splits its work across processes and threads, why your JavaScript shares one main thread with layout, paint, and input, and how the event loop, workers, and OffscreenCanvas decide whether a page stays responsive.

Why the inside of the browser matters

Picture a photo editing app in a browser tab. The user drags a brightness slider on a 12 megapixel photo. The app loops over 48 million bytes of pixel data in JavaScript to apply the change. That loop takes about 400 ms on a mid-range phone.

For those 400 ms the page is frozen. The slider thumb does not move, a click on the Undo button does nothing, and a progress bar that JavaScript updates stops moving. Then everything jumps at once, and the user decides the app is slow and broken.

Nothing here is a network problem, and nothing is a rendering bug. The code is correct. The problem is where the code ran: on the one thread that also handles input, runs layout, and paints the page. To fix it you need a picture of how the browser is built inside.

Three questions this lesson answers

  1. Why does one bad tab not take the whole browser down, and why can one site not read another site's data? The multi-process model and site isolation.
  2. Why does a long loop freeze clicks, typing, and JavaScript animations together? The main thread and the event loop that schedules everything on it.
  3. Where should heavy work go instead? Web Workers, OffscreenCanvas, and a few scheduling tricks, each with a real cost.

How browsers got here

Browsers of the early 2000s ran in one operating system process, so every tab, every plugin, and the browser window shared one memory space. A crash in Flash on one tab closed every tab. A memory leak on one page slowed everything, and a bug in the image decoder could let a page read memory that belonged to another page.

Chrome launched in 2008 with a different plan: put each tab's web content in its own process, with no direct access to the disk or network, and keep a trusted browser process in charge. Firefox, Safari, and Edge all moved to similar designs over the next decade. In 2018 the Spectre CPU attacks showed that even code inside one process could read memory it should not see, which pushed browsers further: keep different sites in different processes so there is nothing secret to read.

So the modern browser is several processes, each with a narrow job, that talk to each other through messages. Inside each process, a few threads share the work. Your page's JavaScript runs on exactly one of those threads, and that single fact explains most of what feels slow on the web.

Two timelines for a 400 ms photo filter. Top: the filter runs on the main thread from 0 to 400 ms; slider inputs at 80 ms and 200 ms both wait until 400 ms. Bottom: the filter runs on a worker thread from 0 to 400 ms while the main thread handles the inputs at 80 ms and 200 ms within a few milliseconds and draws the result at 400 ms.Filter on the main threadmaininputinputboth inputs wait until 400 ms: the page looks frozenFilter in a workermainworkerinputs answered at once; the result is drawn at 400 ms
The work takes 400 ms either way. What changes is which thread waits: on the main thread, every input waits; in a worker, only the result waits.

What knowing this buys you

  • You can explain jank. Jank means visible stutter: dropped frames and late responses to taps. You will know which work causes it.
  • You can pick the right tool. Splitting a task, moving it to a worker, or moving drawing off the main thread each fix a different problem.
  • You can reason about security. Site isolation, cross-origin isolation, and iframes all come from the process model.
  • You can answer a common interview follow-up. "Where does this computation run?" comes up for editors, dashboards, maps, and anything with large data.

The mental model: a small distributed system on one device

Think of the browser as a small company. The browser process is the manager: it owns the window, the tabs, and the address bar, and it decides who may do what. Each website gets a renderer process, a worker in a locked room with no phone and no keys.

The GPU process is the print shop that puts pixels on the screen. The network service is the mail room that sends and receives every request.

The worker in the locked room can still get things done, but only by passing notes under the door. In browser terms the notes are IPC messages (inter-process communication). The renderer asks "please fetch this URL" or "please read this cookie", and the browser process checks whether the request is allowed before anyone acts on it.

This design follows one security rule: assume the renderer is compromised. The renderer runs the most dangerous code on your computer: HTML, CSS, images, fonts, and JavaScript from any site on the internet. If a bug lets an attacker take it over, the sandbox stops them from reaching your files, your camera, or other sites' cookies.

Chromium's process model in two layers. The trusted layer holds the browser process for tabs and navigation, the network service for HTTP and the cache, the GPU process for raster and compositing, and utility processes for audio and storage. Below, inside the sandbox, three renderer processes for news.example, bank.example, and an ads.example iframe. Each renderer sends IPC messages up to the trusted processes.More trusted: no site code runs hereBrowsertabs, navigationNetworkHTTP, cacheGPUraster, compositeUtilityaudio, storageIPC messagesSandbox: renderer processes, one per siteRenderernews.exampleRendererbank.exampleRendererads.example
Site code only ever runs in the bottom layer. Everything a renderer needs from the outside world, it asks for by IPC, and a trusted process decides.

Who does what

Chromium's main process types. Firefox and Safari split things up in a similar way with different names.
ProcessMain jobsRuns site code?How many
BrowserWindow, tabs, address bar, navigation decisions, permissions, storage access, starting and stopping other processesNoOne
RendererHTML parsing, CSS, layout, paint recording, the JavaScript engine (V8 in Chrome), DOM eventsYes, sandboxedAt least one per site, often one per tab
GPUTalks to the graphics driver, rasterizes and composites frames for every tabNoOne
Network serviceHTTP requests, DNS, TLS, the HTTP cache, cookies on the wireNoOne (on some platforms it runs inside the browser process)
UtilitySmaller services: audio, storage, decoding untrusted data, video captureNoSeveral, on demand

Extensions also get their own processes, and so do some kinds of workers when the browser decides they need one. You can see all of this live: in Chrome open the menu, then More tools, then Task manager. Every row is a process, with its memory and CPU use.

Why split at all: three wins and one cost

  • Stability. A renderer that crashes or runs out of memory shows the "Aw, Snap!" page in its own tab. The other tabs and the browser keep running.
  • Security. The renderer runs in a sandbox: the operating system blocks it from opening files, sockets, or devices on its own. An attacker has to find a second bug, in the browser process, to escape.
  • Responsiveness. A page stuck in a loop does not freeze the address bar, the tab strip, or tabs of other sites. The browser process can still close it.
  • The cost is memory. Every process carries its own copy of the JavaScript engine, its own heap, and its own caches. Many tabs means many processes, which matters most on phones with 3 or 4 GB of RAM.

How a sandboxed renderer gets anything done

Follow one fetch("/api/cart") call. Your JavaScript runs in the renderer, but the renderer cannot open a socket. So it sends an IPC message with the URL, method, and headers to the network service, through a loader that the browser process handed it earlier. That loader is locked to the page's origin, so a hijacked renderer cannot claim to be another site.

The network service attaches the cookies that origin may send, does the real work (DNS, TLS, HTTP, the CORS check), and streams the response back. Cross-site responses loaded without CORS, for example by an img tag, also pass CORB (Cross-Origin Read Blocking), which Chromium is replacing with a stricter successor, ORB (Opaque Response Blocking). If such a response is clearly not what the tag expects, such as JSON loaded as an image, it is blocked before it reaches the renderer, so even a compromised renderer never holds those bytes.

A sequence diagram with four columns: renderer, browser process, network service, and server. First the browser process gives the renderer a loader locked to the page's origin. The renderer sends fetch /api/cart to the network service. The network service sends GET /api/cart with cookies to the server. The server replies 200 with JSON. The network service passes the body back to the renderer after its security checks.RendererBrowserNetworkServerlocked loaderfetch /api/cartGET + cookies200, JSONbody after checks
The renderer never opens a socket. It holds a loader that only works for its own origin, and the network service checks and filters every response before the renderer sees a byte.

The same pattern repeats everywhere. Reading document.cookie, opening IndexedDB, asking for the camera, showing a file picker, writing to the clipboard: each one is a message from the renderer and a decision in a more trusted process. This is why some browser APIs are asynchronous even when they look simple: the answer has to come from another process.

Check your understanding · Exercise 1

Which of these run inside a renderer process in Chromium? Select all that apply.

Site isolation: one site, one process

Splitting tabs into processes was not enough. In January 2018 researchers published Spectre, a family of attacks on how modern CPUs guess ahead. With a precise timer and careful code, JavaScript could read any memory inside its own process, past every check the JavaScript engine made. If an attacker's page and your bank's page shared a renderer, the attacker could, in principle, read the bank's data out of memory.

Browsers could not fix the CPUs, so they changed what lives in each process. With site isolation, documents from different sites always run in different renderer processes; Chrome turned it on for every site on desktop in Chrome 67, in 2018. A malicious page can then read its whole process's memory and find nothing worth stealing.

Site is not origin

An origin is scheme plus host plus port: https://mail.example.com:443. A site is coarser: scheme plus the registrable domain, the part of the host you can buy. The registrable domain is the public suffix (like com or co.uk) plus one label. So https://mail.example.com and https://docs.example.com are different origins but the same site, https://example.com.

Chromium isolates by site. The last row surprises people: hosting platforms add their domain to the public suffix list so each customer is its own site.
Page APage BSame origin?Same site?Separate process in Chrome?
https://shop.example.comhttps://shop.example.com/cartYesYesNot required
https://shop.example.comhttps://pay.example.comNoYesNot required (by default)
https://shop.example.comhttp://shop.example.comNoNo (scheme differs)Yes
https://shop.example.comhttps://example.orgNoNoYes
https://a.github.iohttps://b.github.ioNoNo (github.io is a public suffix)Yes

Why site and not origin? For years a page could change its own origin with the document.domain setter, which let pay.example.com and shop.example.com reach into each other, so same-site origins had to be able to share memory. Chrome made that setter do nothing by default in Chrome 115 (2023), unless a page opts out, and it still groups processes by site.

A site can ask for finer, origin-keyed isolation with the Origin-Agent-Cluster: ?1 response header. Chrome treats it as a hint: it may then give that origin a process of its own, but it does not promise to.

Out-of-process iframes

Site isolation applies to frames, not only tabs. A news page at news.example that embeds an ad from ads.example and a video from video.example has three sites in one tab. Chrome gives each cross-site iframe its own renderer; these are called out-of-process iframes, or OOPIFs. A same-site iframe stays in the parent's process.

The browser stitches the frames back together. Each renderer draws its own frame into a surface, and the compositing step in the GPU process places those surfaces into one picture. Input is routed the same way: a click inside the ad goes to the ad's renderer, and a click outside it goes to the page's renderer.

A frame tree for one tab. The top frame news.example runs in process 1. It has three child iframes: a same-site news.example frame in process 1, an ads.example frame in process 2, and a video.example frame in process 3. Inside the ads frame, a nested ads.example frame stays in process 2. Inside the video frame, a nested news.example frame goes back to process 1.news.exampletop frame: process 1news.examplesame site: process 1ads.exampleOOPIF: process 2video.exampleOOPIF: process 3ads.examplenested: process 2news.examplenested: process 13 sites, 3 processes
Process follows site, not position in the tree. A same-site frame shares its parent's process, a cross-site frame gets its own, and a news.example frame nested deep inside another site still joins process 1.

Try it in the lab below: pick what a page embeds and see which frames share a process. Notice that the github.io case and the http case split, even though the host names look close.

Embed
The page https://shop.example.com embeds 2 iframes. https://shop.example.com runs in process 1. https://pay.example.com runs in process 1. https://maps.example.org runs in process 2. In total 2 renderer processes for 3 frames.Tab: shop.example.comtop pageP1pay.example.comP1maps.example.orgP2P1 https://example.comP2 https://example.org
3 frames, 2 renderer processes. Frames share a process only when they share a site: the scheme plus the registrable domain.

What site isolation changes for your code

  • Cross-site frames only talk by message. A parent page and a cross-site iframe cannot touch each other's DOM, variables, or functions. They exchange data with postMessage, and the receiver must check event.origin.
  • A slow cross-site iframe usually does not slow you. An OOPIF has its own main thread, so a heavy ad no longer blocks your page's JavaScript. A same-site iframe still shares your main thread.
  • Memory goes up. A page that embeds widgets from 10 other sites can mean 11 renderer processes. Third-party widgets cost more than their bytes.
  • Precise timers and shared memory need an opt-in. SharedArrayBuffer and high-resolution timers make Spectre attacks easier. Browsers only allow them on pages that are cross-origin isolated, which the next block shows.
http
HTTP/1.1 200 OK
Content-Type: text/html
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
With both headers, self.crossOriginIsolated is true and the page may use SharedArrayBuffer. COOP cuts the window off from cross-origin popups and openers. COEP blocks any cross-origin resource that has not opted in with CORS or a Cross-Origin-Resource-Policy header (the credentialless value is a looser alternative).

Limits: when processes are shared anyway

Processes are not free, so browsers bend the rules when memory is tight. On desktop, Chrome has a soft cap on renderer processes based on the device's memory; past that cap, new tabs of the same site may share an existing renderer, but different sites still stay apart. A same-site popup opened with window.open always shares its opener's process, because the two pages can script each other.

On Android, memory is tighter, so Chrome isolates only some sites, and only on devices with at least 2 GB of RAM. Since Chrome 77 it isolates sites the user logs in to, and since Chrome 92 also sites that use a third-party login (such as OAuth) or send a COOP header. Other sites can share renderers, so your design should never depend on being in its own process.

Firefox and Safari, briefly

Firefox calls its site isolation project Fission, on by default on desktop since late 2021. It has a parent process, per-site web content processes, a GPU process, and separate processes for jobs like media decoding and, on some platforms, networking.

Safari (WebKit) runs pages in WebContent processes, with separate Networking and GPU processes. WebKit has been building site isolation for cross-site iframes, so check its current status before you rely on it. The core ideas are the same everywhere: sandboxed content processes, a trusted parent, and one main thread per page.

Check your understanding · Exercise 2

A page at https://app.example.com embeds four iframes. Which ones does Chromium on desktop put in a different renderer process from the page? Select all that apply.

Inside a renderer: the main thread and its helpers

Zoom into one renderer process. It is not one thread. A thread is a sequence of work that the CPU runs; a process can have many threads that share its memory and run at the same time on different CPU cores. A renderer has a handful of them, and exactly one is in charge of your page.

The threads that matter

  • Main thread. Runs all page JavaScript, dispatches DOM events, computes styles, runs layout, and records paint commands. It is the only thread that can touch the DOM.
  • Compositor thread. Takes the layers the main thread produced and turns them into frames. It handles scrolling and pinch zoom by moving layers, and it runs transform and opacity animations on its own.
  • Raster threads. Turn paint commands into pixels, usually by sending work to the GPU process. Several run in parallel, each on part of the page (tiles).
  • IO thread. Receives IPC messages from the browser and network service and hands them to the right thread.
  • Worker threads. One per Web Worker your page creates. Each has its own JavaScript heap and its own event loop.
Threads inside one renderer process. The main thread runs JavaScript, the DOM, style, and layout. The IO thread receives IPC from the browser process and passes events to the main thread. Worker threads exchange messages with the main thread. The main thread commits layers to the compositor thread, which hands tiles to raster threads, which send work to the GPU process. Only the main thread has the DOM.Renderer processMain threadJS, DOM, style, layoutIO threadIPC in and outWorker threadsyour Web WorkersCompositorlayers, scrollingRaster threadstiles to pixelsBrowserprocessGPUprocessonly the main threadcan touch the DOM
Many threads, one owner of the DOM. Everything your page code does lands on the main thread; the compositor and raster threads only work with what the main thread already produced.

The main thread is the bottleneck by design. The DOM is not thread-safe: two threads changing the same tree at the same time would corrupt it. Rather than lock every node, browsers give the DOM to one thread and keep everything that touches it there. Your JavaScript, React's rendering, getBoundingClientRect(), and the input handler for every keystroke all queue up for the same thread.

What keeps working when the main thread is busy

Because the compositor runs on its own thread, some things keep moving even while JavaScript is stuck in a loop.

Thing the user seesMain thread busy for 500 msWhy
Scrolling the pageUsually smoothThe compositor moves already painted layers. New content that was never painted shows as blank until the main thread catches up.
Scrolling with a non-passive wheel or touchmove listenerStutters or waitsThe compositor must ask the main thread whether the handler calls preventDefault() before it can scroll.
CSS transform or opacity animationSmoothRuns on the compositor.
CSS animation of width, top, or background-colorFreezesNeeds layout or paint on the main thread each frame.
JavaScript animation with requestAnimationFrameFreezesThe callback is main thread JavaScript.
Typing in an input, clicking a buttonWaitsEvents are dispatched on the main thread.
Animated GIF, video playbackUsually keeps playingDecoding and presenting happen off the main thread in most browsers.

The full story of which CSS properties trigger layout, paint, or only compositing is in Reflow, repaint, and composite. For this lesson, remember the rule: anything that needs the DOM, styles, layout, or your JavaScript needs the main thread.

Passive listeners: a small change with a big effect

A touchmove or wheel listener that might call preventDefault() forces the compositor to wait for the main thread on every scroll event. If the main thread is busy, scrolling stalls. Marking the listener passive promises the browser you will not cancel the scroll, so the compositor scrolls at once and tells the main thread afterwards.

js
// The compositor can scroll without waiting for this handler.
list.addEventListener("touchmove", onTouchMove, { passive: true });

// Browsers already make touchstart, touchmove, wheel, and mousewheel
// listeners on window, document, html, and body passive by default.
// On other elements they still default to non-passive.

One main thread per what, exactly?

When memory is short, one renderer process can host several pages of the same site, such as two tabs of the same app. In Chromium they then share that process's one main thread, so a heavy tab can slow another tab of the same site, but never a tab of a different site. Workers always get their own threads.

Check your understanding · Exercise 3

The main thread is stuck in a 2 second JSON parse. What can the user still see working? Select all that apply.

The event loop, step by step

The main thread does not run code whenever it likes. It runs an event loop: a loop that picks one piece of work, runs it to the end, tidies up, maybe draws a frame, and goes round again. Knowing the exact order of that loop answers most "why did this run first?" questions.

The three kinds of work

  • Tasks (also called macrotasks). Running a script, dispatching a click, a setTimeout or setInterval callback, a message from a worker, a network response being delivered. Tasks sit in task queues, and the browser may keep several (for example one for input, one for timers) and pick between them to favor input.
  • Microtasks. Promise reactions (.then, .catch, .finally, and the code after await), queueMicrotask callbacks, and MutationObserver callbacks. They sit in one microtask queue.
  • Rendering steps. requestAnimationFrame callbacks, style, layout, ResizeObserver and IntersectionObserver work, and paint. They run at most once per frame, not once per task.

One turn of the loop

  1. Pick the oldest task from one of the task queues and run it to completion. Nothing interrupts it: no other task, no event handler, no rendering.
  2. Run the microtask checkpoint: run microtasks until the queue is empty, including any new microtasks they add.
  3. If this is a rendering opportunity (a new frame is due, the page is visible, and something changed), update the rendering: run every requestAnimationFrame callback queued so far, recalculate style, run layout, run resize and intersection observers, then paint and hand the frame to the compositor.
  4. If there is spare time before the next frame, the browser may run requestIdleCallback callbacks.
  5. Go back to step 1.
A flowchart of one turn of the event loop. Pick one task, run it to completion, then run all microtasks until the queue is empty. Then a decision: is a frame due? If yes, run requestAnimationFrame callbacks, then style, layout, and observers, then paint, and go back to picking a task. If no, use any idle time for requestIdleCallback and go back to picking a task.Pick one taskinput, timer, messageRun to the endno interruptionsAll microtasksuntil the queue is emptyFrame due?about every 16.7 msIdle timerequestIdleCallbackrAF callbacksyour frame codeStyle, layoutthen observersPaintframe to compositoryesno
Rendering is not after every task: only when a frame is due. And nothing on this loop can interrupt a running task, which is why one long task stalls every box after it.

On a 60 Hz screen a frame is due about every 16.7 ms; on a 120 Hz screen, every 8.3 ms. Between two frames the loop may run many small tasks, or one big one. If a task takes 200 ms, about 12 frames at 60 Hz are simply skipped. That is jank.

A worked example

Predict the order before you read the answer in the comment. We checked it by running the code; browsers and Node.js agree for these APIs.

js
console.log("1 sync start");
setTimeout(() => console.log("T timeout"), 0);
Promise.resolve()
  .then(() => console.log("P1 then"))
  .then(() => console.log("P2 then"));
queueMicrotask(() => console.log("Q microtask"));
(async () => {
  console.log("A before await");
  await null;
  console.log("A2 after await");
})();
console.log("2 sync end");

// 1 sync start, A before await, 2 sync end,
// P1 then, Q microtask, A2 after await, P2 then,
// T timeout
The async function runs synchronously until its first await, and P2 is queued only when P1 finishes, so it lands behind Q and A2.

Three rules explain the whole output. First, synchronous code runs to the end before anything queued. Second, microtasks run in the order they were queued, and a .then chain queues its next step only when the previous step finishes. Third, a task queued with setTimeout(fn, 0) waits until the current task and all microtasks are done.

Lab: step through the loop

This lab runs a click handler that schedules a timeout, two promise steps, and a requestAnimationFrame callback, and it changes the DOM. Step through it and watch the call stack, the queues, and the moment the frame is painted.

A click handler through the event loopStep 1 of 11: A click arrives

The event loop for a click handler that queues a timeout, a requestAnimationFrame callback, and two promise steps, and changes a button's text from Save to Saved. Boxes show what is running now and what the screen shows; three queues hold tasks, microtasks, and rAF callbacks; a console note shows the logs 1 to 5 in order.Running nowidlewaiting for workOn screenSaveTask queueclickMicrotasksrAF callbacksConsole(empty)one task, all microtasks,then maybe a frame
The click becomes a task in the task queue. The main thread is idle, so it picks the task up next.

A click arrives: The click becomes a task in the task queue. The main thread is idle, so it picks the task up next.

Where requestAnimationFrame fits

requestAnimationFrame(cb) asks the browser to call cb right before the next paint. It is the right place to read the latest state and write styles for an animation frame, because the work lands just before style and layout run. A DOM change made in a rAF callback appears in that same frame.

Whether a setTimeout(fn, 0) runs before or after a rAF callback queued at the same time depends on when the next frame is due, so do not rely on that order. Two more facts trip people up. rAF callbacks queued from inside a rAF callback run in the next frame, not the current one. And browsers pause rAF in hidden tabs, so an animation driven by rAF stops when the user switches tabs, which is usually what you want.

Four traps that follow from the loop

  • The DOM changes, but the screen does not. Setting el.textContent = "Saving" and then running a 2 second loop never shows "Saving": the paint only happens after the task ends. Let a frame happen first, or move the work.
  • Microtasks can starve the page. A promise chain that keeps queuing new promises never lets the loop reach the rendering step. It freezes the page exactly like while (true).
  • Timers are a minimum, not a promise. setTimeout(fn, 0) runs after the current task, after microtasks, and after other queued tasks. Nested timers are clamped to at least 4 ms after five levels. In background tabs browsers throttle timers to about once per second, and Chrome throttles long chains of timers in tabs hidden for 5 minutes to about once per minute.
  • Microtasks run between listeners, sometimes. When a real user clicks, the stack empties after each listener, so microtasks run between two click listeners. When your code calls el.click(), the stack never empties, so all listeners run first and the microtasks run after. Tests that simulate clicks can see a different order from real users.

Check your understanding · Exercise 4

Inside one task, code calls setTimeout(a, 0), then Promise.resolve().then(b), then queueMicrotask(c). Which statements are true? Select all that apply.

What blocks the main thread, and how to stop it

Any task that runs longer than 50 ms is a long task. The number comes from the RAIL guidance: to respond to input within 100 ms, the browser needs any running task to finish within about 50 ms, leaving the rest for the event handler and the next frame. While a long task runs, the user's tap or keypress waits in the queue.

Users feel this as lag, and Google measures it as INP, Interaction to Next Paint: the time from a user's input to the next frame that shows a response, reported near the worst interaction on the page. A good INP is 200 ms or less. INP replaced First Input Delay as a Core Web Vital in March 2024, and long tasks are its main cause.

Where one slow interaction spends its time

An interaction has three parts: input delay, the time the event waits because another task is running; processing, your event handlers; and presentation delay, the style, layout, paint, and compositing of the frame that shows the result. The figure shows an example profile of a slow "Add to cart" tap.

A bar chart of one Add to cart tap, an example profile. Before: 120 ms of input delay while an analytics task finishes, 180 ms of processing in the handler and re-render, and 60 ms of style, layout, and paint: 360 ms in total, well past the 200 ms good INP limit. After the fix: 10 ms of input delay, 60 ms of processing, and 40 ms of presentation: 110 ms.BeforeINP 360 msinput delay 120processing 180presentation 60After10 + 60 + 40 = 110 ms200 ms: the good INP limit
Each phase has its own fix: break up other long tasks to cut input delay, do less in the handler to cut processing, and keep the DOM change small to cut presentation delay.

The usual suspects

  • Loading too much JavaScript. Compiling and running scripts happens mostly on the main thread, so a 1 MB bundle can keep it busy for hundreds of milliseconds on a mid-range phone, right when the user first tries to tap.
  • Rendering too much UI at once. A framework re-render of a 5,000 row table, or hydrating a whole page in one go, is one long task.
  • Forced synchronous layout. Reading offsetHeight or getBoundingClientRect() after writing styles makes the browser run layout right now, inside your task. Doing it in a loop (read, write, read, write) is called layout thrashing and can turn 1 layout into 500.
  • Big data work. JSON.parse of a 20 MB response, sorting 100,000 rows, diffing two large objects, or decoding a file in JavaScript.
  • Synchronous APIs. alert(), confirm(), synchronous XMLHttpRequest, and large localStorage reads and writes block until they finish.
  • Third-party scripts. Analytics, ads, chat widgets, and A/B testing tools run on your main thread unless they live in a cross-site iframe.
  • Huge DOMs. Style and layout cost more with more nodes, so a page with 15,000 elements makes every large change slower.

Fix one: break the work up and yield

If the work must touch the DOM, it has to stay on the main thread. The fix is to split it into chunks and yield between chunks: end the current task so the loop can handle input and paint, then continue in a new task. The user still waits the same total time for the result, but every tap gets an answer within one chunk.

js
function yieldToMain() {
  if (globalThis.scheduler?.yield) return scheduler.yield();
  return new Promise((resolve) => setTimeout(resolve, 0));
}

async function processAll(items) {
  let lastYield = performance.now();
  for (const item of items) {
    process(item);
    if (performance.now() - lastYield > 40) {
      await yieldToMain();
      lastYield = performance.now();
    }
  }
}
Yield about every 40 ms, not after every item: each yield has a cost. scheduler.yield() resumes ahead of other queued tasks, while the setTimeout fallback goes to the back of the line.

scheduler.yield() shipped in Chrome 129 (2024) and Firefox 142 (2025); Safari does not support it yet, so always keep the fallback. A related API, scheduler.postTask(fn, { priority }) (Chrome 94, Firefox 142), lets you queue work as user-blocking, user-visible, or background. Avoid yielding with queueMicrotask or await Promise.resolve(): microtasks run before rendering and input, so they do not yield at all.

How big should a chunk be? Use the simulator: it splits 600 ms of work into chunks and shows how long a tap waits in the worst case, and how much later the work finishes because of the yields.

Chunk size
600 ms of work split into 12 chunks of 50 ms, with 11 yields. A tap that arrives just as a chunk starts waits up to 50 ms. The work finishes at about 611 ms.main thread, 600 ms of worka tap lands as a chunk startsworst wait for a tap: 50 msdone at about 611 ms12 chunks, 11 yields
Chunks of 50 ms keep every tap's wait within the 50 ms long-task limit. The cost: 11 yields push the finish to about 611 ms (this model counts 1 ms per yield; real costs vary).

Finding long tasks

  • Chrome DevTools Performance panel. Record an interaction. Long tasks have a red corner, and the flame chart shows which function took the time.
  • Long Animation Frames API (LoAF). In the field, a PerformanceObserver for long-animation-frame entries reports frames that took over 50 ms, with the scripts that ran in them. It replaces the older Long Tasks API for diagnosing INP. It is Chromium-only at the time of writing.
  • INP in real user monitoring. The attribution build of the web-vitals library reports INP with the element and the phase (input delay, processing, presentation) that cost the most.

Check your understanding · Exercise 5

A handler loops over 10,000 items and takes 300 ms. Which changes let a tap during the loop get a response within about 50 ms? Select all that apply.

Workers and OffscreenCanvas: getting work off the main thread

Chunking keeps the page responsive, but the work still competes with your UI for the same thread. When the work does not need the DOM, a Web Worker runs it on a separate thread, in parallel, on another CPU core. The main thread stays free for input and frames the whole time.

How a worker works

A worker is a separate JavaScript environment with its own global object (self), its own heap, and its own event loop. It has no window, no document, and no DOM. It does have fetch, WebSocket, IndexedDB, timers, crypto, and importScripts or ES module imports. The page and the worker share no variables; they talk only with postMessage and message events.

js
// main.js
const worker = new Worker(new URL("./filter.worker.js", import.meta.url), { type: "module" });
worker.onmessage = (e) => drawResult(e.data);
slider.oninput = () => {
  const pixels = getPixels(); // an ArrayBuffer
  worker.postMessage({ pixels, brightness: slider.valueAsNumber }, [pixels]);
};

// filter.worker.js
self.onmessage = ({ data }) => {
  const out = applyBrightness(new Uint8ClampedArray(data.pixels), data.brightness);
  self.postMessage(out.buffer, [out.buffer]);
};
The photo editor from chapter 1, fixed. The second argument to postMessage lists buffers to transfer instead of copy. Module workers (type: "module") work in all major browsers.

Four kinds of worker, four different jobs

QuestionDedicated WorkerSharedWorkerService WorkerWorklet
Created withnew Worker(url)new SharedWorker(url)navigator.serviceWorker.register(url)CSS.paintWorklet.addModule, audioContext.audioWorklet.addModule
Shared byThe one page that made itAll same-origin pages that connect to itAll pages in its scope, per originThe rendering or audio pipeline
Lives as long asIts pageThe last page connected to itEvent-driven; the browser stops it when idleManaged by the browser
Main jobHeavy computation for one pageOne connection or cache shared by several tabsNetwork proxy: offline, caching, pushTiny hooks into paint or audio
Common mistakeCreating a new one per taskAssuming every browser has it: Chrome on Android only added it in version 148 (2026)Using it for computation; it can be killed between eventsExpecting general-purpose APIs

For keeping the UI responsive, the dedicated worker is the tool. A Service Worker, covered in the Service workers lesson, is the wrong place for heavy compute, because the browser may stop it whenever no event is pending.

The hidden cost: structured clone

postMessage copies its data with the structured clone algorithm. It handles objects, arrays, Map, Set, Date, typed arrays, and cycles, but not functions, DOM nodes, or class prototypes (an instance arrives as a plain object). Copying is not free: the sender serializes on its thread, and the receiver deserializes on its thread.

The cost grows with the size and the number of objects. As a rough guide from our own quick test, one structured clone of an array of 200,000 small objects took about 100 to 150 ms on a fast laptop, and a mid-range phone is several times slower. The main thread pays when it sends the input and again when it receives the result, so a worker that trades huge object graphs can block the main thread longer than the work it saved.

The fix is transferable objects: an ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, or stream listed in the transfer list is moved, not copied. The move takes a fraction of a millisecond whatever the size. The catch is that the sender loses it: after the transfer, the sender's buffer is detached and its byteLength is 0.

Two ways to send a buffer to a worker. Left, postMessage(buf) copies it: the main thread keeps its bytes and the worker gets a second copy, at a cost that grows with size. Right, postMessage(buf, [buf]) transfers it: the worker gets the bytes and the main thread's buffer is detached, empty, with byteLength 0. The transfer costs almost nothing.postMessage(buf)main129f33a0…worker129f33a0…copied: cost grows with sizeboth threads own a copypostMessage(buf, [buf])mainworker129f33a0…moved: almost freemain is detached: byteLength 0
A copy costs time on both threads and doubles memory. A transfer is almost free, but afterwards the sender's buffer has byteLength 0.
  • Send bytes, not object graphs. Pass pixel data, audio, and binary files as ArrayBuffers and transfer them.
  • Keep big state in the worker. If a worker owns a 100,000 row dataset, send it the query ("sort by price, rows 0 to 50") and get back only the 50 rows to show.
  • Batch messages. Ten thousand tiny messages cost more than one message with ten thousand items.

Shared memory: SharedArrayBuffer and Atomics

A SharedArrayBuffer is not copied or moved: both threads see the same memory, and Atomics gives safe reads, writes, and waits. It is how WebAssembly threads work, and it suits tight loops like physics or audio. It needs the page to be cross-origin isolated (the COOP and COEP headers from chapter 3), and Atomics.wait is not allowed on the main thread, because blocking it would freeze the page.

OffscreenCanvas: drawing off the main thread

Workers cannot touch the DOM, which used to mean they could not draw. OffscreenCanvas fixes that for canvas. Call canvas.transferControlToOffscreen() on the main thread, transfer the result to a worker, and the worker draws with the 2D context or WebGL. Its frames go to the screen without passing through the main thread, so a chart or map keeps animating while React re-renders the sidebar.

js
// main.js
const offscreen = document.querySelector("canvas").transferControlToOffscreen();
const worker = new Worker(new URL("./chart.worker.js", import.meta.url), { type: "module" });
worker.postMessage({ canvas: offscreen }, [offscreen]);

// chart.worker.js
self.onmessage = ({ data }) => {
  const ctx = data.canvas.getContext("2d");
  function frame() {
    drawChart(ctx);
    requestAnimationFrame(frame); // available in dedicated workers
  }
  frame();
};

OffscreenCanvas has worked in Chrome since version 69 (2018) and in Firefox since version 105 (2022). Safari added it with the 2D context in 16.4 (2023) and WebGL in Safari 17, so it is safe to use with a simple fallback for old devices. One limit remains: the canvas element's size and events still live on the main thread, so resize and pointer events must be forwarded to the worker as messages.

Check your understanding · Exercise 6

A page sends a 50 MB ArrayBuffer to a worker with worker.postMessage(buf, [buf]). What is true right after the call? Select all that apply.

Choosing where work runs

You now have four places to put a piece of work: the main thread as one task, the main thread in chunks, a worker, or a worker that draws with OffscreenCanvas. A fifth option is to not do it in the browser at all. The right choice depends on two questions: does the work need the DOM, and how long does it take on the slowest device you support?

A decision tree. First question: does the work need the DOM? If yes, ask whether it takes under 50 ms on a slow phone: if yes, run it on the main thread as is; if no, chunk it on the main thread and yield. If the work does not need the DOM, ask whether it only draws: if yes, use OffscreenCanvas in a worker; if no, use a dedicated worker, or the server when the data is too big for the browser.Needs the DOM?read or write the pageUnder 50 ms?on a slow phoneOnly drawing?canvas, charts, mapsyesnoRun as ismain threadChunk, yieldmain threadOffscreenworker canvasWorkeror server if hugeyesnoyesno
Two questions decide most cases: does it need the DOM, and how long does it take on the slowest device you support?

The tree is a starting point, not a rule. Real features mix both kinds of work: a search box filters data (no DOM) and then renders results (DOM). Split the feature along that line, and put each half where it belongs.

Decision A dashboard must filter, sort, and aggregate 200,000 rows each time the user changes a filter. Where should that work run?

Main thread, one task

Run the filter and sort in the change handler, then set state.

Pros

  • Simplest code
  • No copying, no messages

Cons

  • A 300 ms task on a phone blocks typing, clicks, and frames
  • INP gets worse as data grows

Main thread, chunked with yields

Process rows in 40 ms slices, await scheduler.yield() between slices, cancel the run if a newer filter arrives

Pros

  • Page stays responsive
  • Works with code that needs the DOM
  • No worker setup

Cons

  • Total time gets a little longer
  • Still competes with rendering for one core
  • Cancellation logic is easy to get wrong

Dedicated worker that owns the data

Load the rows into a worker once

Send only the filter; receive only the visible page of results

Pros

  • Main thread is free during the work
  • Uses a second CPU core
  • Small messages, so clone cost is tiny

Cons

  • More code: messages, versioning, errors
  • Data lives in two places if the UI also needs it
  • Worker startup and memory

Server

Send the filter to an API that returns the aggregated page

Pros

  • No client CPU cost at all
  • Scales to data that does not fit in the browser

Cons

  • A network round trip on every change
  • Does not work offline

What we pick: If the data already lives in the browser and the work is pure computation, use a worker that owns the data and exchange small messages. Chunking on the main thread is the right fix when the work must touch the DOM, or the work is short enough that a worker is not worth the code. Move to the server when the data is too big to ship.

Common cases and good defaults

WorkNeeds DOM?Good default
Rendering a long listYesVirtualize so only visible rows exist; chunk the rest
Syntax highlighting, linting, or type-checking in an editorNoWorker (code editors like Monaco run language services in workers)
Image filters, resizing, compressionNoWorker with transferred ArrayBuffers or ImageBitmaps, often with WebAssembly
Charts or maps with constant animationDraws onlyOffscreenCanvas in a worker
Parsing a large file the user picked (CSV, PDF)NoWorker; PDF.js, for example, parses in a worker
Search over a few thousand itemsNoMain thread is fine; measure before adding a worker
Third-party scriptsSometimesLoad them late; some teams move them into a worker with Partytown

Check your understanding · Exercise 7

Which of these are good candidates for a Web Worker? Select all that apply.

Failure modes and real products

Most production problems with the process and thread model are not bugs in one line of code. They come from a page using more memory than its renderer can hold, more main thread time than a frame allows, or more messages than a worker boundary can carry cheaply.

What breaks in production

  • Renderer out of memory. A page that leaks memory, or holds a huge dataset, crashes its renderer and shows an error page, and the user loses unsaved work. Fix the leak, keep big data in IndexedDB rather than in memory, and save drafts often.
  • Mobile tab discards. Phones kill background renderers to free memory, more often than desktops. When the user comes back, the page reloads from scratch. Listen for visibilitychange to save state when the page is hidden, and in Chromium check document.wasDiscarded on load to restore it.
  • A worker that fails quietly. An uncaught error in a worker is logged to the console, but a request waiting for its reply waits forever. Listen for error and messageerror events on the worker, and add a timeout to every request you send it.
  • Message storms. Posting a message on every pointermove or every row floods the worker queue and the main thread's message queue. Batch per frame.
  • Clone cost hides in the profile. The worker is fast, but the main thread spends 100 ms serializing the input. In the DevTools flame chart, check the time spent in postMessage calls and before your message handlers run.
  • Cross-origin isolation breaks embeds. COEP require-corp blocks every cross-origin image, script, or iframe that has not opted in. Roll it out with the report-only versions of the headers first.

How real products use this

Several well-known products made these trade-offs on purpose. Each one keeps the main thread for input and drawing and moves the heavy work behind a message boundary.

  • VS Code for the Web and Monaco. The Monaco editor runs language features such as TypeScript checking in Web Workers, so typing stays smooth while the language service works. In the browser version of VS Code, extensions run in a worker-based extension host for the same reason.
  • Squoosh. Google's image compression app runs codecs compiled to WebAssembly in workers and moves image data with transfers, so the UI stays responsive while a large image is encoded.
  • PDF.js. Firefox's built-in PDF viewer parses documents in a worker, so opening a large PDF does not freeze the page.
  • Partytown. A library that moves third-party scripts like analytics into a worker and proxies their DOM access back to the main thread. Some scripts break under it, which shows the cost of the trick.
  • Chrome itself. Site isolation on desktop added roughly 10 to 13 percent memory overhead in Google's 2018 measurements. Google decided the security was worth it, and the limited Android rollout is the same trade-off made the other way on memory-poor devices.

Think first

Users of a web spreadsheet report that on phones the tab sometimes reloads and their last edits are gone. What is happening and what would you do?

Using this in a system design interview

Browser architecture is rarely the question itself. It is the knowledge behind good answers to other questions: "the editor lags when the document is big", "the dashboard freezes when filters change", "how would you embed third-party widgets safely?". Bring it up when the design has heavy client work, untrusted embeds, or a performance requirement like "typing must feel instant".

What to say and draw

  1. Name the constraint. "All DOM work and our JavaScript share one main thread. To keep INP under 200 ms, no task should run over about 50 ms."
  2. Classify the heavy work. For each heavy piece, say whether it needs the DOM. Draw a box for the main thread and a box for a worker, and put each piece in one.
  3. Draw the message boundary. Show what crosses it and how big it is. "The worker owns the 200,000 rows; the main thread sends the filter and gets back 50 rows." Mention transfers for binary data.
  4. Cover the DOM-bound part. Virtualize long lists, chunk with scheduler.yield(), avoid layout thrashing, and use compositor-only animations.
  5. Mention isolation where it fits. Third-party widgets go in cross-site iframes, which also gives them their own process and main thread. Mention COOP and COEP only if you need SharedArrayBuffer or WebAssembly threads.

Keep it short unless asked: one or two sentences about the main thread and where heavy work runs is usually enough. Go deeper only when the interviewer follows up.

How deep to go by level

LevelWhat a strong answer shows
JuniorKnows JavaScript is single-threaded on the main thread, that long loops freeze the page, and that Web Workers exist for heavy work. Can explain why promises run before setTimeout.
MidExplains tasks, microtasks, and the rendering step; names long tasks and INP; chunks work with yields; picks a worker for pure computation and avoids sending huge objects across.
SeniorReasons about the process model and site isolation; designs the worker boundary around message size and ownership of state; knows transferables, OffscreenCanvas, cross-origin isolation, and how to measure with LoAF and field INP.

Think first

You are designing a browser code editor that must syntax-highlight and type-check files of 10,000 lines while the user types. Where does each piece run?

Summary

  • A browser is several sandboxed processes: a trusted browser process, renderers for web content, a GPU process, a network service, and utility processes. Renderers ask the others for anything sensitive.
  • Site isolation puts different sites (scheme plus registrable domain) in different renderers, including cross-site iframes. It is a defense against Spectre-style attacks, and it costs memory.
  • Your JavaScript, DOM events, style, layout, and paint recording share one main thread per page. The compositor thread can keep scrolling and transform animations going while it is busy.
  • Each turn of the event loop runs one task to completion, then all microtasks, then maybe a rendering step with rAF, style, layout, and paint.
  • Tasks over 50 ms are long tasks. They delay input and hurt INP. Break DOM-bound work into chunks and yield with scheduler.yield() or setTimeout.
  • Move pure computation to a dedicated Web Worker. Keep messages small, transfer binary data, and let the worker own large state.
  • OffscreenCanvas lets a worker draw to a canvas, so animations and charts keep running while the main thread is busy.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

Why is a modern browser split into several processes, and what does it cost?

Question 2

What is site isolation, and how is a site different from an origin?

Question 3

Walk me through one turn of the event loop. Where do promises, setTimeout, and requestAnimationFrame fit?

Question 4

A page feels laggy when users type in a search box. How do you find and fix the cause?

Question 5

When would you use a Web Worker, and what are the pitfalls?

Practice it

These design problems lean on inside the browser.

References

Read next