DEV Community

Cover image for Preload just before visibility with rootMargin, thresholds, and a stability check
Chris Rodriguez
Chris Rodriguez

Posted on Originally published at chriseugenerodriguez.com

Preload just before visibility with rootMargin, thresholds, and a stability check

IntersectionObserver is great for lazy-loading, but the tricky part is deciding when to start loading. If you wait until the element is visible, users see pop-in. If you start too early, you waste bandwidth. This adaptation explains a small, testable pattern that separates three concerns: when to notice, which visibility milestone counts, and how to make the decision stable.

Implementation summary (the server will insert the verified code example): the preload wrapper forwards root and rootMargin into the IntersectionObserver constructor, passes a threshold array as the 'threshold' option, and stores per-element state in a WeakMap. When the observer reports an intersection, the wrapper schedules a microtask or short timer and then rechecks the element's bounding box and a shouldLoad predicate; if the element still has non-zero area and the predicate allows it, loadFn is called exactly once for that element.

Expected behavior

  • Early-but-not-too-early: a positive bottom rootMargin (px or %) starts prefetch while the element is still approaching the viewport, giving the asset time to be ready when the user reaches it.
  • Single-firing: thresholds plus a WeakMap 'loaded' flag ensure loadFn runs just once per target even if the observer callbacks oscillate.
  • Resistance to layout jitter: a microtask/timer debounce and a width/height > 0 check avoid starting loads for elements that briefly appear due to reflow, device pixel rounding, or resize.

Choosing units and thresholds

  • Pixel rootMargin: use when you need a fixed lead (predictable on a known layout).
  • Percentage rootMargin: use when the lead should scale with the root/container height (responsive layouts).
  • Threshold array: pick a single threshold (e.g., 0.25) for a one-shot trigger or a small array when you need more diagnostic crossings.

Limitations

  • The wrapper and tests prove the decision logic (option forwarding, single-fire behavior, jitter reduction) but do not simulate browser intersection geometry, network timing, or device-specific scrolling feel. You must tune rootMargin and thresholds in the browsers/devices you support.
  • The wrapper does not handle caching, connection prioritization, or post-fetch behavior — those are separate decisions.

Why use this pattern
Separating rootMargin (lead), thresholds (milestone), and a stability debounce (correctness guard) yields a predictable preload trigger that reduces pop-in and avoids duplicate loads. See the server-inserted example and the article for tests that demonstrate the contract and how to adapt it to your components.

Working example

export function createPreloadObserver({
  root = null,
  rootMargin = '0px 0px 0px 0px',
  thresholds = [0],
  debounceMs = 0,
  shouldLoad = () => true,
  loadFn = () => {},
  IntersectionObserverClass = globalThis.IntersectionObserver,
} = {}) {
  if (typeof IntersectionObserverClass !== 'function') {
    throw new TypeError('IntersectionObserverClass must be provided');
  }

  // Map from element -> state
  const elementState = new WeakMap();

  // Microtask / short timer debounce helper: returns a canceller
  function scheduleCheck(el) {
    const state = elementState.get(el) || { pending: null };
    if (state.pending) return; // already scheduled

    // Use microtask debounce if debounceMs === 0, otherwise setTimeout
    if (debounceMs === 0) {
      state.pending = Promise.resolve().then(async () => {
        state.pending = null;
        await performStableCheck(el);
      });
    } else {
      state.pending = setTimeout(async () => {
        state.pending = null;
        await performStableCheck(el);
      }, debounceMs);
    }

    elementState.set(el, state);
  }

  // A layout-stable check: confirm element still has a non-zero area and
  // that shouldLoad still returns true. Consumers can provide an element
  // with a getBoundingClientRect method (like DOM elements) or a
  // plain object that implements that method in tests.
  async function performStableCheck(el) {
    try {
      const rect = typeof el.getBoundingClientRect === 'function'
        ? el.getBoundingClientRect()
        : null;

      // If rect exists but area is zero, consider it not stable / not visible.
      if (rect && (rect.width === 0 || rect.height === 0)) return;

      if (!shouldLoad(el)) return;

      // ensure we only call load once per element
      const state = elementState.get(el) || {};
      if (state.loaded) return;
      state.loaded = true;
      elementState.set(el, state);

      await loadFn(el);
    } catch (err) {
      // Swallow to avoid unhandled promise rejections during microtask
      // but rethrow synchronously so tests can see mistakes when desired.
      setTimeout(() => { throw err; });
    }
  }

  const observer = new IntersectionObserverClass((entries) => {
    for (const entry of entries) {
      // Only schedule a preload when IntersectionObserver reports
      // that the element is intersecting (isIntersecting true) OR
      // when a threshold crossing happened that implies we passed
      // a numeric threshold. The IO API guarantees callbacks only
      // happen when thresholds are crossed or intersection state
      // flips, so checking isIntersecting is usually enough for
      // a preload-before-visible strategy.
      if (entry.isIntersecting) {
        scheduleCheck(entry.target);
      }
    }
  }, { root, rootMargin, threshold: thresholds });

  return {
    observe(target) {
      elementState.set(target, { loaded: false, pending: null });
      observer.observe(target);
    },
    unobserve(target) {
      observer.unobserve(target);
      elementState.delete(target);
    },
    disconnect() {
      observer.disconnect();
    },
    // expose internals for test inspection only (not for production use)
    _observerInstance: observer,
  };
}
Enter fullscreen mode Exit fullscreen mode

Read the full article: https://chriseugenerodriguez.com/blog/intersectionobserver-preload-without-jitter

Top comments (0)