---
title: Observers
source: 'https://academia.sh/en/courses/browser-platform/observers'
course: 'The Browser and the Web Platform'
language: en
updated: '2026-08-17T18:09:11+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.

```js
// 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.
