Part 7 · 10 chapters · ~70 min

JavaScript In The Page

The engine runs your code; the browser decides where, with what memory, and across which boundaries. This part is realms and agents and why instanceof fails across a frame, how a script is compiled and cached, the two heaps a tab has and what keeps things alive, the five shapes of leak and how to find them, the weak primitives, the three kinds of worker and the service worker lifecycle, what a message costs, and worklets and WebAssembly.

75

Where your script runs: agents, realms and the global

the question

"Is window the global object? And why does instanceof Array fail across an iframe?"

Window is the global object of one realm. A realm is the unit of JavaScript isolation: a global object, a set of intrinsics (its own Object, Array, Promise, Error), and a global environment. A tab with three same-origin iframes is four realms sharing one event loop. Each realm has its own Array constructor, so an array built in one is not an instance of another's.

the layers, from outside in
  1. Process. A renderer, per site. Memory and crash isolation.
  2. Agent. An event loop plus the realms that can synchronously reach each other: a window and its same-origin frames, or one worker. Same thread.
  3. Realm. A global object and its intrinsics. One per window, one per worker, one per worklet instance.
  4. Execution context. The stack frame of the function currently running, with its lexical environment. The call stack is per agent.
what the realm boundary changes
  1. instanceof fails across realms. Use Array.isArray, Object.prototype.toString.call, or duck typing. Libraries that check x instanceof Promise break on cross-frame promises.
  2. Globals are per realm. A polyfill applied in the parent is absent in the iframe. A library loaded twice is two instances with two caches.
  3. Cross-origin realms expose almost nothing. The WindowProxy for a cross-origin frame allows postMessage, a few location setters, and closed. Everything else throws a SecurityError.
  4. Memory is retained per realm. A reference from the parent into an iframe's object graph keeps that realm's entire heap alive after the frame is removed.
AGENTS, REALMS, AND GLOBALS
what "the window" actually is
swipe the figure sideways, or tap expand for full screen
1/5
one realm
One tab, one top-level document. It has a realm: a global object (window), a set of intrinsics (Object, Array, Promise), and a global environment where your top-level variables live.
76

Script execution: compile, run, and the code cache

Between "the bytes arrived" and "the function runs" there is parsing, compilation and, on repeat visits, a cache that skips most of it. The JS course goes into V8's pipeline; this is the browser's side of it.

what happens to a script tag's bytes
  1. Streaming parse. For external scripts, V8 begins parsing on a background thread as bytes arrive, so a 1 MB script is mostly parsed by the time it finishes downloading. Inline scripts and scripts injected via eval or new Function cannot stream.
  2. Lazy compilation. Top-level code is compiled fully; inner functions are pre-parsed only (syntax checked, not compiled) until first call. Wrapping a function in parentheses is a hint to compile it eagerly, which bundlers use for immediately-invoked code.
  3. Code caching. After a script runs, V8 serialises its compiled bytecode and the browser stores it alongside the HTTP cache entry. On the next visit, the cached bytecode is deserialised instead of recompiled. Chrome caches after the first run for scripts seen twice, and more aggressively for scripts that ran long. Service worker Cache API entries can be code-cached too.
  4. Execution. On the main thread, as a task, in document order for sync and defer, in arrival order for async. The script's top level runs; functions it defines wait for callers.
worked numbers
what the cache saves, for a 500 KB bundle on a mid-range phone:

  cold     download + parse ~120 ms + compile ~80 ms + execute
  warm     disk cache + deserialise ~15 ms + execute

the cache is keyed by the resource. a new filename on every deploy means cold every deploy.
that is a reason to split vendor code that rarely changes from app code that changes often.
the lens
The Performance panel shows "Compile Script" and "Compile Code" events before "Evaluate Script". Large compile events on repeat visits mean the code cache is not being hit: changed URLs, no-store headers, or the script is inline.
77

Memory in the renderer: V8 heap, Oilpan, and what counts

A tab's memory is several heaps. JavaScript objects live in V8's heap. DOM nodes, computed styles and layout objects live in Blink's heap, managed by its own garbage collector, Oilpan. Images, canvases and GPU textures live outside both. The DevTools memory number is a sum, and the leak you are chasing can be in any of them.

the heaps
  1. V8 heap. Objects, closures, strings, arrays. Generational: a young space collected often by a fast scavenger, an old space collected by mark-sweep-compact, incrementally and concurrently to avoid long pauses. The JS course has the detail.
  2. Blink heap (Oilpan). DOM nodes, CSSOM, layout objects, event listeners' native side. Traced garbage collection with cross-heap references to V8 handled by wrapper tracing: a DOM node is alive if JS references its wrapper or the document references it.
  3. Backing stores. ArrayBuffer memory, image decode caches, canvas backing bitmaps, WebGL and WebGPU buffers. Counted as "external" memory; large and easy to forget.
  4. Shared. Compiled code, fonts, the browser's own caches.
what keeps things alive
  1. Reachability from a root: the global object, the current stack, the document, pending callbacks, timers, observers, and the browser's own structures (an open IndexedDB cursor, a running fetch).
  2. A DOM node is alive while JS can reach it or the document contains it. A node removed from the document but held in a variable is a detached node.
  3. A closure keeps its whole scope's referenced variables. A listener that references one variable from a scope keeps that variable, not the whole scope, in modern engines; but it keeps whatever that variable references, transitively.
  4. An event listener keeps its target alive while the target is alive, not the other way round. Adding a listener to a long-lived object (window, document) with a closure over a short-lived component keeps the component.
78

Leaks: the catalogue, and finding them

A leak is memory the program can no longer use but the collector cannot free, because something still references it. In the browser there are five shapes that account for nearly all of them.

the five
  1. Detached DOM trees. Nodes removed from the document but referenced from JS: caches, closures, "elements" arrays in a component that outlived its mount. The subtree, its listeners and their closures come along.
  2. Forgotten listeners on long-lived targets. window.addEventListener('resize', this.onResize) in a component that never removes it. The window keeps the listener; the listener keeps this; this keeps the component's DOM.
  3. Timers and intervals never cleared. The callback and everything it closes over, forever, plus whatever work it keeps doing.
  4. Growing collections. A Map used as a cache with no eviction; an array that only pushes; a log. Correct references, unbounded size.
  5. Closures over large scopes. A small long-lived function that captured a large short-lived structure because both were in one scope. Modern engines share one context object per scope, so one surviving closure retains everything any closure in that scope referenced.
finding them, in order
  1. Confirm it is a leak. Performance monitor (DevTools → More tools): JS heap and DOM node count over time while repeating the action. A staircase that never descends after forced GC is a leak. A sawtooth is normal.
  2. Three heap snapshots. Baseline, after the action once, after the action several times. Compare snapshot 3 to snapshot 1 with the "Objects allocated between" filter; what grew by the repeat count is the leak.
  3. Filter "Detached" for DOM leaks; sort by retained size for everything else.
  4. Read Retainers. The path from a root to the object. The fix is always an edge on that path.
  5. Allocation timeline for leaks that grow continuously: it shows which call stacks allocated memory that was not freed.
A DETACHED DOM LEAK
the most common leak, step by step
swipe the figure sideways, or tap expand for full screen
1/6
mount + cache
A list component mounts. It creates 500 li elements, appends them to the document, and stores a reference to the container in a module-scope Map for "fast lookup later".
79

WeakMap, WeakRef, FinalizationRegistry: holding without keeping

Three primitives let you reference an object without preventing its collection. They are the tools for caches and metadata that should die with their subject.

which one
  1. WeakMap. Keys are objects held weakly; when a key is collected, its entry vanishes. Not iterable, no size, which is the price of being unobservable. Use for: per-node metadata, memoisation keyed by object, private state before class fields existed.
  2. WeakSet. The same for membership. "Has this node been processed?"
  3. WeakRef. A single weak reference. ref.deref() returns the object or undefined. Use for: optional caches where recomputing is acceptable. Never for correctness; the collector's timing is not specified and differs between engines and runs.
  4. FinalizationRegistry. A callback after an object is collected, with a token you supplied. Use for: releasing a native or external resource (a WebGL buffer, a worker-side handle) when the JS object dies. Never for logic the program depends on; the callback may run late or, at page close, never.
worked numbers
the detached-node cache, fixed:

  const meta = new WeakMap();          // node → data
  meta.set(node, { measured: 42 });
  // node removed and unreferenced → entry collected with it

  const cache = new Map();             // id → WeakRef(node)
  cache.set(id, new WeakRef(node));
  const n = cache.get(id)?.deref();  // maybe undefined; rebuild if so

weak structures make the leak impossible instead of merely fixed.
80

Dedicated, shared and service workers

Workers are the only way to run JavaScript off the main thread. Three kinds exist because three different ownership models are needed.

DedicatedSharedService
OwnerOne pageAll same-origin pagesNone; the browser
LifetimeUntil the page closes or terminate()While any page is connectedEvent-driven; killed when idle
GlobalDedicatedWorkerGlobalScopeSharedWorkerGlobalScopeServiceWorkerGlobalScope
Talks viapostMessage on one portA port per connecting pagepostMessage, fetch events, push, sync
Can fetchYesYesYes, and intercepts others' fetches
DOMNoNoNo
Typical useCPU work for this pageOne connection or cache for many tabsCaching, offline, push, background sync
CaveatMessage costNot on Chrome AndroidNo in-memory state; 30 s idle kill
what every worker has and lacks
  1. Has: its own event loop and realm, fetch, WebSocket, IndexedDB, Cache API, timers, crypto, WebAssembly, OffscreenCanvas (dedicated), importScripts or ES modules (new Worker(url, { type: 'module' })).
  2. Lacks: document, window, DOM APIs, localStorage (synchronous storage is banned off-main-thread), alert, and anything that touches layout or paint. Reading the DOM from a worker is the wrong question; send the data.
  3. Can create: nested dedicated workers.
THREE KINDS OF WORKER
dedicated, shared, service
swipe the figure sideways, or tap expand for full screen
1/6
dedicated
Dedicated worker: new Worker("w.js"). One owner page. Dies when the page does. Talks over a single MessagePort. The tool for CPU work that belongs to this page.
81

Service workers in depth

A service worker is a programmable network proxy with a lifecycle the browser controls. Getting the lifecycle wrong is how teams ship an update their users never receive.

the lifecycle, precisely
  1. Register. navigator.serviceWorker.register('/sw.js', { scope: '/' }). Scope defaults to the script's directory; it can only be narrowed, or widened with a Service-Worker-Allowed header.
  2. Install. The worker's install event. Precache the app shell with event.waitUntil(caches.open(v).then(c => c.addAll([...]))). If any addAll entry fails, install fails and the worker is discarded.
  3. Waiting. If an older version controls any open client, the new one waits. self.skipWaiting() in install skips this, at the risk of two versions of the app's assets being mixed in an open tab.
  4. Activate. The activate event. Delete old caches by name. self.clients.claim() takes control of open pages immediately instead of on their next navigation.
  5. Fetch. For every request from a controlled page (and the navigation itself), a fetch event. event.respondWith(promise) decides. No respondWith means the browser proceeds normally.
  6. Idle and termination. After about 30 seconds with no events, the worker is stopped. Global variables are lost. Anything to remember goes to Cache or IndexedDB.
  7. Update. On navigation and at most every 24 hours, the browser refetches sw.js, bypassing the HTTP cache by default (updateViaCache). A byte-different file starts a new install.
the strategies, by resource type
  1. Cache first: fingerprinted static assets. Never changes under a given URL.
  2. Network first, cache fallback: HTML and API responses where freshness matters and offline is a degraded mode.
  3. Stale while revalidate: content that can be slightly old: avatars, feeds. Serve cache, refresh in the background.
  4. Network only: anything with side effects. Never cache a POST.
  5. Cache only: the precached shell.
the bug everyone ships once
A service worker that caches HTML with cache-first and no versioning. Users see the old app forever until the worker's own file changes and an activate step clears the cache. The DevTools Application panel has "Update on reload" and "Bypass for network" for development precisely because this is so easy to do.
82

Messaging: structured clone, transfer, and sharing

Every boundary in the browser, between frames, between pages and workers, between windows, is crossed by postMessage. What it costs depends on how the data is passed.

three modes
  1. Clone. The default. Deep copy via the structured clone algorithm. Handles cycles, Map, Set, Date, RegExp, Blob, ArrayBuffer and typed arrays, Error. Loses prototypes, functions, symbols, DOM nodes (throws). Cost: linear in the size of the graph, on both sides.
  2. Transfer. The second argument lists objects whose ownership moves. The sender's copy is detached. Constant time. Applies to ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, streams, VideoFrame, AudioData.
  3. Share. SharedArrayBuffer is referenced by both sides. Requires cross-origin isolation via COOP and COEP. Coordination via Atomics.
the channels
  1. worker.postMessage / self.postMessage. The implicit port between a page and its dedicated worker.
  2. MessageChannel. Two entangled ports. Hand one to an iframe or a worker to get a private two-way channel instead of broadcasting through window.postMessage.
  3. window.postMessage(msg, targetOrigin). Cross-frame and cross-window. Always specify targetOrigin; '*' leaks data to whatever is in the frame. And always check event.origin on receipt.
  4. BroadcastChannel. Same-origin pub/sub across all tabs, workers and frames. The simplest tab-coordination primitive; the one to reach for before a shared worker.
  5. Service worker clients. client.postMessage from the worker, navigator.serviceWorker.controller.postMessage from the page.
MESSAGE COST
copy, transfer, or share
swipe the figure sideways, or tap expand for full screen
1/6
clone
postMessage({ rows }) with a 50 MB array of objects. Structured clone walks the whole graph, serialises it, and the receiving side deserialises it into new objects. Two passes over 50 MB, both on their respective main threads. Tens to hundreds of milliseconds.
83

Worklets, WebAssembly and the other execution contexts

worklets
  1. What they are. Lightweight, restricted scripts the rendering engine calls at specific points in its own pipeline, on its own threads. No event loop of their own; no fetch; a tiny API surface.
  2. AudioWorklet. Runs on the audio rendering thread with a 128-sample buffer and a hard deadline. The only way to do custom DSP without glitches.
  3. Paint worklet (CSS Houdini). paint(myPainter) as a background-image, run during raster. Custom borders, patterns, charts as backgrounds.
  4. Animation worklet. Scroll-linked and input-linked animations on the compositor thread, when CSS scroll-driven animations are not enough.
  5. Layout worklet. Custom layout algorithms. Experimental; largely unshipped.
WebAssembly in the page
  1. Loading. WebAssembly.instantiateStreaming(fetch(url)) compiles while downloading, on background threads, with a code cache like JS. Serve with application/wasm or streaming fails.
  2. Memory. A WebAssembly.Memory is an ArrayBuffer the module owns. JS reads and writes it via typed array views. Passing a string means encoding it into that memory.
  3. Calls. JS to Wasm calls are fast (near a JS function call in modern V8); the cost is in marshalling data across, not in the call.
  4. Threads. Wasm threads are Web Workers sharing a SharedArrayBuffer memory, so they need cross-origin isolation.
  5. When it wins. Numerical kernels, codecs, parsers, physics, anything already written in C, C++, Rust or Go. It does not make DOM manipulation faster; the DOM is on the other side of the boundary.
84

Reading memory and workers in DevTools

ToolShowsUse it for
Performance monitorLive JS heap, DOM nodes, listeners, layouts/s, recalcs/sConfirming a leak: repeat the action, watch for a staircase
Memory → Heap snapshotEvery object, grouped by constructor, with shallow and retained sizeFinding what leaked (compare snapshots) and why (Retainers)
Memory → Allocation instrumentation on timelineAllocations over time with stacks, blue bars for retainedContinuous growth; which code path allocates what survives
Memory → Allocation samplingLow-overhead sampled allocations by functionWhere allocation pressure comes from in a hot loop
Detached elements (newer Chrome)Detached DOM nodes directlyThe DOM leak shortcut
Performance → Memory checkboxHeap size over the recordingGC pauses and their timing relative to jank
Sources → Threads paneEach worker as a selectable threadBreakpoints and stepping inside workers
Application → Service WorkersRegistration, state, update controls, push and sync simulationLifecycle debugging; "Update on reload"; unregister
Network → the gear icon in the Size column"(ServiceWorker)" responsesWhich requests the SW answered
go to the lab
  1. Route /js/detached-list: mount and unmount the list ten times. Performance monitor shows the DOM node count. Take a snapshot, filter Detached, read the Retainers, name the Map.
  2. Route /js/listener-leak: the same with a window resize listener. The retainer path now goes through the listener.
  3. Route /js/worker-clone-vs-transfer: send 50 MB to a worker both ways with the Performance panel recording. Compare the main-thread time of each postMessage.
  4. Route /js/sw-lifecycle: register, change one byte in the worker file, reload, and watch the waiting state in the Application panel. Click skipWaiting and see the activate event.