ChatGPT tiene un problema que pocos mencionan: las conversaciones largas destruyen el rendimiento del navegador. Una conversación de 600+ mensajes genera más 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. Desarrollé ChatGPT Booster, una extensión Chrome que resuelve esto usando virtual scrolling implementado con CSS puro y MutationObserver. Esta es la historia técnica de cómo lo construí.
ChatGPT renderiza absolutamente todos los mensajes de una conversación en el DOM. No hay paginación, no hay virtualización, no hay lazy rendering. Si tienes una conversación con 608 mensajes (un caso real que usé como benchmark), el navegador mantiene 46,640 nodos DOM activos simultáneamente.
Para ponerlo en perspectiva: Google recomienda que una página tenga menos de 1,500 nodos DOM totales. ChatGPT supera ese número 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 recálculos de layout sobre miles de nodos. Abrir DevTools en ese punto muestra long tasks de 200-400ms en cada interacción.
Mi primer intento fue el obvio: manipular el DOM directamente con JavaScript. Agregar display: none vía element.style o toggling de clases CSS a los mensajes fuera del viewport. Funcionó durante 3 segundos, hasta que React reconcilió el DOM y deshizo todos mis cambios.
ChatGPT está 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 conversación), reconcilia el DOM virtual contra el DOM real y elimina cualquier modificación externa que no coincida con su estado interno.
La solución fue inyectar un tag <style> en el documento. CSS aplicado vía una hoja de estilos inyectada sobrevive a la reconciliación de React, porque React no gestiona hojas de estilo externas. No importa cuántas 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 página 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.
La arquitectura tiene tres capas: ocultamiento inicial vía CSS estático, revelación selectiva vía CSS dinámico, y expansión automática vía IntersectionObserver.
El archivo content.css se carga en document_start y oculta todos los turns de la conversación. Esto es crítico: si esperamos a que la página cargue, el usuario verá el flash de 46,000 nodos renderizándose antes de que podamos ocultarlos.
El content script (content.js) cuenta los turns presentes en el DOM y genera un tag <style> dinámico que muestra solo los últimos 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 conversación. Esto me permite apuntar a turnos específicos sin depender de la estructura del DOM, que puede cambiar entre versiones.
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 más. La función loadOlderMessages recalcula los índices, actualiza el <style> dinámico y reposiciona el sentinel.
Cargar mensajes más antiguos arriba del viewport desplaza el contenido visible hacia abajo. Para evitar ese salto, guardo la posición relativa antes de expandir el rango y la restauro después:
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 cálculo se ejecute después de que el navegador haya procesado los cambios de layout causados por mostrar los nuevos mensajes.
ChatGPT es una SPA (Single Page Application). Cuando el usuario hace click en otra conversación en el sidebar, React simplemente actualiza el estado y re-renderiza. El problema es que la memoria y el estado acumulado de la conversación anterior no se limpia completamente, y la extensión pierde sincronización con los índices de los turns.
La solución 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 parámetro 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 navegación como client-side routing. En su lugar, forzamos una navegación completa con window.location.href, que limpia todo el estado de JavaScript y permite que la extensión se inicialice limpiamente con la nueva conversación.
Para mostrar al usuario cuánta memoria se ahorra, calculo el tamaño 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 .length no cuenta caracteres, sino unidades de código UTF-16, y cada unidad ocupa 2 bytes: los caracteres fuera del BMP (un emoji, por ejemplo) cuentan como dos unidades, así que la multiplicación sigue dando el tamaño exacto de la cadena en su representación UTF-16. Conviene leerlo como una cota superior, eso sí: V8 almacena internamente las cadenas puramente ASCII con 1 byte por carácter, así que el consumo real de un HTML mayoritariamente ASCII suele ser menor. En mi conversación 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 ahí), pero el navegador los excluye del árbol de renderizado. No se calculan estilos, no se computan layouts, no se generan capas de pintura. El ahorro principal está en evitar ese trabajo, no en la memoria cruda del DOM.
Cuando el usuario envía un mensaje o recibe una respuesta, ChatGPT inserta nuevos turns en el DOM. La extensión necesita detectarlos y expandir automáticamente 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 múltiples 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.
La extensión sigue la arquitectura Manifest V3 de Chrome con tres componentes:
background.js): Gestiona la verificación de licencias y el almacenamiento de configuración. En MV3, el background script es un Service Worker que se termina después de 30 segundos de inactividad, así que todo el estado persiste en chrome.storage.content.js + content.css): Se inyecta en las páginas de ChatGPT. Ejecuta toda la lógica de virtualización. Se comunica con el Service Worker vía chrome.runtime.sendMessage.popup.html + popup.js): Panel de control donde el usuario puede ver estadísticas (nodos DOM, memoria ahorrada) y ajustar configuración (cuántos mensajes mostrar por defecto).La comunicación 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 crítico. Sin él, Chrome cierra el canal de mensajes inmediatamente y sendResponse falla silenciosamente, ya que la operación de chrome.storage es asíncrona.
Los números antes y después en una conversación de 608 mensajes:
| Métrica | Sin extensión | 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 reducción 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 instantáneamente, y las conversaciones largas dejan de ser un problema.
ChatGPT Booster está 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 más allá de esta extensión. Cualquier herramienta que necesite modificar el comportamiento de una SPA en producción se enfrenta a los mismos desafíos, y las soluciones son transferibles.