← Volver al blog
·9 min de lectura

ChatGPT Booster: virtual scrolling para conversaciones largas en ChatGPT

Chrome ExtensionsPerformanceJavaScript

ChatGPT tiene un problema que pocos mencionan: las conversaciones largas destruyen el rendimiento del navegador. Una conversacion de 600+ mensajes genera mas de 46,000 nodos DOM, y el navegador empieza a sufrir. Scroll con lag, input que tarda en responder, y en algunos casos un tab que directamente se congela. Desarrolle ChatGPT Booster, una extension Chrome que resuelve esto usando virtual scrolling implementado con CSS puro y MutationObserver. Esta es la historia tecnica de como lo construi.

El problema: todo el DOM, todo el tiempo

ChatGPT renderiza absolutamente todos los mensajes de una conversacion en el DOM. No hay paginacion, no hay virtualizacion, no hay lazy rendering. Si tienes una conversacion con 608 mensajes (un caso real que use como benchmark), el navegador mantiene 46,640 nodos DOM activos simultaneamente.

Para ponerlo en perspectiva: Google recomienda que una pagina tenga menos de 1,500 nodos DOM totales. ChatGPT supera ese numero por un factor de 30x en conversaciones largas.

El efecto es acumulativo. Los primeros 100 mensajes se sienten fluidos. A los 300, el scroll empieza a tartamudear. A los 600, escribir en el input tiene un delay visible porque cada keystroke dispara recalculos de layout sobre miles de nodos. Abrir DevTools en ese punto muestra long tasks de 200-400ms en cada interaccion.

Por que un enfoque CSS-first

Mi primer intento fue el obvio: manipular el DOM directamente con JavaScript. Agregar display: none via element.style o toggling de clases CSS a los mensajes fuera del viewport. Funciono durante 3 segundos, hasta que React reconcilio el DOM y deshizo todos mis cambios.

ChatGPT esta construido en React. Cada vez que React re-renderiza (lo cual ocurre frecuentemente: al recibir tokens de streaming, al actualizar el estado interno, al cambiar de conversacion), reconcilia el DOM virtual contra el DOM real y elimina cualquier modificacion externa que no coincida con su estado interno.

La solucion fue inyectar un tag <style> en el documento. CSS aplicado via una hoja de estilos inyectada sobrevive a la reconciliacion de React, porque React no gestiona hojas de estilo externas. No importa cuantas veces React re-renderice: las reglas CSS persisten.

/* content.css - inyectado en document_start */
[data-testid^="conversation-turn-"] {
  display: none !important;
}

Esta regla se inyecta antes de que cualquier script de la pagina se ejecute, gracias a run_at: "document_start" en el manifest. El resultado es que los mensajes nunca se renderizan visualmente, aunque existan en el DOM. El navegador no gasta recursos en layout ni paint de elementos ocultos con display: none.

Implementacion del virtual scrolling

La arquitectura tiene tres capas: ocultamiento inicial via CSS estatico, revelacion selectiva via CSS dinamico, y expansion automatica via IntersectionObserver.

Capa 1: Ocultar todo al inicio

El archivo content.css se carga en document_start y oculta todos los turns de la conversacion. Esto es critico: si esperamos a que la pagina cargue, el usuario vera el flash de 46,000 nodos renderizandose antes de que podamos ocultarlos.

Capa 2: Mostrar los ultimos N mensajes

El content script (content.js) cuenta los turns presentes en el DOM y genera un tag <style> dinamico que muestra solo los ultimos 15 mensajes:

function updateVisibleRange(startIndex, endIndex) {
  let styleEl = document.getElementById('cgb-virtual-style');
  if (!styleEl) {
    styleEl = document.createElement('style');
    styleEl.id = 'cgb-virtual-style';
    document.head.appendChild(styleEl);
  }

  // Generar selectores :nth-child para el rango visible
  const selectors = [];
  for (let i = startIndex; i <= endIndex; i++) {
    selectors.push(
      `[data-testid="conversation-turn-${i}"]`
    );
  }

  styleEl.textContent = `
    ${selectors.join(',\n')} {
      display: block !important;
    }
  `;
}

Uso selectores [data-testid="conversation-turn-N"] porque ChatGPT asigna un data-testid incremental a cada turno de conversacion. Esto me permite apuntar a turnos especificos sin depender de la estructura del DOM, que puede cambiar entre versiones.

Capa 3: Cargar mas al hacer scroll hacia arriba

Para que el usuario pueda ver mensajes anteriores, inserto un elemento sentinel invisible al inicio del rango visible:

function setupScrollDetection() {
  const sentinel = document.createElement('div');
  sentinel.id = 'cgb-sentinel';
  sentinel.style.height = '1px';

  const firstVisible = document.querySelector(
    `[data-testid="conversation-turn-${visibleStart}"]`
  );
  firstVisible.parentNode.insertBefore(sentinel, firstVisible);

  const observer = new IntersectionObserver((entries) => {
    if (entries[0].isIntersecting) {
      loadOlderMessages();
    }
  }, { rootMargin: '200px' });

  observer.observe(sentinel);
}

Cuando el usuario hace scroll hacia arriba y el sentinel entra al viewport (con 200px de margen anticipado), se expande el rango visible cargando 15 mensajes mas. La funcion loadOlderMessages recalcula los indices, actualiza el <style> dinamico y reposiciona el sentinel.

Preservar la posicion del scroll

Cargar mensajes mas antiguos arriba del viewport desplaza el contenido visible hacia abajo. Para evitar ese salto, guardo la posicion relativa antes de expandir el rango y la restauro despues:

function loadOlderMessages() {
  const scrollContainer = getScrollContainer();
  const anchorEl = document.querySelector(
    `[data-testid="conversation-turn-${visibleStart}"]`
  );
  const anchorOffset = anchorEl.getBoundingClientRect().top;

  // Expandir el rango
  visibleStart = Math.max(1, visibleStart - 15);
  updateVisibleRange(visibleStart, visibleEnd);

  // Restaurar posicion relativa
  requestAnimationFrame(() => {
    const newOffset = anchorEl.getBoundingClientRect().top;
    scrollContainer.scrollTop += (newOffset - anchorOffset);
  });
}

El requestAnimationFrame asegura que el calculo se ejecute despues de que el navegador haya procesado los cambios de layout causados por mostrar los nuevos mensajes.

Force reload al cambiar de conversacion

ChatGPT es una SPA (Single Page Application). Cuando el usuario hace click en otra conversacion en el sidebar, React simplemente actualiza el estado y re-renderiza. El problema es que la memoria y el estado acumulado de la conversacion anterior no se limpia completamente, y la extension pierde sincronizacion con los indices de los turns.

La solucion fue interceptar los clicks del sidebar usando event capture:

document.addEventListener('click', (e) => {
  const link = e.target.closest('a[href^="/c/"]');
  if (link) {
    e.preventDefault();
    e.stopImmediatePropagation();
    window.location.href = link.href;
  }
}, true); // capture: true

El tercer parametro true activa la fase de captura, que se ejecuta antes de que React procese el evento en la fase de burbujeo. Al llamar preventDefault() y stopImmediatePropagation(), evitamos que React maneje la navegacion como client-side routing. En su lugar, forzamos una navegacion completa con window.location.href, que limpia todo el estado de JavaScript y permite que la extension se inicialice limpiamente con la nueva conversacion.

Calculo de memoria ahorrada

Para mostrar al usuario cuanta memoria se ahorra, calculo el tamano del HTML oculto:

function calculateMemorySaved() {
  const hiddenTurns = document.querySelectorAll(
    '[data-testid^="conversation-turn-"]'
  );
  let totalBytes = 0;

  hiddenTurns.forEach(turn => {
    const style = getComputedStyle(turn);
    if (style.display === 'none') {
      // innerHTML.length * 2 porque JavaScript usa UTF-16
      totalBytes += turn.innerHTML.length * 2;
    }
  });

  return totalBytes;
}

El multiplicador * 2 se debe a que JavaScript almacena strings internamente en UTF-16, donde cada caracter ocupa 2 bytes. En mi conversacion de benchmark (608 mensajes), los turnos ocultos suman aproximadamente 42 MB de HTML que el navegador ya no necesita procesar para layout y paint.

Es importante notar que display: none no elimina los nodos del DOM (React los necesita ahi), pero el navegador los excluye del arbol de renderizado. No se calculan estilos, no se computan layouts, no se generan capas de pintura. El ahorro principal esta en evitar ese trabajo, no en la memoria cruda del DOM.

MutationObserver para mensajes nuevos

Cuando el usuario envia un mensaje o recibe una respuesta, ChatGPT inserta nuevos turns en el DOM. La extension necesita detectarlos y expandir automaticamente el rango visible:

const chatObserver = new MutationObserver(
  debounce((mutations) => {
    for (const mutation of mutations) {
      for (const node of mutation.addedNodes) {
        if (node.nodeType === Node.ELEMENT_NODE &&
            node.matches?.('[data-testid^="conversation-turn-"]')) {
          visibleEnd++;
          updateVisibleRange(visibleStart, visibleEnd);
        }
      }
    }
  }, 800)
);

const chatContainer = document.querySelector('[role="presentation"]');
if (chatContainer) {
  chatObserver.observe(chatContainer, {
    childList: true,
    subtree: true,
  });
}

El debounce de 800ms es deliberado. Durante el streaming de una respuesta de ChatGPT, el contenido de un turn se actualiza multiples veces por segundo con cada token nuevo. Sin debounce, el MutationObserver dispara cientos de callbacks que compiten con el rendering del streaming. Los 800ms dan tiempo a que el streaming se estabilice antes de recalcular el rango.

Arquitectura Manifest V3

La extension sigue la arquitectura Manifest V3 de Chrome con tres componentes:

  • Service Worker (background.js): Gestiona la verificacion de licencias y el almacenamiento de configuracion. En MV3, el background script es un Service Worker que se termina despues de 30 segundos de inactividad, asi que todo el estado persiste en chrome.storage.
  • Content Script (content.js + content.css): Se inyecta en las paginas de ChatGPT. Ejecuta toda la logica de virtualizacion. Se comunica con el Service Worker via chrome.runtime.sendMessage.
  • Popup (popup.html + popup.js): Panel de control donde el usuario puede ver estadisticas (nodos DOM, memoria ahorrada) y ajustar configuracion (cuantos mensajes mostrar por defecto).

La comunicacion entre componentes usa el sistema de mensajes de Chrome:

// Content script -> Service Worker
chrome.runtime.sendMessage(
  { type: 'GET_CONFIG' },
  (response) => {
    initialVisibleCount = response.visibleCount || 15;
    initialize();
  }
);

// Service Worker -> Content script
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg.type === 'GET_CONFIG') {
    chrome.storage.sync.get(['visibleCount'], (data) => {
      sendResponse(data);
    });
    return true; // Mantener el canal abierto para respuesta asincrona
  }
});

El return true en el listener del Service Worker es critico. Sin el, Chrome cierra el canal de mensajes inmediatamente y sendResponse falla silenciosamente, ya que la operacion de chrome.storage es asincrona.

Resultados

Los numeros antes y despues en una conversacion de 608 mensajes:

| Metrica | Sin extension | Con ChatGPT Booster | |---------|---------------|---------------------| | Nodos DOM | 46,640 | ~3,000 | | Tiempo de carga | 8-12s (con freeze) | < 1s | | Memoria DOM estimada | ~45 MB | ~3 MB | | Input lag | 200-400ms | < 50ms |

La reduccion de 46,640 a ~3,000 nodos DOM es un factor de 15x. El navegador pasa de luchar con decenas de miles de nodos a trabajar con una cantidad manejable. El scroll es fluido, el input responde instantaneamente, y las conversaciones largas dejan de ser un problema.

ChatGPT Booster esta publicado en el Chrome Web Store. El enfoque CSS-first para sobrevivir a React, el uso de document_start para evitar flashes, y la captura de eventos para forzar navegaciones limpias son patrones que aplican mas alla de esta extension. Cualquier herramienta que necesite modificar el comportamiento de una SPA en produccion se enfrenta a los mismos desafios, y las soluciones son transferibles.