Paginación Infinita con Intersection Observer API: Guía de Rendimiento en el DOM

JAVASCRIPT 6 de abril de 2026 159 lecturas
Implementa paginación infinita nativa de alto rendimiento eliminando eventos de scroll costosos mediante el patrón centinela y la API Intersection Observer.

Desafíos de Rendimiento en el Scroll Tradicional

Durante años, la implementación de paginación infinita o scroll infinito en aplicaciones web representó uno de los mayores cuellos de botella en términos de rendimiento en el cliente. La aproximación clásica se basaba en registrar un escuchador de eventos directamente sobre el objeto global o un contenedor escroleable mediante window.addEventListener('scroll', listener). Esta estrategia presenta deficiencias arquitectónicas severas: el evento de scroll se dispara decenas o cientos de veces por segundo en el hilo principal de ejecución (Main Thread) de JavaScript. Cada invocación ejecuta cálculos geométricos mediante métodos como getBoundingClientRect(), lo que fuerza al motor del navegador a realizar un recálculo constante del diseño (reflow) y operaciones repetitivas de pintado (repaint).

Este fenómeno de degradación, conocido como layout thrashing, no solo satura el bucle de eventos (Event Loop), sino que destruye la tasa de cuadros por segundo (FPS). El resultado es una experiencia de usuario deficiente caracterizada por tirones visuales (jank), respuestas lentas ante la interacción e incrementos significativos en el consumo de batería en dispositivos móviles.

La Revolución de IntersectionObserver API

La introducción de la API IntersectionObserver transformó la forma en que los navegadores gestionan la visibilidad de elementos en el árbol DOM. En lugar de ejecutar código sincrónico continuo en el hilo principal, IntersectionObserver delega los cálculos de intersección al propio motor de renderizado del navegador, aprovechando subprocesos optimizados como el compositor thread. El navegador calcula geométricamente la superposición entre el elemento objetivo y su contenedor raíz (o el viewport) y únicamente notifica a nuestra aplicación de manera asíncrona cuando se cruza un umbral de visibilidad prefijado.

Al desacoplar el monitoreo de espacio del hilo de ejecución de JavaScript, el consumo de CPU disminuye a niveles casi imperceptibles durante el desplazamiento. El hilo principal permanece completamente libre para procesar interacciones críticas, solicitudes de red o actualizaciones de estado, manteniendo una tasa constante de 60 cuadros por segundo sin importar la complejidad del documento.

El Patrón Centinela y la Importancia de rootMargin

Para construir un sistema de paginación infinita robusto, se utiliza el patrón de diseño denominado Elemento Centinela (Sentinel Node). Este patrón consiste en posicionar un nodo HTML liviano e invisible al final de la lista de elementos dinámicos. El observador vigila únicamente la presencia de este nodo centinela. Cuando el centinela entra en el área visible, el observador dispara una función de retorno que consulta la siguiente página de datos y la inserta antes del centinela, empujando a este último más abajo en el documento.

Aunque es común configurar la visibilidad mediante la propiedad threshold (el porcentaje del elemento que debe estar visible), utilizar un valor de 1.0 puede provocar retardos perceptibles para el usuario, ya que la solicitud de datos solo iniciará cuando el centinela sea completamente visible. Para una experiencia fluida e imperceptible, la mejor práctica consiste en utilizar la propiedad rootMargin. Al configurar, por ejemplo, rootMargin: '300px 0px', creamos un margen virtual alrededor del contenedor visible. Esto permite que la solicitud de red se dispare 300 píxeles antes de que el usuario llegue físicamente al final del contenido, garantizando que los nuevos elementos ya estén cargados y renderizados cuando el usuario continúe desplazándose.

Prevención de Condiciones de Carrera y Gestión de Memoria

Un error común en la implementación de scroll infinito es no controlar el estado asíncrono de las peticiones HTTP. Si el usuario realiza un desplazamiento rápido, la intersección puede dispararse múltiples veces consecutivas antes de que la primera solicitud a la API haya finalizado. Esto deriva en solicitudes duplicadas, sobrecarga en el servidor y renderizado inconsistente. Por ello, la arquitectura debe incorporar banderas de bloqueo (isLoading), control de fin de registros (hasMore) y soporte para AbortController, asegurando que si la vista se desmonta, las peticiones en curso se cancelen inmediatamente.

Finalmente, en sistemas enterprise con miles de registros, la carga indefinida de nodos HTML puede ocasionar un alto consumo de memoria RAM (DOM Bloating). En esos escenarios de gran escala, este patrón se complementa con técnicas de virtualización de listas (List Windowing), destruyendo o reemplazando los nodos que quedan muy alejados del viewport por espacios de relleno (spacers) equivalentes.

/**
 * InfiniteScrollManager - Manejo de paginación infinita nativa mediante Intersection Observer
 */
class InfiniteScrollManager {
  constructor({
    targetSelector,
    fetchDataCallback,
    renderDataCallback,
    rootMargin = '300px 0px',
    threshold = 0.1
  }) {
    this.target = document.querySelector(targetSelector);
    this.fetchDataCallback = fetchDataCallback;
    this.renderDataCallback = renderDataCallback;
    
    this.page = 1;
    this.isLoading = false;
    this.hasMore = true;
    this.abortController = null;

    this.options = {
      root: null,
      rootMargin,
      threshold
    };

    this.observer = null;
    this.init();
  }

  init() {
    if (!this.target) {
      console.warn(`[InfiniteScroll] Elemento centinela "${this.target}" no encontrado.`);
      return;
    }

    this.observer = new IntersectionObserver(
      (entries) => this.handleIntersection(entries),
      this.options
    );

    this.observer.observe(this.target);
  }

  async handleIntersection(entries) {
    const entry = entries[0];

    if (entry.isIntersecting && !this.isLoading && this.hasMore) {
      await this.loadNextBatch();
    }
  }

  async loadNextBatch() {
    this.isLoading = true;
    this.abortController = new AbortController();

    try {
      const response = await this.fetchDataCallback({
        page: this.page,
        signal: this.abortController.signal
      });

      if (!response || response.data.length === 0 || response.hasMore === false) {
        this.hasMore = false;
        this.destroy();
        return;
      }

      this.renderDataCallback(response.data);
      this.page += 1;
    } catch (error) {
      if (error.name !== 'AbortError') {
        console.error('[InfiniteScroll] Error al obtener datos:', error);
      }
    } finally {
      this.isLoading = false;
    }
  }

  destroy() {
    if (this.observer && this.target) {
      this.observer.unobserve(this.target);
      this.observer.disconnect();
    }
    if (this.abortController) {
      this.abortController.abort();
    }
  }
}

// Ejemplo de inicialización en la aplicación:
/*
const scrollManager = new InfiniteScrollManager({
  targetSelector: '#scroll-anchor',
  fetchDataCallback: async ({ page, signal }) => {
    const res = await fetch(`/api/projects?page=${page}&limit=10`, { signal });
    if (!res.ok) throw new Error('Error al conectar con la API');
    return await res.json();
  },
  renderDataCallback: (projects) => {
    const container = document.querySelector('#projects-grid');
    const fragment = document.createDocumentFragment();
    
    projects.forEach(project => {
      const card = document.createElement('article');
      card.className = 'project-card';
      card.textContent = project.title;
      fragment.appendChild(card);
    });
    
    container.appendChild(fragment);
  }
});
*/
¿Qué te pareció?
🔥 Brillante 0
💡 Me sirvió 0
🚀 A otro nivel 0

¿Te resultó útil este snippet? Explora más código y soluciones en AndresSY.dev.

Volver a Snippets