skip to content

Observers and Scheduling

You will learn the callback-based APIs that let the browser tell you when something changed — visibility, size, DOM structure — and how to schedule low-priority work. Interviewers like these because the naive alternative is always a scroll or resize handler that destroys the frame budget.

on this pageshow

explore

questions

21

In a browser page you call observer.observe(el) on an element that sits far below the fold, and your IntersectionObserver callback runs once right away even though the user has not scrolled. Why does that happen, and what should the callback do with that first entry?

level: juniorimportance: must knowfreq 66%

answer

  1. callback runs before any scrolling
  2. observe queues a baseline report
  3. first entry is a starting state
  4. isIntersecting can be false
  5. branch on the flag, not on being called

basics

~20 s

observe() queues an initial observation, so the callback always fires once with a baseline entry describing the target's current position. For an off-screen element that entry has isIntersecting false, so the callback must branch on isIntersecting rather than assume it was called because the element became visible.

solid answer

~40 s

Calling `observe()` does not just arm a future notification — it queues an **initial observation**, so the callback runs once asynchronously with a baseline entry for that target even if nothing has scrolled. For an element below the fold that entry has `isIntersecting: false` and `intersectionRatio: 0`; its `boundingClientRect` shows the target's current position relative to the viewport. The practical rule is that being called is not the signal — `entry.isIntersecting` is. Delivery is also batched: one observer watching many elements invokes the callback once with an `entries` array, so you loop and use `entry.target` to know which element each entry describes. The callback's second argument is the observer itself, which is handy for calling `unobserve(entry.target)` on a one-shot effect like revealing a card or loading an image.

code

javascript · 9 lines
javascript
const observer = new IntersectionObserver((entries, obs) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;
    entry.target.classList.add('is-visible');
    obs.unobserve(entry.target);
  }
});

document.querySelectorAll('.card').forEach((el) => observer.observe(el));

go deeper

for a junior

Know the shape: new IntersectionObserver(callback, options), then observe(element). Say out loud that the callback receives an array of entries and that you check entry.isIntersecting before acting.

for a middle

Explain that observe() queues an initial observation, so a baseline entry always arrives, and that delivery is batched and asynchronous. Be able to name the useful entry fields and say why entry.boundingClientRect beats calling getBoundingClientRect() yourself.

for a senior

Show the teardown discipline: unobserve for one-shot effects so the browser stops evaluating finished elements, disconnect in view teardown. Be ready to explain why callbacks must be written to tolerate an entry that reports 'not intersecting'.

for a principal

Own the convention: one shared observer per option set for a list of items rather than an observer per element, plus a documented rule that observers are always released on teardown. Be able to justify that choice in terms of per-element bookkeeping the browser would otherwise carry.

## What an IntersectionObserver actually watches An `IntersectionObserver` asks a single geometric question over and over on your behalf: does the target element's bounding box overlap the *root's* box, and by how much? You create one with a callback and an options object, then register elements with `observe(target)`. The browser computes these intersections itself, as part of the work it already does to render a frame, and hands you the results asynchronously. That is the whole point of the API — you never read `scrollY`, never call `getBoundingClientRect()` on every scroll event, and never force the browser to compute layout just to answer "is this on screen yet?". ## Why the callback fires immediately `observe()` is specified to queue an **initial observation** for the newly registered target. The observer has no idea what the element's state was before you asked, so instead of waiting for a change it reports the current state once, as a baseline. That first invocation happens asynchronously — not inside the `observe()` call — so any code written directly after `observe()` still runs first. This surprises people because the API is usually described as "tells you when an element enters the viewport". It is more accurate to say it tells you the target's intersection state now, and then again every time that state crosses a threshold you asked about. ```js const io = new IntersectionObserver((entries) => { console.log('callback', entries[0].isIntersecting); // logs second, with false }); io.observe(document.querySelector('#footer-card')); console.log('after observe'); // logs first ``` ## What is in an entry Each `IntersectionObserverEntry` carries: - `target` — the observed element this entry is about. With one observer and many targets, this is how you tell them apart. - `isIntersecting` — a boolean: does the target currently overlap the root box (as adjusted by `rootMargin`)? - `intersectionRatio` — how much of the target's area is inside, from 0 to 1. - `boundingClientRect` — the target's rectangle, the same numbers `getBoundingClientRect()` would give, but measured for you without you triggering the work. - `intersectionRect` — the overlapping region. - `rootBounds` — the root's rectangle used for the comparison; it is `null` for some cross-origin observations. - `time` — a high-resolution timestamp, comparable with `performance.now()`, for when the change was recorded. ## Batching: one callback, many entries The callback signature is `(entries, observer)`. If twenty cards cross into view during the same scroll, you typically get **one** invocation with twenty entries, not twenty invocations. Code that assumes `entries[0]` is the element it cares about works fine in a demo with one target and breaks quietly in a list. Always iterate. The second argument matters more than it looks: it lets the callback reference the observer without a closure variable, which is what makes the one-shot pattern clean. ```js const io = new IntersectionObserver((entries, obs) => { for (const entry of entries) { if (!entry.isIntersecting) continue; // the guard the initial entry demands entry.target.classList.add('is-visible'); obs.unobserve(entry.target); // never fires for this one again } }); ``` ## Teardown: unobserve vs disconnect `observer.unobserve(target)` stops watching one element and leaves the rest registered. `observer.disconnect()` stops watching everything; the observer object still exists and can be reused with a fresh `observe()`. For a reveal-on-scroll or lazy-load effect, `unobserve` after the first hit is the right move — the work is done, and there is no reason for the browser to keep evaluating that element. When a view or component is torn down, `disconnect()` in the teardown path is the reliable sweep, because it does not require you to have kept a list of everything you observed. ## Consequences for how you write the callback Three rules follow directly: 1. **Branch on `isIntersecting`.** "The callback ran" means "something crossed a threshold or was just registered", not "the element is visible". 2. **Loop over `entries` and use `entry.target`.** Never index blindly. 3. **Do not depend on the first callback having already happened.** It is a queued task; treat the observer as eventually consistent, and set your initial UI state from your own data, not from the assumption that the observer already reported. One more consequence is worth internalising: because the browser measures for you, reading `entry.boundingClientRect` inside the callback is free, while calling `getBoundingClientRect()` yourself there is not. If you need the target's geometry, take it from the entry.

  • Is that first callback delivered synchronously from inside the observe() call?
    No. `observe()` queues the initial observation; the callback is invoked later, asynchronously, once the browser has computed intersections. Code written on the line after `observe()` runs first. That is why you should never set up state that depends on the observer having already reported — initialise from your own data and let the entry correct it.
  • You observe forty cards with one observer and they all scroll into view together. How many times does the callback run?
    Typically once, with an `entries` array holding the cards whose state changed — delivery is batched, not one invocation per element. Iterate the array and use `entry.target` to identify each card. Code that reads `entries[0]` works with a single target and silently drops the rest as soon as the list grows.
  • When would you call disconnect() instead of unobserve()?
    `unobserve(target)` retires one element and leaves the others registered — right for a one-shot reveal or lazy load. `disconnect()` drops every registration at once, which is what you want in a component or view teardown, since it does not require you to have tracked every element you observed. The observer object stays usable afterwards.

saying these in an interview costs you the question

  • Says the callback only runs when the element becomes visible
  • Treats being called as proof of intersection and never reads isIntersecting
  • Expects one callback invocation per observed element
  • Assumes the first callback runs synchronously inside observe()
  • Reads window.scrollY inside the callback to decide visibility

context

open as a page

A sidebar can be dragged wider by the user and also shrinks when a neighbouring panel opens, while the browser window stays the same size. Why is a window 'resize' event listener not enough to react to that element's own size changing, and what does ResizeObserver give you instead?

level: juniorimportance: must knowfreq 72%

basics

~20 s

The window resize event fires only when the viewport changes size. An element's own box can change from dragging, sibling layout, content, or CSS while the viewport never moves. ResizeObserver watches a chosen element and reports its new dimensions.

open as a page

An IntersectionObserver is constructed with { threshold: [0, 0.5, 1] }. When exactly does its callback run as the user scrolls, and what does entry.intersectionRatio mean at that moment?

level: middleimportance: must knowfreq 58%

basics

~20 s

The callback runs whenever the visible fraction of the target crosses one of the listed thresholds, in either direction — not continuously while scrolling. intersectionRatio is that fraction: the intersecting area divided by the target's own bounding-box area, between 0 and 1.

open as a page

You call `observer.observe(el, { childList: true })` on a MutationObserver. Which changes under `el` will fire the callback, which will be ignored, and what happens if you pass `{}` instead?

level: middleimportance: must knowfreq 62%

basics

~20 s

childList: true reports only direct children of el being added or removed. Attribute edits, in-place text changes, and anything deeper in the tree are ignored unless you also pass attributes, characterData or subtree. Calling observe with {} throws a TypeError.

open as a page

When you call observe(el) on a ResizeObserver, when does the callback first run, and what do unobserve() and disconnect() each do when the code that created the observer is torn down?

level: middleimportance: must knowfreq 58%

basics

~20 s

Observing delivers an initial callback at the next rendering opportunity with the element's current size, even though nothing changed. unobserve(target) stops watching one element; disconnect() stops watching all of them and is what teardown should call, since the observer stays live otherwise.

open as a page

An infinite-scroll list observes a sentinel div at the bottom of the page with IntersectionObserver to fetch the next page. In production it fires the fetch several times for the same page, and once a batch lands it sometimes stops firing entirely. Explain both behaviours and how you would make the trigger reliable.

level: seniorimportance: must knowfreq 48%

basics

~20 s

IntersectionObserver is edge-triggered: it reports threshold crossings, not a standing state. Repeated crossings while a fetch is still pending cause duplicate loads, and a sentinel that stays inside the root after a short batch never crosses again, so nothing more arrives. Add an in-flight guard, and re-observe the sentinel after each render to force a fresh report.

open as a page

In a browser, what object is passed to the callback you register with requestIdleCallback(), and what do its timeRemaining() and didTimeout members tell you?

level: juniorimportance: should knowfreq 45%

basics

~20 s

requestIdleCallback invokes your callback with an IdleDeadline object. timeRemaining() returns how many milliseconds of idle time are left in this period, capped at 50, and didTimeout is true when the callback ran only because the timeout option expired.

open as a page

In a MutationObserver callback, every MutationRecord with type 'attributes' has oldValue === null. What did the observe() call leave out, and how do you also stop unrelated attributes from generating records?

level: juniorimportance: should knowfreq 38%

basics

~20 s

The observe() options omitted attributeOldValue: true, so the browser does not record previous values. Add it to populate oldValue, and add attributeFilter with an array of attribute names to record only the attributes you care about.

open as a page

What does the browser's scheduler.postTask() let you express about a piece of work that setTimeout and requestIdleCallback cannot, and what priorities does it accept?

level: middleimportance: should knowfreq 36%

basics

~20 s

scheduler.postTask() schedules a task at an explicit priority — 'user-blocking', 'user-visible' (the default), or 'background' — returns a promise for its result, and accepts a signal so the task can be cancelled or re-prioritised. setTimeout offers only a delay; requestIdleCallback offers only "whenever there is room".

open as a page

A dashboard batches analytics events and flushes them with requestIdleCallback(flush). On a busy page the flush does not run for many seconds. Why does that happen, and how do you bound the delay?

level: middleimportance: should knowfreq 50%

basics

~20 s

Idle callbacks only run when the main thread has spare time before the next frame, so a page with back-to-back long tasks never produces an idle period. Pass a timeout in the options object — requestIdleCallback(flush, { timeout: 2000 }) — and the browser runs the callback anyway once it expires.

open as a page

In a lazy-loading image gallery built on IntersectionObserver, you want images to start loading roughly 300px before they scroll into view. Which constructor option does that, and what are the rules for its value and for the root it is measured against?

level: middleimportance: should knowfreq 50%

basics

~20 s

rootMargin grows or shrinks the root's rectangle before the intersection is computed, so rootMargin: '300px 0px' reports images as intersecting 300px early. It follows CSS margin shorthand, every non-zero value must carry px or % units, and percentages resolve against the root's own dimensions.

open as a page

What does MutationObserver's takeRecords() return, and what happens to records that were queued but not yet delivered when you call disconnect()?

level: middleimportance: should knowfreq 42%

basics

~10 s

takeRecords() synchronously returns the observer's queued but undelivered MutationRecords and empties the queue, so the callback will not receive them again. disconnect() stops all observation and also empties that queue, silently discarding anything pending.

open as a page

In a ResizeObserver callback, how do an entry's contentRect, contentBoxSize, borderBoxSize and devicePixelContentBoxSize differ, and what does the box option passed to observe() actually control?

level: middleimportance: should knowfreq 45%

basics

~20 s

contentRect is the legacy content-box rectangle; contentBoxSize, borderBoxSize and devicePixelContentBoxSize are arrays of {inlineSize, blockSize} for the content box, the border box, and the content box in device pixels. The box option chooses which box's changes trigger a notification, not which properties you get.

open as a page

A browser routine splits a long job into chunks and yields between them with `await new Promise(r => setTimeout(r, 0))`. Why can the remaining chunks be delayed by unrelated work, and what does scheduler.yield() change?

level: seniorimportance: should knowfreq 33%

basics

~20 s

Yielding with a zero-delay timer puts your continuation at the back of the task queue, behind every task queued while you were running — so unrelated work runs between your chunks and the job stretches out. scheduler.yield() returns a promise whose continuation is prioritised ahead of newly queued tasks of the same priority.

open as a page

Analytics wants an ad slot counted as viewed only after at least 50% of it has been continuously on screen for one second. IntersectionObserver reports crossings, not durations — how would you build that on top of it, and when do you stop observing the slot?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Observe the slot with threshold 0.5, start a one-second timer on the entry where isIntersecting turns true, clear that timer on the entry where it turns false, and count the impression only if the timer survives. Then call unobserve on that slot so it can never be counted twice.

open as a page

A MutationObserver watching a container with { childList: true, subtree: true } appends a wrapper element inside that same container from its own callback, and the callback then runs over and over. Why, and how do you break the cycle?

level: seniorimportance: should knowfreq 46%

basics

~20 s

The callback's own DOM write is itself an observed mutation, so it queues a new record that re-invokes the callback, which writes again. Break it by disconnecting before writing and re-observing after, by making the write idempotent, or by narrowing the registration so your own changes are not observed.

open as a page

An analytics script registers one MutationObserver on document.body with { childList: true, subtree: true, attributes: true }, and the app becomes janky during large list renders. Why is that registration expensive, and how would you cut the cost?

level: seniorimportance: should knowfreq 33%

basics

~20 s

Every node insertion, removal and attribute write anywhere in the page allocates a MutationRecord and enlarges the batch handed to one main-thread callback, so a render touching thousands of nodes produces thousands of records. Narrow the target, drop unneeded categories, add attributeFilter, and keep the callback cheap.

open as a page

A page logs 'ResizeObserver loop completed with undelivered notifications' to the console, and the CI test runner fails the build because it treats it as an uncaught error. What produces that message, and how would you fix the code causing it?

level: seniorimportance: should knowfreq 50%

basics

~20 s

A ResizeObserver callback changed something that resized an observed element in the same frame, so the browser re-ran layout and re-delivered repeatedly. When it cannot settle, it stops, defers the remaining notifications to the next frame, and fires an error event on window. Fix the feedback, don't silence it.

open as a page

Legacy DOM mutation events such as DOMNodeInserted, DOMSubtreeModified and DOMAttrModified were deprecated in favour of MutationObserver. What was wrong with them?

level: middleimportance: nice to knowfreq 20%

basics

~20 s

Mutation events fired synchronously during each individual DOM change and propagated through the tree like any other event, so every insertion paid dispatch cost and handlers could mutate the DOM re-entrantly mid-operation. MutationObserver replaced them with asynchronous, batched record delivery.

open as a page

You lead a large web app where teams keep deferring work — prefetch, analytics, offscreen widget setup. How would you set a main-thread scheduling policy, and what goes wrong if everything is pushed into idle callbacks or background priority?

level: principalimportance: nice to knowfreq 22%

basics

~20 s

Classify deferrable work by who waits for it and how stale it may become, then map each class to a primitive with a bound: urgent to an explicit high priority, deferrable to a background task, disposable to an idle callback with a timeout. Deferring everything just relocates the jam.

open as a page

A design system has thirty components, and each one constructs its own ResizeObserver when it mounts. As the lead, how would you decide between per-instance observers and a single shared observer with an element-to-handler registry, and what does each option cost?

level: principalimportance: nice to knowfreq 28%

basics

~20 s

Per-instance observers keep ownership and failure local and are the right default. A shared observer with an element-to-handler registry cuts object count and gives one callback per frame plus one place to enforce policy, at the cost of a singleton whose registry can leak and whose callback is a shared failure point.

open as a page