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?
answer
- callback runs before any scrolling
- observe queues a baseline report
- first entry is a starting state
- isIntersecting can be false
- branch on the flag, not on being called
basics
~20 sobserve() 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 sCalling `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 linesconst 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
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.
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.
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'.
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