Cómo implementar el scroll infinito en Vanilla JavaScript
Implementa scroll infinito en JavaScript puro con Intersection Observer, un elemento centinela, paginación, control de cargas y accesibilidad.
Implementa el scroll infinito en vanilla JavaScript con la Intersection Observer API: coloca un elemento centinela (sentinel) al final de tu lista, obsérvalo y solicita la siguiente página de datos cada vez que entre en el viewport.
Si ya has construido algo así antes, conoces el modo de fallo: mueves la barra de desplazamiento con un poco de más fuerza y los mismos diez elementos aparecen en la lista tres veces. Hacer que la mecánica básica funcione lleva unos diez minutos; conseguir que sobreviva a un usuario real te lleva el resto de la tarde. Esto reemplaza al viejo enfoque de evento scroll más getBoundingClientRect, que ejecuta cálculos de posición en cada tick del scroll. Esta guía construye un feed completo y ejecutable, con fetch real, paginación y añadido al DOM, y luego cubre los cuatro problemas de producción (peticiones duplicadas, no detenerse nunca, manejo de errores y momento del prefetch) más los fallbacks de accesibilidad que separan una demo de código listo para publicar.
Puntos clave
- Usa
IntersectionObserver, no eventos de scroll: un listener de scroll se dispara continuamente en el hilo principal y obliga a hacer cálculos de posición manuales, mientras que el observer ejecuta un callback solo cuando el objetivo realmente cruza el viewport. IntersectionObserverforma parte del Baseline en todos los navegadores modernos desde marzo de 2019, por lo que hoy el scroll infinito no necesita ningún polyfill.- Protege cada fetch con un flag booleano para que un scroll rápido no pueda lanzar varias peticiones solapadas antes de que la primera se resuelva.
- Detente cuando la API devuelva una página corta o vacía. Llama a
observer.disconnect()y oculta el centinela, o el observer seguirá pidiendo páginas que ya no existen. - Combina el scroll infinito con un botón visible de “Cargar más”: es a la vez el fallback para teclado, para lectores de pantalla y para escenarios sin JavaScript.
¿Por qué IntersectionObserver supera a los eventos de scroll?
Usa IntersectionObserver en lugar de un listener de scroll porque informa de la visibilidad de forma asíncrona a través de un callback que se dispara solo cuando tu objetivo cruza el viewport, en vez de ejecutarse en cada frame de scroll. El patrón antiguo adjunta un handler de scroll y llama a getBoundingClientRect() en cada tick para calcular si el final de la lista está cerca. Eso son cálculos que leen el layout en el hilo principal, ejecutándose muchas más veces de las necesarias, y es una fuente bien conocida de jank en el scroll.
scroll + getBoundingClientRect() | IntersectionObserver | |
|---|---|---|
| Se dispara | En cada frame de scroll | Solo cuando el objetivo cruza el viewport |
| Cálculo de posición | Manual, en tu código | Lo maneja el navegador |
| Hilo de ejecución | Sincrónico en el hilo principal | Entregado de forma asíncrona |
| ¿Necesita polyfill? | n/a | No (Baseline) |
No se requiere ningún polyfill. MDN marca la API como Baseline Widely available, con soporte en todos los navegadores principales desde marzo de 2019, por lo que los consejos antiguos que recomiendan un polyfill (y citan el soporte de la era de Chrome 51) están desactualizados. Una excepción: no recurras a trackVisibility por defecto, ya que MDN sigue listando esa propiedad de detección de oclusión como experimental y con disponibilidad limitada.
¿Qué es el patrón del centinela?
Discover how at OpenReplay.com.
El patrón del centinela coloca un único elemento marcador al final de la lista; cuando el observer informa de que el centinela ha entrado en el viewport, solicitas la siguiente página y la añades. El centinela es simplemente un elemento vacío después de tu último ítem, y nunca necesitas volver a seleccionarlo, porque añadir nuevos elementos lo va empujando cada vez más abajo.
Las tres piezas móviles:
- Construir el observer:
new IntersectionObserver(callback, options). - Empezar a observar:
observer.observe(sentinel). - En el callback, comprobar
entry.isIntersectingy cargar la siguiente página cuando seatrue.
Itera el array entries en lugar de leer entries[0]. La referencia del constructor IntersectionObserver() advierte contra asumir un número concreto de entradas, porque una sola ejecución de tu callback puede llevar varios cruces a la vez.
Un ejemplo completo de scroll infinito en vanilla JavaScript
A continuación tienes una implementación completa y funcional contra JSONPlaceholder, una API REST simulada y gratuita que se ejecuta sobre JSON Server con LowDB por detrás. Su endpoint /posts contiene 100 registros y acepta los parámetros de consulta _page y _limit, devolviendo el fragmento solicitado como un array simple. Eso te da un conjunto de datos finito, lo que resulta conveniente para demostrar qué ocurre cuando los datos se agotan.
El markup: una lista, un botón de fallback, un centinela y una línea de estado como live region.
<main>
<ul id="list" aria-label="Posts"></ul>
<button id="load-more" type="button">Load more</button>
<div id="sentinel" aria-hidden="true"></div>
<p id="status" role="status" aria-live="polite"></p>
</main>
El script conecta el observer al centinela y solicita una página por cada intersección:
const LIMIT = 10;
let page = 1;
let loading = false; // guard against overlapping requests
let done = false; // stop at end of data
const list = document.getElementById("list");
const sentinel = document.getElementById("sentinel");
const loadMoreBtn = document.getElementById("load-more");
const status = document.getElementById("status");
async function fetchPosts(page) {
const url = `https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=${LIMIT}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
function render(posts) {
const frag = document.createDocumentFragment();
for (const post of posts) {
const li = document.createElement("li");
li.innerHTML = `<h2>${post.title}</h2><p>${post.body}</p>`;
frag.appendChild(li);
}
list.appendChild(frag);
}
async function loadNextPage() {
if (loading || done) return;
loading = true;
status.textContent = "Loading…";
try {
const posts = await fetchPosts(page);
render(posts);
page += 1;
if (posts.length < LIMIT) { // short/empty page = no more data
done = true;
observer.disconnect();
loadMoreBtn.hidden = true;
status.textContent = "You've reached the end.";
} else {
status.textContent = "";
}
} catch (err) {
status.textContent = "Could not load posts. Tap Load more to retry.";
console.error(err);
} finally {
loading = false;
}
}
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) loadNextPage();
}
},
{ root: null, rootMargin: "200px", threshold: 0 }
);
observer.observe(sentinel);
loadMoreBtn.addEventListener("click", loadNextPage);
document.addEventListener("DOMContentLoaded", loadNextPage);
Todas las peticiones pasan por loadNextPage, así que el callback del observer, el clic en el botón y la carga inicial de DOMContentLoaded comparten la misma lógica de protección y de parada.
Los cuatro problemas que separan una demo del código listo para publicar
La mayoría de los tutoriales se detienen en “añade los datos”. Estas cuatro correcciones son las que hacen que sobreviva a usuarios reales.
| Síntoma | Causa | Solución |
|---|---|---|
| Peticiones duplicadas al hacer scroll rápido | Sin protección de peticiones | Flag booleano if (loading) return; |
| La lista nunca se detiene, vuelve a pedir páginas vacías | Sin detección del final | if (posts.length < LIMIT) observer.disconnect() |
| Los errores desaparecen en silencio | Sin ruta de error en fetch | Comprobar res.ok, try/catch, exponer un reintento |
| Pausa visible al llegar al final | rootMargin: "0px" | rootMargin: "200px" para hacer prefetch antes |
Protégete contra las peticiones duplicadas. Un movimiento rápido puede disparar el callback varias veces antes de que se resuelva el primer await. El flag loading hace que cada llamada extra retorne de inmediato hasta que la petición en curso se resuelva en el bloque finally. También puedes dejar de observar el centinela con unobserve durante la petición y volver a observarlo después. Solo no confundas unobserve (un objetivo) con disconnect (todos los objetivos).
Detente al final de los datos. Con una fuente finita, si sigues pidiendo acabarás bombardeando con peticiones páginas que no existen. Detecta una página más corta que LIMIT, la misma señal de fin de datos que se usa en el tutorial de bucle sobre APIs paginadas de Prismatic, que sale de su bucle en cuanto una petición vuelve con un array vacío. Luego llama a disconnect() y oculta el centinela y el botón.
Prefiere threshold: 0 con rootMargin. Establecer rootMargin: "200px" inicia la siguiente petición aproximadamente 200 píxeles antes de que el usuario llegue al final, eliminando la pausa visible. Combínalo con threshold: 0, no con 1.0: un centinela más alto que el viewport puede que nunca sea visible al 100 %, por lo que un umbral de visibilidad total puede fallar silenciosamente y no dispararse nunca.
Los bugs del scroll infinito dependen del timing y de la velocidad del scroll, así que un scroll local cuidadoso rara vez los reproduce. Observar sesiones reales mediante una herramienta como session replay es una forma de sacar a la luz ese tipo de fallos que permanecen invisibles en una prueba rápida: peticiones duplicadas al hacer un movimiento rápido, o una lista que nunca se detiene.
Accesibilidad y el fallback de “Cargar más”
Combina siempre el scroll infinito con un botón visible de “Cargar más”: es el fallback para teclado y lectores de pantalla, el fallback sin JavaScript y, a menudo, la única forma en que un usuario puede pausar el flujo para llegar al footer. El contenido con carga automática interminable atrapa a los usuarios de tecnologías asistivas, entierra los enlaces del footer detrás de contenido que no deja de crecer y rompe la restauración de la posición de scroll del botón “atrás” cuando el usuario vuelve a una posición que ya no existe en el DOM.
Tres pasos concretos, todos presentes en el código anterior:
- Anuncia el estado de carga a través de una live region:
<p role="status" aria-live="polite">permite que los lectores de pantalla escuchen “Loading…” y “You’ve reached the end.” - Mantén el botón como un control real y enfocable, para que funcione cuando el observer nunca se dispare o JavaScript esté desactivado.
- Marca el centinela con
aria-hidden="true". Es un mecanismo, no contenido, y no debería llegar al árbol de accesibilidad.
Si el footer de un feed realmente importa (enlaces de contacto, avisos legales, paginación para enlaces profundos), considera si un botón de “Cargar más” por sí solo es el mejor patrón y reserva la carga automática para contenido en el que el flujo interminable sea precisamente el objetivo.
El scroll infinito en vanilla JavaScript se reduce a una idea duradera: observar un centinela, hacer la petición en la intersección y manejar los casos límite. Toma el archivo completo de arriba, apunta fetchPosts a tu propio endpoint paginado y confirma que tanto la protección como la ruta de parada al final se disparan antes de publicar. Esas dos líneas son las que convierten una demo funcional en código en el que puedes confiar en producción.
Preguntas frecuentes
¿Cuál es la diferencia entre unobserve y disconnect en un IntersectionObserver?
Llama a unobserve cuando quieras que el observer deje de vigilar un elemento concreto y continúe con el resto, y llama a disconnect cuando quieras que suelte todo lo que está observando en ese momento. Para el scroll infinito, eso se traduce en unobserve para pausar el único centinela mientras una petición está en curso, y disconnect una vez que los datos se agotan y el observer ya no tiene más trabajo que hacer.
¿Cuándo debería usar paginación o un botón de Cargar más en lugar de scroll infinito?
Elige paginación o un botón de Cargar más cuando el footer importa, por ejemplo con enlaces de contacto, texto legal o paginación para enlaces profundos, porque la carga automática interminable deja el contenido del footer permanentemente fuera de alcance y atrapa a los usuarios de teclado y lectores de pantalla. El scroll infinito encaja en contenido abierto donde el flujo interminable es precisamente el objetivo, como los feeds sociales. Cuando los usuarios necesitan un punto de parada o deben llegar al final, un control explícito es el mejor patrón.
¿Por qué un threshold de 1.0 a veces no dispara el scroll infinito?
Un threshold de 1.0 requiere que el elemento observado sea visible al 100 por cien antes de que se dispare el callback, por lo que un centinela más alto que el viewport nunca puede entrar completamente en vista y el callback simplemente nunca se ejecuta. Usa en su lugar threshold 0 combinado con un buffer de rootMargin: así el callback se dispara en cuanto cualquier parte del centinela cruza el límite ampliado del root, que es el valor por defecto más fiable para el scroll infinito.
¿Necesito manejar múltiples entradas en el callback de IntersectionObserver?
Sí. La referencia del constructor en MDN indica que no debes confiar en que el array entries tenga una longitud concreta, porque una sola ejecución de tu callback puede llevar más de un cruce. Para un único centinela, entries[0] suele funcionar en la práctica, pero recorrer todas las entradas y comprobar isIntersecting en cada una es el enfoque correcto y evita intersecciones perdidas o mal atribuidas cuando más de un objetivo informa a la vez.
¿El scroll infinito rompe el botón atrás del navegador?
Sí, el scroll infinito puede romper la restauración de la posición de scroll del botón atrás, porque el navegador intenta devolver al usuario a una posición de scroll que ya no existe en el DOM después de que el contenido cargado dinámicamente se descarte al navegar. El usuario acaba en el lugar equivocado o al principio de la lista. Entre las mitigaciones están persistir el estado cargado, restaurar la posición de scroll manualmente u ofrecer un botón de Cargar más para que la navegación se corresponda con un estado estable y reproducible.