wandres.dev
LISTAS GRANDES · Virtualización y paginación

Scroll infinito: lo que rompe y cómo repararlo

El pie de página inalcanzable, la vuelta atrás que pierde la posición, el DOM que crece sin límite y los problemas de accesibilidad, con el patrón híbrido que los resuelve.

⏱ 19 min

El scroll infinito es el patrón de carga más popular de la última década y también el que más funcionalidad rompe por unidad de código. No es solo que el DOM crezca: es que convierte un recurso direccionable, cacheable y compartible —la página siete de resultados— en un estado acumulado en el cliente que no se puede enlazar, ni restaurar, ni imprimir, ni recorrer con el teclado hasta el final. Esta lección enumera cada rotura y da su reparación.

🎯 Al terminar esta lección sabrás
  • Enumerar las cinco funcionalidades que el scroll infinito rompe y por qué.
  • Implementar la carga por centinela con IntersectionObserver sin fugas ni cargas duplicadas.
  • Restaurar posición y estado al volver atrás, apoyándose en la caché de retroceso cuando exista.
  • Aplicar el patrón híbrido de scroll infinito con paginación direccionable.

Qué rompe exactamente

El pie de página deja de ser alcanzable. Si cada vez que el usuario llega abajo aparece más contenido, el pie se aleja para siempre. Cualquier cosa que viva ahí —contacto, aviso legal, mapa del sitio, cambio de idioma— se vuelve inaccesible para el ratón y para el teclado. Es el problema más citado y el más fácil de arreglar: no pongas nada útil en el pie de una página con scroll infinito, o dale al usuario una forma de detenerlo.

La vuelta atrás pierde el trabajo del usuario. El escenario es universal: el usuario baja durante dos minutos, abre el resultado cuarenta, pulsa atrás y aparece arriba del todo con diez resultados. Ha perdido dos minutos de desplazamiento. Este es, con diferencia, el daño real más grande del patrón, porque no es un fallo de accesibilidad que sufre una minoría: lo sufren todos, todo el rato.

El estado no es direccionable. No se puede compartir un enlace a “los resultados que estaba viendo”. No se puede abrir una posición en otra pestaña. No se puede volver mañana. Y un rastreador que no ejecute scroll no ve más allá de la primera tanda.

El DOM crece sin límite. Cada tanda añade nodos que no se quitan nunca. Después de veinte tandas de cincuenta elementos tienes mil elementos y todos los costes del primer capítulo del nivel, con el agravante de que la degradación es progresiva: la página va bien al principio y se atasca a los tres minutos, que es justo cuando el usuario está más metido.

Desaparece la noción de cuánto hay. Ni el usuario ni el lector de pantalla saben cuántos resultados existen, en cuál están, ni si merece la pena seguir. Una lista paginada dice “1.240 resultados, página 3 de 63”. Un scroll infinito no dice nada.

La carga por centinela, bien hecha

El disparo de la carga se hace con un elemento centinela al final de la lista y un IntersectionObserver con margen, para empezar a cargar antes de que el usuario llegue al borde.

export function cargaProgresiva({ centinela, cargar, margen = '600px' }) {
  let cargando = false;
  let agotado = false;

  const observador = new IntersectionObserver(
    async ([entrada]) => {
      if (!entrada.isIntersecting || cargando || agotado) return;
      cargando = true;
      try {
        const { hayMas } = await cargar();
        if (!hayMas) {
          agotado = true;
          observador.disconnect();
        }
      } catch (error) {
        // Sin esto, un fallo de red deja la lista muerta para siempre:
        // el centinela sigue intersecando pero cargando quedo en true.
        console.error(error);
      } finally {
        cargando = false;
      }
    },
    { rootMargin: margen }
  );

  observador.observe(centinela);
  return () => observador.disconnect();
}

Tres detalles que son la diferencia entre que funcione y que no. El guardián cargando evita que varias intersecciones seguidas disparen tres peticiones de la misma página. El finally lo devuelve a false también cuando hay error, que es el fallo que deja la lista congelada tras un corte de red. Y rootMargin de varios cientos de píxeles hace que la carga empiece antes de que el usuario vea el hueco, que es la diferencia entre una lista que fluye y una que se para en cada tanda.

Para el crecimiento indefinido del DOM hay dos salidas. La primera es combinar con virtualización: el scroll infinito trae datos, la virtualización decide qué se monta, y son ortogonales. La segunda, si no quieres virtualizar, es desmontar por arriba: cuando la lista pasa de N tandas, quita la más antigua y sustitúyela por un espaciador de su altura medida. Es media virtualización, y para muchos casos es suficiente.

Restaurar la posición al volver

Hay dos mecanismos y conviene usar los dos, porque cubren casos distintos.

La caché de retroceso del navegador —bfcache— congela la página entera, DOM incluido, cuando el usuario navega fuera, y la restaura tal cual al volver. Si tu página es elegible, el problema de la vuelta atrás no existe: el usuario vuelve exactamente donde estaba, con las mil filas montadas y el scroll en su sitio. Las condiciones de elegibilidad son estrictas y las rompe cualquier descuido: nada de manejadores de unload, nada de Cache-Control: no-store en el documento principal, nada de conexiones abiertas que el navegador no pueda congelar. Verifícalo en el panel de aplicación del navegador, que tiene una prueba específica que te dice si la página es elegible y, si no lo es, por qué.

La restauración manual cubre el resto: recarga, apertura desde un enlace, o navegación que no pasa por la caché.

// Al salir de la lista: guardar cuanto se habia cargado y donde estaba.
history.scrollRestoration = 'manual';

function guardarEstado(scroller, paginasCargadas) {
  sessionStorage.setItem(
    'lista:' + location.pathname,
    JSON.stringify({ scroll: scroller.scrollTop, paginas: paginasCargadas })
  );
}

// Al entrar: recargar las paginas y RESTAURAR DESPUES de montarlas.
async function restaurarEstado(scroller, cargarPagina) {
  const bruto = sessionStorage.getItem('lista:' + location.pathname);
  if (!bruto) return;
  const { scroll, paginas } = JSON.parse(bruto);
  for (let p = 0; p < paginas; p++) await cargarPagina(p);
  // Dos fotogramas: uno para que el DOM se monte, otro para que el
  // layout resuelva la altura. Antes de eso, scrollTop se recorta.
  requestAnimationFrame(() =>
    requestAnimationFrame(() => {
      scroller.scrollTop = scroll;
    })
  );
}

history.scrollRestoration = 'manual' es imprescindible: sin él, el navegador intenta restaurar el scroll por su cuenta sobre un documento que todavía no tiene altura, falla, y luego tu restauración compite con la suya. Y el doble requestAnimationFrame no es superstición: escribir scrollTop antes de que el layout haya dado altura al contenedor hace que el navegador lo recorte al máximo disponible, que en ese instante es cero.

La restauración manual tiene un coste honesto que hay que aceptar: para volver a la posición hay que volver a pedir todas las páginas. Si el usuario había cargado veinte tandas, son veinte peticiones. Por eso lo correcto es guardar el cursor y no el número de páginas, y pedir un rango en una sola petición si tu API lo permite.

Accesibilidad: foco, anuncios y conjunto

Cuatro medidas concretas, todas obligatorias.

Anunciar la carga. Cuando aparecen elementos nuevos sin que el usuario haya hecho nada, un lector de pantalla no dice nada. Hace falta una región en vivo.

<div aria-live="polite" aria-atomic="true" class="visualmente-oculto"
     id="anuncio"></div>
function anunciar(texto) {
  const region = document.getElementById('anuncio');
  region.textContent = '';                  // fuerza la re-lectura
  requestAnimationFrame(() => (region.textContent = texto));
}

anunciar('20 resultados mas cargados. 120 de 1240.');

El truco de vaciar y volver a escribir en el fotograma siguiente es necesario porque una región en vivo solo anuncia los cambios; si escribes el mismo texto dos veces, el lector calla.

No robar el foco. La tentación al cargar más es mover el foco al primer elemento nuevo. No lo hagas en la carga automática por scroll: el usuario no ha pedido nada y le acabas de mover el cursor. Sí hazlo cuando el usuario pulsa un botón explícito de cargar más, porque entonces sí ha pedido una acción y espera continuidad.

Declarar el conjunto. Igual que en la virtualización, aria-setsize con el total real y aria-posinset con la posición de cada elemento. Sin ellos, el lector anuncia “elemento 12 de 20” cuando hay mil doscientos cuarenta.

Dar una salida al teclado. Un usuario de teclado que quiera llegar al pie tiene que tabular por todos los elementos cargados, y cada vez que llega al final aparecen más. La solución estándar es un enlace de salto al principio de la lista —“saltar la lista de resultados”— y no tener nada indispensable después de ella.

El scroll infinito es una decisión de negocio disfrazada de decisión de interfaz, y por eso la discusión técnica no la gana nadie

Conviene saber de dónde viene el patrón, porque explica por qué es tan difícil de quitar una vez está puesto. El scroll infinito optimiza una métrica muy concreta: el tiempo de sesión y el número de elementos vistos. Y la optimiza de verdad, no es folclore; funciona precisamente porque elimina el punto de decisión que representa un botón. Cada vez que un usuario tiene que pulsar “siguiente”, una fracción de usuarios decide que ya ha visto bastante. Quitar ese punto de decisión sube las métricas de consumo de forma medible. Ahora fíjate en qué métrica no optimiza: la de completar una tarea. Si el usuario está buscando algo concreto —un producto, un documento, un mensaje de hace tres semanas— el scroll infinito es estrictamente peor que la paginación, porque no puede saltar, no puede volver, no puede marcar y no puede saber cuánto le queda. De ahí sale la regla de decisión que sí funciona y que evita la discusión de opiniones: scroll infinito para consumo, paginación para búsqueda. Un muro de fotos, un feed de novedades, una galería para pasar el rato: consumo, scroll infinito. Un listado de facturas, un buscador de productos, un archivo de documentación, unos resultados de búsqueda: tarea, paginación. La consecuencia práctica para ti, como ingeniero, es que cuando alguien de producto pide scroll infinito para una pantalla de tarea, la conversación no va de rendimiento ni de accesibilidad, aunque los argumentos que tengas a mano sean esos. Va de qué está midiendo esa pantalla. Presentar el problema como técnico es perder la discusión, porque los costes técnicos son reparables —los tienes todos en esta lección— y las métricas de sesión son reales. Presentarlo como lo que es, un intercambio entre tiempo de sesión y tasa de finalización, la convierte en una conversación que se puede tener con datos. Y a menudo termina en el patrón híbrido, que es el que de verdad quiere todo el mundo.

El patrón híbrido

La solución que usan las tiendas y los buscadores grandes combina lo mejor de los dos mundos y no es complicada.

  1. Carga automática por scroll durante unas pocas tandas. Tres o cuatro. El usuario que está explorando no toca nada.
  2. Después, un botón explícito de cargar más. Corta la cinta transportadora, hace alcanzable el pie, y da al usuario un punto de decisión que también le sirve para parar.
  3. La URL se actualiza con la posición. Con history.replaceState en cada tanda, sin añadir entradas al historial. Así el enlace es compartible y la recarga aterriza cerca.
  4. Existe paginación real por debajo. Enlaces rel="next" y rel="prev" en el marcado, aunque el usuario nunca los use: los rastreadores sí, y los usuarios de teclado también.
  5. Se anuncia el total. “120 de 1.240” en un sitio visible y en la región en vivo.
function alCargarTanda(pagina, cursor) {
  const url = new URL(location.href);
  url.searchParams.set('pagina', String(pagina));
  if (cursor) url.searchParams.set('desde', cursor);
  // replaceState, no pushState: no queremos que el boton de atras
  // recorra tanda por tanda.
  history.replaceState({ pagina, cursor }, '', url);
}

El resultado no es más código del que ya tenías: es el mismo código con la URL sincronizada y un botón a partir de la cuarta tanda. Y arregla cuatro de los cinco problemas de la lista inicial.

⚔️ Repara una lista infinita
  1. Verifica en el panel de aplicación si tu página es elegible para la caché de retroceso. Si no lo es, arregla la primera causa que te indique.
  2. Implementa la carga por centinela y prueba el caso de fallo de red: comprueba que la lista se recupera al reintentar.
  3. Mide el tiempo de respuesta a una pulsación de tecla en un campo de la página después de una tanda y después de veinte. Compara.
  4. Añade la región en vivo y verifica con un lector de pantalla que anuncia cada tanda una sola vez.
  5. Convierte tu lista al patrón híbrido y comprueba que recargar la página con la URL actualizada aterriza donde estabas.