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