Mini-tutorial: Preload before visibility without jitter

Mini-tutorial: Preload before visibility without jitter

calendar_today ago • schedule2 min read
— Originally published at chriseugenerodriguez.com

Prerequisites

  • An observable component (implements getBoundingClientRect).
  • A load function (loadFn) that starts the fetch or image decode.
  • Familiarity with IntersectionObserver options: root, rootMargin, threshold.

Implementation decision
Use three levers: rootMargin to start observing earlier than the viewport edge, thresholds to tie notifications to an explicit visibility milestone, and a very short debounce plus a bounding-box size check to avoid triggering loadFn during rapid layout changes. Track per-target loaded state in a WeakMap so each target loads at most once.

Expected result

  • Positive bottom rootMargin gives lead time so assets can start downloading before the element becomes visible.
  • Threshold(s) let you express which percentage of the element should be visible before you consider it stable enough to begin loading.
  • Microtask (or short timeout) debounce plus width/height > 0 avoids duplicate or spurious loads caused by reflow or fractional-pixel jitter; loadFn runs once per element when it still has meaningful area.

Limitation
This pattern proves the decision logic and is unit-testable, but it does not remove the need for in-browser tuning: actual intersection timings, network conditions, image decode, and device viewport differences affect perceived pop-in. Choose px vs % rootMargin and threshold values based on where the component runs.

(Server will insert the verified implementation example for the platform.)

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,
  };
}

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

🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

I’m a Senior Dev and I’ve Forgotten How to Think Without a Prompt

Karol Modelski - Mar 19

SEO-Friendly Web Design Checklist: Architecture Before Aesthetics

floxolab - Aug 30

Retry Failed n8n API Requests Without Duplicates

floxolab - Oct 14

How Long Does SEO Take? Measure Progress Before Traffic

floxolab - Sep 30

Debugging a design prompt before it becomes code

NiceDayUp - Aug 18
chevron_left
483 Points • 22 Badges
Santa Monica, CA • chriseugenerodriguez.com
16Posts
0Comments
3Connections
10 years fe

Related Jobs

View all jobs →

Commenters (This Week)

20 comments
2 comments
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!