When you pass a threshold array to IntersectionObserver, intermediate values can be silently skipped during fast scroll — and the fix requires understanding how the browser schedules callbacks, not just reading the API docs.

Problem / Scenario Context

Threshold misfires surface most often in two situations: viewport-triggered animations that must fire at specific visibility percentages, and analytics pipelines that need to record how far into view an ad or content block scrolled. A developer sets threshold: [0.25, 0.5, 0.75], expecting three sequential callback invocations as the element enters the viewport, and finds that only one fires.

This happens because the IntersectionObserver API deep dive documents a fundamental scheduling constraint: callbacks are dequeued once per animation frame, not once per threshold boundary crossed. If the user scrolls fast enough to carry the element past all three boundaries inside a single 16 ms frame, the callback receives one entry reporting the final ratio. The intermediate thresholds are not delivered — they were never individually "observed" between frames.

The same constraint applies to CSS-driven animations and programmatic scrolls using behavior: 'instant'. Understanding this frame-alignment model is the prerequisite for building threshold logic that works correctly at any scroll speed.

Mechanics Explanation

The browser maintains an internal intersection observation task. On each rendering opportunity (roughly aligned with the display refresh cycle), the browser:

  1. Computes the current intersectionRect and intersectionRatio for every observed target.
  2. Compares each ratio against the observer's threshold list.
  3. If the ratio has crossed at least one threshold boundary since the last delivery, queues one IntersectionObserverEntry for that target.
  4. Delivers all queued entries to the callback as a batch.

The key word is "one entry per target per frame." Only the final ratio computed at step 1 makes it into the entry — there is no buffering of the intermediate ratios the element passed through during the frame interval.

IntersectionObserver threshold frame-batching timeline A horizontal timeline with two frames. In the slow-scroll frame, the element crosses 0.25 then 0.5, generating two callbacks. In the fast-scroll frame, the element jumps from 0.0 to 0.82 in one frame interval, generating only one callback with ratio 0.82 — the 0.25 and 0.5 thresholds are skipped. frame N frame N+1 frame N+2 slow ratio: 0.0 → 0.25 ratio: 0.25 → 0.50 cb(0.25) cb(0.50) fast ratio: 0.0 → 0.82 in one frame — thresholds 0.25, 0.50 never sampled cb(0.82) callback delivered skipped window

There is a second precision constraint: intersectionRatio is derived from integer pixel counts divided against the element's total area. Because pixel coordinates are discrete, the resulting float is rounded to roughly 3 decimal places. A threshold of 0.5 may be computed as 0.499 or 0.501 depending on element size, device pixel ratio, and subpixel antialiasing. Strict equality comparisons (ratio === 0.5) will therefore miss callbacks that the browser considers to have fired.

Comparison Table

Scroll Condition Thresholds Declared Callbacks Actually Delivered Reason
Slow (1 threshold/frame) [0.25, 0.5, 0.75] Three callbacks: 0.25, 0.5, 0.75 Each boundary crossed in a separate frame
Fast (all thresholds in one frame) [0.25, 0.5, 0.75] One callback: final ratio (e.g., 0.82) Browser samples once per frame; only terminal ratio reported
behavior: 'instant' scroll [0.1, 0.5, 0.9] One or two callbacks Instant jump often spans multiple frame intervals; layout pass determines final state
CSS animation (fast) [0.25, 0.5] One callback at animation end Same frame-batching rule applies to animated transforms
rootMargin contraction [0.5] May fire unexpectedly early rootMargin shifts effective root — threshold applies to contracted root area

Minimal Reproducible Example

This self-contained snippet isolates the frame-batching behavior. Run it in a browser console on any page with enough scroll height.

TypeScript
// TypeScript — paste into a .ts file or browser console (remove type annotations for plain JS)
interface ThresholdLog {
  ratio: number;
  timestamp: number;
  isIntersecting: boolean;
}

const target = document.createElement('div');
Object.assign(target.style, {
  width: '200px', height: '50px',
  background: 'salmon', margin: '2000px auto 2000px'
});
document.body.appendChild(target);

const log: ThresholdLog[] = [];

const observer = new IntersectionObserver((entries) => {
  entries.forEach(e => {
    log.push({ ratio: e.intersectionRatio, timestamp: performance.now(), isIntersecting: e.isIntersecting });
    console.log('ratio:', e.intersectionRatio.toFixed(3), 'ts:', performance.now().toFixed(1));
  });
}, { threshold: [0.1, 0.25, 0.5, 0.75, 0.9] });

observer.observe(target);

// Trigger fast scroll — intermediate thresholds will be skipped
window.scrollTo({ top: 2020, behavior: 'instant' });

// Compare: slow scroll fires more callbacks
// window.scrollTo({ top: 2020, behavior: 'smooth' });

In the fast-scroll case you will see one or two log lines rather than five, confirming that intermediate thresholds were coalesced within the same frame.

Production-Safe Solution

The canonical fix reconstructs which thresholds were logically crossed by comparing the previous ratio to the current ratio inside the callback. This works regardless of whether the browser coalesced frames.

TypeScript
interface ThresholdCrossing {
  threshold: number;
  entry: IntersectionObserverEntry;
  direction: 'enter' | 'exit';
}

type ThresholdHandler = (crossing: ThresholdCrossing) => void;

class ThresholdObserver {
  private readonly thresholds: readonly number[];
  private readonly handler: ThresholdHandler;
  private lastRatio: number;
  private observer: IntersectionObserver | null;

  constructor(
    target: Element,
    thresholds: number[],
    handler: ThresholdHandler
  ) {
    // Sort ascending so reconstruction loop is predictable
    this.thresholds = [...thresholds].sort((a, b) => a - b);
    this.handler = handler;
    this.lastRatio = -1; // sentinel: not yet observed

    this.observer = new IntersectionObserver(
      this.handleEntries.bind(this),
      { threshold: this.thresholds as number[], rootMargin: '0px' }
    );
    this.observer.observe(target);
  }

  private handleEntries(entries: IntersectionObserverEntry[]): void {
    for (const entry of entries) {
      const current = entry.intersectionRatio;
      const prev = this.lastRatio;

      // Reconstruct every threshold boundary the element passed through
      for (const t of this.thresholds) {
        const enteredAt = prev < t && current >= t;
        const exitedAt  = prev >= t && current < t;
        if (enteredAt || exitedAt) {
          this.handler({ threshold: t, entry, direction: enteredAt ? 'enter' : 'exit' });
        }
      }
      this.lastRatio = current;
    }
  }

  destroy(): void {
    if (this.observer) {
      // Flush any pending entries before disconnecting
      this.observer.takeRecords();
      this.observer.disconnect();
      this.observer = null;
    }
  }
}

// Plain JS equivalent — remove type annotations:
// class ThresholdObserver {
//   constructor(target, thresholds, handler) { ... }
//   handleEntries(entries) { ... }
//   destroy() { ... }
// }

Key decisions in this implementation:

  • lastRatio starts at -1 so the first callback (ratio going from "unobserved" to any positive value) correctly triggers enter crossings even if the initial ratio is already above a threshold.
  • takeRecords() is called before disconnect() to flush any callbacks that were queued but not yet delivered. Without this, entries computed during the final animation frame are silently discarded. This is the memory and lifecycle management pattern recommended for all observer teardown.
  • The threshold array is sorted ascending so the loop evaluates boundaries in a consistent order, which matters when the handler has side effects that depend on crossing sequence.

For syncing observer callbacks with requestAnimationFrame to schedule DOM mutations triggered by threshold crossings, wrap the handler call inside requestAnimationFrame rather than mutating synchronously inside handleEntries.

Verification Steps

  • Open Chrome DevTools > Performance, start recording, trigger a fast scroll, stop recording. In the Main thread flame chart, search for IntersectionObserver. Each task block represents one callback delivery. Count the blocks — each represents one batch, not one threshold.
  • In the console, add console.log(entry.intersectionRatio.toFixed(4), performance.now()) to your callback. On a fast scroll the timestamps between entries will be more than 16 ms apart, confirming frame-level batching.
  • Set threshold: [0.5] on a 100px element and verify with getBoundingClientRect() that the element is exactly 50px intersecting when the callback fires. Subpixel differences will show as 0.4980.502; use Math.abs(ratio - 0.5) < 0.01 to tolerate this.
  • Call observer.takeRecords() immediately after observer.disconnect() in a teardown path and log the returned entries — if the array is non-empty, the implementation was previously losing those callbacks on unmount.
  • In React, add console.log('cleanup') to the useEffect cleanup and confirm it runs before the component unmounts by navigating away; the observer must be disconnected by then.

Common Mistakes to Avoid

  • Strict equality on ratio: entry.intersectionRatio === 0.5 will frequently return false even when the element is at exactly half-visibility. Always use a tolerance: Math.abs(entry.intersectionRatio - 0.5) < 0.01.
  • Omitting takeRecords() before disconnect(): Skipping this call silently drops the final batch of entries. In analytics pipelines this causes underreporting of visibility milestones when a user navigates away mid-scroll.
  • Assuming threshold: [0, 1] covers all entry/exit cases: threshold: 0 fires when any pixel enters or exits; threshold: 1 fires only when the element is fully contained by the root. If the element is larger than the root, intersectionRatio can never reach 1.0 — the callback for that threshold never fires. Cap your maximum threshold at a value that is geometrically achievable given the element's size.
  • Initializing the observer before the DOM is mounted: In SSR/CSR hybrid frameworks, constructing the observer in a module-level scope or during server rendering yields intersectionRatio: 0 on the first synthetic callback, triggering false-positive exit logic. Always initialize inside useEffect, onMounted, or ngAfterViewInit.

↑ Back to IntersectionObserver API Deep Dive