Un listener scroll en window puede dispararse cientos de veces durante un solo swipe. Si en cada uno de esos eventos ejecutas getBoundingClientRect() sobre todas las tarjetas de un feed, cincuenta imágenes bastan para que la página empiece a ir a tirones. La Intersection Observer API elimina esa comprobación del flujo del scroll: registras qué elementos observar y el navegador te avisa cuando su visibilidad cambia de verdad.

Cómo funciona IntersectionObserver

Un observer vigila uno o más elementos target respecto a un root (el viewport, por defecto) y llama a una callback cada vez que un target cruza un umbral de visibilidad.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    console.log(entry.target, entry.isIntersecting, entry.intersectionRatio);
  }
}, {
  root: null,         // null = el viewport del navegador
  rootMargin: "0px",  // agranda o reduce la caja del root antes de probar
  threshold: 0,       // se dispara en cuanto se ve 1px
});

document.querySelectorAll(".card").forEach((el) => observer.observe(el));

Cada entry trae isIntersecting (un booleano), intersectionRatio (cuánto del target cae dentro del root, de 0 a 1) y el propio boundingClientRect del target. La callback es asíncrona. El navegador calcula las intersecciones una vez por frame, como parte de su propio trabajo de renderizado, así que tu código nunca fuerza un recálculo de layout mientras el usuario hace scroll.

threshold también acepta un array. [0, 0.5, 1] dispara la callback por separado al 0%, 50% y 100% de visibilidad, que es como se envía un evento de “marcado como leído al llegar a la mitad” sin crear tres observers.

Cómo hacer lazy loading de imágenes con IntersectionObserver

El caso de uso clásico: no descargar una imagen hasta que está a punto de entrar en el viewport.

1
<img class="lazy" data-src="/media/hero-photo.jpg" alt="Cresta montañosa al atardecer" width="1200" height="800">
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const imgObserver = new IntersectionObserver((entries, obs) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;
    const img = entry.target;
    img.src = img.dataset.src;
    img.classList.remove("lazy");
    obs.unobserve(img); // ya terminamos con esta
  }
}, { rootMargin: "200px 0px" }); // empieza a cargar 200px antes de que sea visible

document.querySelectorAll("img.lazy").forEach((img) => imgObserver.observe(img));

rootMargin es lo que hace que esto se sienta instantáneo. Un valor positivo agranda la caja efectiva del root, así que la callback se dispara mientras la imagen todavía está 200px por debajo del pliegue, en vez de en su primer píxel visible. Mantén también width y height explícitos en la etiqueta: reservan el espacio para que el layout no salte cuando llega la imagen real, una causa distinta de layout shift que la mayoría de demos de lazy loading ignora.

Antes de escribir todo esto, comprueba si <img loading="lazy"> es suficiente. Todos los navegadores actuales lo soportan de forma nativa, sin JavaScript. La versión con observer solo tiene sentido cuando necesitas algo que el atributo no puede hacer: un swap de placeholder personalizado, vídeo o iframes lazy, o imágenes de fondo puestas desde CSS.

Cómo construir scroll infinito con un elemento centinela

El scroll infinito no vigila el contenido que ya has cargado. Vigila un elemento vacío debajo de la lista y carga más cuando ese centinela se hace visible.

1
2
<ul id="results"></ul>
<div id="sentinel"></div>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const sentinel = document.querySelector("#sentinel");
let page = 1;

const scrollObserver = new IntersectionObserver(async (entries) => {
  if (!entries[0].isIntersecting) return;
  scrollObserver.unobserve(sentinel); // pausa mientras carga esta página
  await loadNextPage(page++);
  scrollObserver.observe(sentinel);   // retoma la vigilancia para la siguiente
}, { rootMargin: "400px" });

scrollObserver.observe(sentinel);

El par unobserve/observe está ahí por las redes lentas. Sin él, un usuario que haga scroll lo bastante rápido puede disparar la callback otra vez antes de que la primera petición se resuelva, y dos páginas de resultados llegan desordenadas.

loadNextPage es una llamada fetch normal, así que necesita la misma comprobación de response.ok que cualquier otra petición. La guía sobre el manejo de errores de la Fetch API cubre qué hacer cuando esa petición responde 404 o la red se cae a mitad de scroll. Si falla, deja el centinela sin observar y muestra un botón de reintento. Volver a observarlo de inmediato te mete en un bucle de peticiones fallidas.

La forma antigua de hacer esto era un handler de scroll con throttle o debounce que llamaba a getBoundingClientRect() sobre el centinela. Como muestra la comparación entre debounce y throttle, ambas técnicas siguen haciendo polling, solo que con menos frecuencia. Un observer no hace polling en absoluto. El navegador es dueño de la comprobación y tu código reacciona al resultado.

Cuándo se ejecuta la callback de IntersectionObserver

No en la cola de microtasks. Si has leído cómo async/await interactúa con el event loop, no asumas que la callback se comporta como la continuación de una promise. El navegador la planifica desde su pipeline de renderizado, cerca del paint. Por eso es lo bastante ligera como para usarla en una página que hace scroll, y también por eso es la herramienta equivocada para lógica que tiene que ejecutarse antes del próximo repaint.

Trampas comunes de IntersectionObserver

Comprueba una bounding box, no píxeles. Un elemento marcado como 100% intersecante puede seguir estando completamente oculto detrás de un modal opaco o un header sticky pintado encima. La API responde a “esta caja está dentro de aquella caja”, no a “el usuario puede ver esto”.

Un observer por elemento se acumula. Llamar a new IntersectionObserver() para cada una de doscientas tarjetas crea doscientos observers independientes. Crea uno por comportamiento (uno para todas las imágenes lazy, otro para el centinela) y llama a .observe() sobre cada target que lo necesite. La callback ya recibe entries de cualquier target que haya cambiado.

jsdom no lo implementa. En una suite de Jest o Vitest que corre sobre jsdom, IntersectionObserver es undefined a menos que lo mockees o instales un polyfill. Mockea el constructor y llama tú mismo a la callback con entries falsas para probar la lógica de lazy loading de forma aislada. No intentes simular un scroll real en un test unitario.

Observar antes de que el elemento exista. En un framework que renderiza de forma asíncrona, llamar a observer.observe(el) sobre una ref todavía null falla en silencio: sin error, sin warning, el elemento nunca se observa. Observa dentro del hook que corre después de que el nodo del DOM esté montado (useEffect en React, mounted en Vue).

Cuándo usar IntersectionObserver en vez de loading=“lazy”

Para etiquetas <img> simples, añade loading="lazy" y no escribas JavaScript. Usa IntersectionObserver para lo que un atributo no puede expresar: triggers de paginación, autoplay de vídeo al hacerse visible, eventos de “visto” para analítica, swaps de placeholder a imagen. Un buen primer paso es buscar en tu código el listener de scroll que llama a getBoundingClientRect() y sustituirlo por un único observer compartido.

Artículos relacionados