El coste de diez mil nodos: qué paga el navegador por cada uno
Los cuatro costes que suma cada elemento del DOM, un experimento para medirlos en tu máquina, y el umbral a partir del cual hay que dejar de añadir nodos.
“Diez mil filas es mucho” es una afirmación que todo el mundo repite y casi nadie sabe justificar. Un array de diez mil objetos en JavaScript no es nada; diez mil elementos en el DOM cambian la categoría de la página. La diferencia está en que un nodo del DOM no es un dato: es un compromiso de renderizado que el navegador tiene que mantener en cinco sistemas a la vez, y cuatro de esos cinco escalan con el número de nodos aunque el usuario solo vea treinta.
- Enumerar los costes concretos que un elemento del DOM impone al navegador.
- Medir en tu propia máquina cómo escalan la construcción, el primer layout y una invalidación posterior.
- Reconocer los costes que no aparecen en el panel de rendimiento.
- Fijar un umbral defendible para tu aplicación a partir de medidas, no de folclore.
Qué cuesta exactamente un nodo
Cada elemento conectado al documento genera trabajo en cinco lugares.
El objeto del DOM. Un Element en un motor moderno es un objeto de C++ con su lista de atributos, sus referencias a padre, hijos y hermanos, su mapa de listeners si los tiene, y el envoltorio de JavaScript que lo expone. Son cientos de bytes por nodo antes de que haya renderizado nada.
El estilo calculado. Para cada elemento el motor resuelve el valor de cada propiedad. Los motores comparten agresivamente los objetos de estilo entre elementos que resuelven lo mismo —es la única razón de que una tabla de diez mil celdas idénticas sea viable—, pero el emparejamiento de selectores hay que hacerlo igual para cada uno. El coste escala con el número de elementos multiplicado por el número de reglas candidatas, que los motores acotan con filtros e índices por clase, etiqueta y atributo, pero no anulan.
El árbol de cajas y el layout. Cada elemento con caja genera un objeto de layout, que ocupa memoria y que hay que recorrer en cada layout. Aquí es donde el tipo de contenedor multiplica: un contenedor flexible con diez mil hijos flexibles necesita varias pasadas de medición sobre los diez mil.
El pintado. Cada caja produce órdenes de dibujo que hay que generar, ordenar y ejecutar. Si el contenido está fuera de pantalla el navegador puede saltárselo, pero solo si puede demostrar que no asoma, y sin containment a menudo no puede.
El árbol de accesibilidad. Se construye en paralelo al de cajas y también escala con el número de nodos. Es el coste que menos se menciona y el que más se nota en un lector de pantalla sobre una lista enorme: la navegación se vuelve pastosa por razones que no aparecen en ningún perfil de rendimiento del navegador.
Como referencia externa, Lighthouse considera excesivo un DOM a partir de unos ochocientos elementos y lo puntúa mal a partir de mil cuatrocientos. Esos umbrales no son una ley física: son percentiles del corpus de páginas que Lighthouse usa para calibrar. Sirven de aviso, no de límite.
El experimento
Este fragmento mide las tres cosas que importan y se ejecuta en cualquier página en blanco. Fíjate en que separa el coste de construir los nodos del coste de renderizarlos y del coste de invalidarlos después, que son tres cifras muy distintas y que la gente confunde.
function medirNodos(n) {
const cont = document.createElement('div');
cont.style.cssText = 'font:14px/1.5 system-ui;width:800px';
document.body.appendChild(cont);
// 1. Construir fuera del documento: sin cajas, sin estilo, sin layout.
const t0 = performance.now();
const frag = document.createDocumentFragment();
for (let i = 0; i < n; i++) {
const fila = document.createElement('div');
fila.className = 'fila';
const a = document.createElement('span');
a.className = 'col-id';
a.textContent = String(i);
const b = document.createElement('span');
b.className = 'col-nombre';
b.textContent = 'elemento numero ' + i;
fila.append(a, b);
frag.appendChild(fila);
}
const t1 = performance.now();
// 2. Conectar y forzar estilo + layout de todo.
cont.appendChild(frag);
cont.getBoundingClientRect();
const t2 = performance.now();
// 3. Invalidar: cuanto cuesta UN cambio cuando ya hay N nodos.
cont.style.fontSize = '14.001px';
cont.getBoundingClientRect();
const t3 = performance.now();
cont.remove();
return {
nodos: n * 3,
construir: +(t1 - t0).toFixed(1),
primerRender: +(t2 - t1).toFixed(1),
invalidacion: +(t3 - t2).toFixed(1),
};
}
console.table([500, 2000, 10000, 30000].map(medirNodos));
Lo que vas a observar, y es lo importante de todo el ejercicio:
La columna de construcción crece de forma casi lineal y es la más pequeña de las tres. Crear objetos es barato. Este es el motivo de que la gente subestime el problema: si mides con un console.time alrededor del bucle de creación, sales convencido de que diez mil filas cuestan poquísimo.
La columna de primer render crece más rápido que lineal en cuanto el contenedor tiene algo de complejidad. Cambia el div por un contenedor flexible o por una rejilla con pistas automáticas y repite: el factor de crecimiento cambia.
La columna de invalidación es la que arruina la interactividad. Ese número es lo que cuesta cualquier cambio posterior en la página: escribir en un campo de búsqueda, abrir un menú, marcar una casilla. Y aquí está la trampa que hace que las listas grandes sean tan traicioneras: el coste no lo paga la lista, lo paga todo lo demás. La lista se renderizó una vez y se quedó quieta; el que sufre es el formulario del otro lado de la pantalla, que ahora tarda cuarenta milisegundos en responder porque cada invalidación arrastra treinta mil nodos.
Repite el experimento con contain: strict y contain-intrinsic-size en cada fila y compara la tercera columna. Ese es exactamente el contenido del nivel anterior aplicado aquí, y suele bastar para un orden de magnitud de mejora en la invalidación sin tocar una línea de JavaScript.
Los costes que no aparecen en el perfil
Tres consumos que el panel de rendimiento no te va a enseñar y que deciden la experiencia real.
La memoria. Cada nodo, su estilo, su objeto de layout y su entrada en el árbol de accesibilidad ocupan. En un dispositivo con dos gigas de RAM y varias pestañas abiertas, una lista de cincuenta mil elementos es una de las formas más fiables de conseguir que el sistema descarte tu pestaña y el usuario vuelva a una página recargada. Para medirlo con rigor hay APIs específicas y sus condiciones, que son el tema del nivel de memoria.
Los listeners. Poner un onclick en cada fila de diez mil filas son diez mil closures vivas, diez mil entradas en la tabla de listeners y diez mil referencias que impiden que el recolector libere las filas cuando las quites del DOM si no las desregistras. La delegación de eventos —un solo listener en el contenedor que mira event.target— no es una preferencia estilística: es la diferencia entre una estructura de datos y diez mil.
// Un listener para toda la lista, sin importar cuantas filas haya.
lista.addEventListener('click', (evento) => {
const fila = evento.target.closest('.fila');
if (!fila || !lista.contains(fila)) return;
seleccionar(fila.dataset.id);
});
El hit testing. En cada movimiento del ratón y en cada toque el navegador tiene que determinar sobre qué elemento está el puntero. El coste depende de la estructura de capas y del número de cajas candidatas. Con listas enormes y muchas capas de composición, el desplazamiento con el dedo se vuelve perceptiblemente peor sin que ninguna función de JavaScript aparezca en el perfil.
El error conceptual del que sale casi todo problema de lista grande es tratar el DOM como si fuera un array de objetos que da la casualidad de que se ve. No lo es. Cada nodo que conectas es una obligación contractual que le impones al navegador: te comprometes a que resuelva su estilo en cada recálculo, a que le asigne una posición en cada layout, a que decida si hay que pintarlo en cada fotograma, a que lo tenga en cuenta en cada búsqueda de hit testing, a que lo exponga en el árbol de accesibilidad y a que lo mantenga en memoria hasta que lo quites. Da igual que el usuario no lo esté mirando: el compromiso se mantiene igual. Con esa lente, la pregunta “¿cuántos nodos puedo tener?” se reformula sola en la pregunta correcta, que es “¿cuántas veces por segundo estoy obligando al navegador a cumplir el compromiso, y sobre cuántos nodos?”. Una lista de treinta mil nodos en una página que no cambia nunca es viable, aunque tarde en cargar. Una lista de tres mil nodos en una página donde el usuario escribe en un buscador es intratable, porque cada pulsación de tecla invalida y cada invalidación recorre tres mil cajas. El corolario práctico es la regla que hay que grabarse: los datos viven en JavaScript, el DOM es una vista. Si tienes diez mil registros, tienes un array de diez mil registros y un DOM de treinta nodos. En cuanto se acepta esa separación, toda la virtualización deja de parecer un truco de rendimiento y se ve como lo que es: la implementación honesta de la relación entre un modelo y su vista, la misma que llevas aplicando toda la vida cuando no paginas una consulta que devuelve un millón de filas.
El umbral
No hay un número universal, pero sí hay un procedimiento para encontrar el tuyo, y da un resultado defendible en una reunión.
- Ejecuta el experimento con la estructura real de tus filas, no con
divvacíos. La complejidad por fila importa tanto como el número de filas. - Fíjate en la tercera columna, la de invalidación, no en las otras dos. Es la que degrada la interactividad.
- Encuentra el
nen el que la invalidación cruza los ocho milisegundos en tu máquina de desarrollo. - Divide ese
nentre cuatro o entre seis. Ese es el umbral en el móvil de gama media de tus usuarios, y el factor sale del throttling de CPU calibrado que se trata en el nivel de móvil. - Aplica un margen de seguridad. El DOM de la lista no es el único DOM de la página.
En la práctica, para filas con tres o cuatro elementos y un poco de CSS, el umbral suele caer entre las quinientas y las dos mil filas en móvil. Por encima de eso hay que dejar de discutir y empezar a virtualizar, y ese es el contenido de la virtualización por ventana. Por debajo, la combinación de containment, delegación de eventos y content-visibility suele resolver el problema sin la complejidad de una lista virtual, que no es poca.
- Adapta
medirNodospara que construya tus filas reales, con sus clases y su estructura. Ejecuta la tabla. - Repite con el contenedor como
display: flexy comodisplay: grid. Compara los factores de crecimiento. - Añade
contain: strictconcontain-intrinsic-sizea cada fila y mide de nuevo la columna de invalidación. - Cuenta los listeners de tu lista actual. Si hay uno por fila, conviértelos a delegación y mide la memoria antes y después.
- Escribe tu umbral en un documento, con las medidas que lo respaldan y la fecha. Es lo que vas a enseñar la próxima vez que alguien proponga renderizar todo.