skip to content

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%

answer

  1. edges, not durations
  2. one timer per target
  3. clear it when it leaves
  4. retire the slot once counted
  5. geometry does not mean seen

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.

solid answer

~50 s

IntersectionObserver gives you the two edges — the moment the slot passes 50% and the moment it drops back below — so the dwell requirement becomes a timer stretched between them. Keep a `Map` keyed by `entry.target` holding the pending timeout: on an intersecting entry, start `setTimeout(count, 1000)` and store it; on a non-intersecting entry, `clearTimeout` and delete it, so a fast scroll-past never counts. When the timer fires, record the impression and call `observer.unobserve(target)` — the slot's answer can no longer change anything, and unobserving is what prevents a double count when the user scrolls back. If you need the exact dwell rather than a fixed threshold, `entry.time` is a high-resolution timestamp comparable with `performance.now()`, so you can subtract the two crossings. One caveat worth raising unprompted: intersection is geometry, so a backgrounded tab keeps reporting the slot as intersecting — pair it with `document.visibilityState` if the metric is supposed to mean a human looked at it.

code

javascript · 20 lines
javascript
const pending = new Map();

const io = new IntersectionObserver((entries, obs) => {
  for (const entry of entries) {
    const el = entry.target;
    if (entry.isIntersecting) {
      if (pending.has(el)) continue;
      pending.set(el, setTimeout(() => {
        pending.delete(el);
        obs.unobserve(el);
        console.log('impression', el.dataset.slotId);
      }, 1000));
    } else {
      clearTimeout(pending.get(el));
      pending.delete(el);
    }
  }
}, { threshold: 0.5 });

document.querySelectorAll('.ad-slot').forEach((el) => io.observe(el));

go deeper

for a junior

Know that the observer tells you when something crosses in and out, and that turning that into 'visible for one second' means starting a timer on the way in and cancelling it on the way out.

for a middle

Explain the per-target bookkeeping: a map keyed by entry.target, a guard against starting a second timer, and clearing on the non-intersecting entry so a quick scroll-past does not count.

for a senior

Show the operational judgment — unobserve after counting so the guarantee is structural, use entry.time when you need real dwell, and name the cases the geometry cannot see: occlusion, hidden tabs, and exposure interrupted by navigation.

for a principal

Own the definition of the metric before the code: what 'viewed' means for this business, which failure you prefer between undercounting and overcounting, and how the client-side measurement is reconciled with whatever the server believes. Then make one shared tracker, not one per team.

## Why the API alone does not answer the question The requirement has three parts — an area fraction, a duration, and a once-only guarantee. `IntersectionObserver` answers exactly one of them. It tells you when the target's visible fraction crosses a threshold you nominated, in either direction. It has no concept of "for how long", and no concept of "already counted". Both are yours to add, and an interviewer asking this is checking whether you know where the API's responsibility ends. ## The shape of the solution Threshold 0.5 gives you a pair of events per exposure: a rising crossing and a falling one. A dwell requirement is a timer that must survive from one to the other. ```js const pending = new Map(); // Element -> timeout id const io = new IntersectionObserver((entries, obs) => { for (const entry of entries) { const el = entry.target; if (entry.isIntersecting) { if (pending.has(el)) continue; // already ticking pending.set(el, setTimeout(() => { pending.delete(el); obs.unobserve(el); // count once, then retire recordImpression(el.dataset.slotId); }, 1000)); } else { clearTimeout(pending.get(el)); pending.delete(el); // scrolled past: no impression } } }, { threshold: 0.5 }); document.querySelectorAll('.ad-slot').forEach((el) => io.observe(el)); ``` Four details in that snippet are the actual answer: 1. **Keyed per target.** One observer watches every slot and delivers a batch of entries, so a single module-level timer is wrong the moment there are two slots. `entry.target` is the key. 2. **Cleared on the way out.** Without the `else` branch, a flick-scroll past the slot still counts a second later, and the numbers inflate exactly where advertisers care. 3. **`unobserve` after counting.** It makes the once-only guarantee structural rather than a flag someone can forget to check, and it stops the browser evaluating a slot whose answer is now irrelevant. 4. **The re-entrancy guard.** The initial observation, plus repeated crossings around the tripwire during slow scrolling, can deliver several intersecting entries; without `if (pending.has(el)) continue` you leak overlapping timers. ## Measuring real dwell instead of a fixed threshold If the metric is "total seconds visible" rather than "was it visible for a second", stop using timers for measurement and use the entries' own clock. `entry.time` is a `DOMHighResTimeStamp` on the same time origin as `performance.now()`, so accumulating `time` deltas between rising and falling crossings gives you dwell without a single `setTimeout`. That is also more robust: timers can be delayed by long tasks, while the timestamps record when the geometry actually changed. ```js // on the rising edge start.set(el, entry.time); // on the falling edge total.set(el, (total.get(el) ?? 0) + (entry.time - start.get(el))); ``` ## What the geometry does not know A senior answer volunteers the honest limits of "viewed": - **Occlusion.** A slot under a cookie banner or a modal still intersects. IntersectionObserver v2 adds `trackVisibility` with a required `delay` and reports `entry.isVisible`, but it is Chromium-only, so a cross-browser tracker cannot depend on it. - **Tab and window state.** Intersection is measured against the viewport box, not against whether the tab is foregrounded. A slot that was intersecting when the user switched away stays in that state, and a naive `setTimeout` still fires, so background tabs quietly accrue impressions. Reading `document.visibilityState` — and abandoning the pending timer when the page is not visible — is what makes the metric defensible. - **The unload boundary.** A partial exposure that is still ticking when the user navigates away is simply lost unless you flush what you have on the way out, which is a transport decision rather than an observer one. ## Teardown Every pending timeout is a live reference to your callback and its closure. When the view is destroyed, `observer.disconnect()` plus clearing every entry in the `Map` is the sweep; doing only one of the two leaves either timers firing into a dead view or an observer still evaluating detached elements. In a single-page app that mounts and unmounts feeds repeatedly, that pair is the difference between a stable memory profile and a slow climb. ## How to present it Lead with "the observer gives me edges; the duration is a timer between them". Then name the four details — per-target keying, clearing on exit, `unobserve` after counting, and the guard against duplicate rising entries. Finish with the honesty about occlusion and hidden tabs. That is a complete senior answer in under two minutes.

  • Why keep the timers in a Map keyed by the element instead of a single variable?
    Because one observer reports many slots, and a batch can contain entries for several of them in one invocation. A single variable makes the second slot overwrite the first's timer, so one impression is lost and another may be attributed to the wrong slot. `entry.target` is a stable key, and the map also gives you the list to clear on teardown.
  • How would you measure total visible time rather than checking a one-second threshold?
    Use `entry.time`, a high-resolution timestamp on the same origin as `performance.now()`. Record it on the rising crossing, subtract on the falling one, and accumulate per target. It avoids timers entirely and is more accurate under load, since a busy main thread delays a `setTimeout` callback but not the recorded timestamp of the geometry change.
  • The user switches to another tab while an ad slot is on screen. What does your tracker count, and is that right?
    By default it keeps counting: intersection is geometry, and the slot's last reported state stays intersecting, so a pending `setTimeout` still fires. That inflates the metric, since nobody is looking. Gate the count on `document.visibilityState` being 'visible' and abandon or pause the pending timer when the page hides.

saying these in an interview costs you the question

  • Assumes IntersectionObserver reports how long an element stayed visible
  • Starts the dwell timer but never clears it on the way out
  • Counts an impression on every intersecting entry, inflating fast scrolls
  • Leaves the slot observed after counting and double-counts on scroll back
  • Treats isIntersecting true as proof a human saw the pixels

context