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,
};
}
Read the full article: https://chriseugenerodriguez.com/blog/intersectionobserver-preload-without-jitter
Top comments (0)