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.
Where your script runs: agents, realms and the global
"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.
- Process. A renderer, per site. Memory and crash isolation.
- 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.
- Realm. A global object and its intrinsics. One per window, one per worker, one per worklet instance.
- Execution context. The stack frame of the function currently running, with its lexical environment. The call stack is per agent.
- instanceof fails across realms. Use
Array.isArray,Object.prototype.toString.call, or duck typing. Libraries that checkx instanceof Promisebreak on cross-frame promises. - 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.
- 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. - 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.
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.
- 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
evalornew Functioncannot stream. - 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.
- 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.
- 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.
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.
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.
- 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.
- 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.
- Backing stores. ArrayBuffer memory, image decode caches, canvas backing bitmaps, WebGL and WebGPU buffers. Counted as "external" memory; large and easy to forget.
- Shared. Compiled code, fonts, the browser's own caches.
- 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).
- 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.
- 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.
- 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.
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.
- 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.
- 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 keepsthis;thiskeeps the component's DOM. - Timers and intervals never cleared. The callback and everything it closes over, forever, plus whatever work it keeps doing.
- Growing collections. A Map used as a cache with no eviction; an array that only pushes; a log. Correct references, unbounded size.
- 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.
- 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.
- 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.
- Filter "Detached" for DOM leaks; sort by retained size for everything else.
- Read Retainers. The path from a root to the object. The fix is always an edge on that path.
- Allocation timeline for leaks that grow continuously: it shows which call stacks allocated memory that was not freed.
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.
- 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.
- WeakSet. The same for membership. "Has this node been processed?"
- 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. - 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.
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.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.
| Dedicated | Shared | Service | |
|---|---|---|---|
| Owner | One page | All same-origin pages | None; the browser |
| Lifetime | Until the page closes or terminate() | While any page is connected | Event-driven; killed when idle |
| Global | DedicatedWorkerGlobalScope | SharedWorkerGlobalScope | ServiceWorkerGlobalScope |
| Talks via | postMessage on one port | A port per connecting page | postMessage, fetch events, push, sync |
| Can fetch | Yes | Yes | Yes, and intercepts others' fetches |
| DOM | No | No | No |
| Typical use | CPU work for this page | One connection or cache for many tabs | Caching, offline, push, background sync |
| Caveat | Message cost | Not on Chrome Android | No in-memory state; 30 s idle kill |
- 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' })). - 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.
- Can create: nested dedicated workers.
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.
- 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. - Install. The worker's
installevent. Precache the app shell withevent.waitUntil(caches.open(v).then(c => c.addAll([...]))). If any addAll entry fails, install fails and the worker is discarded. - 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. - Activate. The
activateevent. Delete old caches by name.self.clients.claim()takes control of open pages immediately instead of on their next navigation. - Fetch. For every request from a controlled page (and the navigation itself), a
fetchevent.event.respondWith(promise)decides. No respondWith means the browser proceeds normally. - 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.
- 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.
- Cache first: fingerprinted static assets. Never changes under a given URL.
- Network first, cache fallback: HTML and API responses where freshness matters and offline is a degraded mode.
- Stale while revalidate: content that can be slightly old: avatars, feeds. Serve cache, refresh in the background.
- Network only: anything with side effects. Never cache a POST.
- Cache only: the precached shell.
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.
- 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.
- 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.
- Share. SharedArrayBuffer is referenced by both sides. Requires cross-origin isolation via COOP and COEP. Coordination via Atomics.
- worker.postMessage / self.postMessage. The implicit port between a page and its dedicated worker.
- 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.
- window.postMessage(msg, targetOrigin). Cross-frame and cross-window. Always specify targetOrigin;
'*'leaks data to whatever is in the frame. And always checkevent.originon receipt. - 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.
- Service worker clients.
client.postMessagefrom the worker,navigator.serviceWorker.controller.postMessagefrom the page.
Worklets, WebAssembly and the other execution contexts
- 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.
- 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.
- Paint worklet (CSS Houdini).
paint(myPainter)as a background-image, run during raster. Custom borders, patterns, charts as backgrounds. - Animation worklet. Scroll-linked and input-linked animations on the compositor thread, when CSS scroll-driven animations are not enough.
- Layout worklet. Custom layout algorithms. Experimental; largely unshipped.
- Loading.
WebAssembly.instantiateStreaming(fetch(url))compiles while downloading, on background threads, with a code cache like JS. Serve withapplication/wasmor streaming fails. - 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.
- 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.
- Threads. Wasm threads are Web Workers sharing a SharedArrayBuffer memory, so they need cross-origin isolation.
- 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.
Reading memory and workers in DevTools
| Tool | Shows | Use it for |
|---|---|---|
| Performance monitor | Live JS heap, DOM nodes, listeners, layouts/s, recalcs/s | Confirming a leak: repeat the action, watch for a staircase |
| Memory → Heap snapshot | Every object, grouped by constructor, with shallow and retained size | Finding what leaked (compare snapshots) and why (Retainers) |
| Memory → Allocation instrumentation on timeline | Allocations over time with stacks, blue bars for retained | Continuous growth; which code path allocates what survives |
| Memory → Allocation sampling | Low-overhead sampled allocations by function | Where allocation pressure comes from in a hot loop |
| Detached elements (newer Chrome) | Detached DOM nodes directly | The DOM leak shortcut |
| Performance → Memory checkbox | Heap size over the recording | GC pauses and their timing relative to jank |
| Sources → Threads pane | Each worker as a selectable thread | Breakpoints and stepping inside workers |
| Application → Service Workers | Registration, state, update controls, push and sync simulation | Lifecycle debugging; "Update on reload"; unregister |
| Network → the gear icon in the Size column | "(ServiceWorker)" responses | Which requests the SW answered |
- 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. - Route
/js/listener-leak: the same with a window resize listener. The retainer path now goes through the listener. - 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. - 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.