Lesson 09 / 24
Observers
The observer family that reports visibility, size, and tree changes in a batched, asynchronous way; the intersection ratio's calculation, root margin and threshold, the content-versus-border-box distinction, batched delivery of records, and the observer's contribution to layout cost.
Contents
The address and history define the view the user wants. But how much of that view shows on screen, how far a container has widened, and when the tree changed cannot get read from this information. Loading images only once they near the viewport in a long measurement list, redrawing a chart once its container narrows, processing newly added rows — all three demand continuous measurement.
Doing this by hand on every frame is wrong for two reasons. Measurement is expensive, and the measurement itself forces the browser to do a layout calculation. The browser answers these three questions with a family of interfaces that respond at the moment it has already done its own calculation.
Common Form
All three observers follow the same pattern, and once the pattern gets learned, all three get used the same way.
An observer object gets constructed with a callback function. It observes nothing when constructed; which targets get watched gets declared separately. The same observer can watch multiple targets, and separate options can get given for each target.
The callback gets called not with individual events, but with an array of records. Every change accumulated in the time between calls gets delivered in a single call.
Watching ends with three operations: stopping the observation of a single target, cutting off observation of all targets, or manually taking records not yet delivered. A component has to disconnect its observer once it gets removed from the tree; an observer that does not get disconnected holds a reference to its target, and becomes the source of the leak defined in the Asynchronous JavaScript and Runtime curriculum.
The reason this pattern gets chosen over an event is that the event model offers no frequency control. A scroll event gets produced on every scroll step, and its listener has to measure every single time. An observer, though, takes the measurement from the browser’s own calculation and reports only once the result changes.
Intersection Observer
The intersection observer reports how much a target intersects with a root rectangle. The root is the viewport by default; a scrollable container can also get given as the root.
Two settings determine the behavior. Root margin grows or shrinks the root rectangle; growing it makes it possible to get a notification before the target is even visible. Threshold says at which intersection ratios the notification gets produced.
// intersection.mjs — calculating the intersection ratio and threshold crossing const rect = (top, bottom, left = 0, right = 360) => ({ top, bottom, left, right }); const area = (r) => Math.max(0, r.bottom - r.top) * Math.max(0, r.right - r.left); const intersect = (a, b) => rect( Math.max(a.top, b.top), Math.min(a.bottom, b.bottom), Math.max(a.left, b.left), Math.min(a.right, b.right), ); // The margin expands or shrinks the root rectangle in every direction. const rootWithMargin = (root, margin) => rect( root.top - margin.top, root.bottom + margin.bottom, root.left - margin.left, root.right + margin.right, ); const ratio = (target, root) => area(intersect(target, root)) / area(target); const viewport = rect(0, 800); const preload = { top: 0, bottom: 300, left: 0, right: 0 }; const rows = [ ["T-01", rect(-40, 60)], // overflows past the top edge ["T-02", rect(300, 400)], // fully inside ["T-03", rect(760, 860)], // at the bottom edge ["T-04", rect(900, 1000)], // outside the viewport, inside the margin ["T-05", rect(1200, 1300)], // outside the margin ]; const THRESHOLD = 0.25; console.log("row ratio(no margin) ratio(margin: bottom 300) threshold 0.25"); for (const [name, target] of rows) { const a = ratio(target, viewport); const b = ratio(target, rootWithMargin(viewport, preload)); console.log( name, a.toFixed(2).padStart(12), b.toFixed(2).padStart(18), (a >= THRESHOLD ? " passed" : " failed").padEnd(9), b >= THRESHOLD ? "| passed with margin" : "| failed with margin too", ); } console.log("expanded root:", JSON.stringify(rootWithMargin(viewport, preload)));
row ratio(no margin) ratio(margin: bottom 300) threshold 0.25
T-01 0.60 0.60 passed | passed with margin
T-02 1.00 1.00 passed | passed with margin
T-03 0.40 1.00 passed | passed with margin
T-04 0.00 1.00 failed | passed with margin
T-05 0.00 0.00 failed | failed with margin too
expanded root: {"top":0,"bottom":1100,"left":0,"right":360}
The ratio gets calculated against the target’s own area, not the root’s area. This is why an element larger than the viewport does not reach a ratio of one, even if it completely covers the screen.
The margin’s effect shows up in the fourth row. A row sitting 100 pixels below the screen does not intersect at all in the margin-free calculation; once 300 pixels get added to the bottom margin, it counts as fully intersecting. If image loading gets started by this notification, the image is ready by the time the user reaches the row. The margin’s size is a trade-off: a small margin produces empty boxes, a large margin produces unnecessary downloads.
Threshold values can get given as a list; a notification gets produced at every threshold crossing. For just the question “did it appear or disappear,” a single zero threshold is enough. Measuring how long and at what ratio an ad or a piece of content got seen requires multiple thresholds.
Two behaviors frequently surprise. The observer reports the initial state too once a target starts getting watched; a call gets produced for elements already visible on screen. The observer also answers the question “is it intersecting,” not “is it visible”: a target with zero opacity or sitting underneath another element still counts as visible if it intersects.
Resize Observer
The resize observer reports when an element’s size changes. This is where it diverges from the window resize event: a container’s size can change without the window changing — when a side panel opens, when a font loads, when a sibling element grows.
The notification gives the boxes defined in the Box Model lesson in the Visual Presentation with CSS curriculum separately: the content box and the border box are different numbers, and which one gets read depends on the calculation. A chart’s drawing area is the content box; the space the container takes up is the border box.
This observer has a trap of its own. Code inside the callback that changes the observed element’s size produces a new notification; the notification changes the size again, and a loop forms. The browser detects this loop, reports an error, and continues drawing for that frame. The fix is making a change in the callback that does not depend on the measurement, or limiting the new measurement with a condition.
Mutation Observer
The mutation observer reports changes in the document tree: adding and removing children, attribute changes, a text node’s content changing. Which types get watched gets declared with options, and the entire subtree can get brought into scope.
// records.mjs — batching mutation records and delivering them together class ChangeObserver { constructor(callback) { this.callback = callback; this.queue = []; this.targets = []; } observe(target, options) { this.targets.push({ target, options }); } record(entry) { const match = this.targets.find( (t) => t.target === entry.target && t.options[entry.type] === true, ); if (match === undefined) return; // an unobserved type does not get recorded if (entry.type === "attribute" && match.options.oldValue !== true) entry = { ...entry, old: undefined }; this.queue.push(entry); } flush() { // runs at the microtask boundary if (this.queue.length === 0) return; const entries = this.queue; this.queue = []; this.callback(entries); } } let callCount = 0; const watcher = new ChangeObserver((entries) => { callCount += 1; console.log(`callback ${callCount}: ${entries.length} entr${entries.length === 1 ? "y" : "ies"}`); for (const entry of entries) console.log(" ", JSON.stringify(entry)); }); const list = { name: "ul#measurements" }; const heading = { name: "h2#heading" }; watcher.observe(list, { childList: true, attribute: true, oldValue: true }); watcher.observe(heading, { childList: true }); // attribute not observed // Within a single task, three rows get added and one attribute gets changed. for (const code of ["T-05", "T-06", "T-07"]) watcher.record({ type: "childList", target: list, added: code }); watcher.record({ type: "attribute", target: list, name: "data-filter", old: "all" }); watcher.record({ type: "attribute", target: heading, name: "class", old: "open" }); console.log("queue before flush:", watcher.queue.length); watcher.flush(); // In the next task, a single change: a separate callback. watcher.record({ type: "childList", target: list, added: "T-08" }); watcher.flush(); watcher.flush(); // an empty queue produces no callback console.log("total callbacks:", callCount);
queue before flush: 4
callback 1: 4 entries
{"type":"childList","target":{"name":"ul#measurements"},"added":"T-05"}
{"type":"childList","target":{"name":"ul#measurements"},"added":"T-06"}
{"type":"childList","target":{"name":"ul#measurements"},"added":"T-07"}
{"type":"attribute","target":{"name":"ul#measurements"},"name":"data-filter","old":"all"}
callback 2: 1 entry
{"type":"childList","target":{"name":"ul#measurements"},"added":"T-08"}
total callbacks: 2
Four behaviors can get read from the output. Records get accumulated; four changes got delivered in a single call. Delivery happens not instantly, like events, but after the running code finishes — the microtask boundary from the Asynchronous JavaScript and Runtime curriculum applies here. An unwatched type never gets recorded: the heading element’s attribute change never entered the queue. An empty queue produces no call.
Having the old value in the record has to get requested separately. When it does not get requested, the record only says “this attribute changed”; the previous value is lost. This distinction exists for memory cost, and is off by default.
The mutation observer gets used as a last resort. Code that changes a tree it wrote itself already knows what it changed; notifying at the site of the change is both cheaper and clearer than observing the tree. The observer makes sense when a change produced by code outside your control needs watching.
The Cost of Measurement
The observers’ common rationale comes together here. Directly querying an element’s size or position forces the browser to do every pending style and layout calculation right then. Code that measures and changes in a loop, in sequence, triggers this forcing on every iteration.
Observers eliminate this forcing: the measurement gets taken at the moment the browser does its own calculation, and the result arrives ready in the callback. Splitting measurement and change into separate phases, though, is the next lesson’s subject.
Summary
- The three observers follow a common pattern: constructed with a callback, targets get declared separately, delivery happens as an array of records rather than one by one, and observation gets disconnected explicitly.
- The intersection ratio gets calculated against the target’s own area; root margin pulls the notification earlier, and the threshold list determines at which ratios the notification gets produced.
- The intersection observer reports the initial state too when watching starts, and measures intersection, not visibility.
- The resize observer reports the content and border boxes separately; code that changes the size inside the callback produces a loop.
- Mutation records get accumulated and delivered in a batch at the microtask boundary; an unwatched type does not get recorded, and the old value does not get carried unless it gets requested separately.
- Because observers take the measurement from the browser’s own calculation, they eliminate the layout forcing that direct querying creates.
Next Step
Observers solve when the measurement gets taken, but leave open when the change gets applied. Code that updates a heading’s position while the measurement list scrolls, grows a bar, or advances a counter runs against the browser’s drawing schedule if it does this at a random moment: a frame gets dropped, motion turns choppy. The order in which the browser prepares every frame is defined, and there is a way to fit into that order. The next lesson takes up the concept of the frame, why timers are not frame-aligned, and why reading and writing get split into separate phases.
To keep your progress and take notes, Log in
My notes
Log in to take notes.