El umbral de elementos y de dónde sale
Los dos modelos de coste que compiten, por qué el número mágico ronda el millar, las cinco variables que lo mueven un orden de magnitud, y cómo medir el tuyo en diez minutos.
«SVG hasta mil elementos, canvas por encima» es la regla que todo el mundo repite y casi nadie sabe de dónde sale. Sale de un modelo de coste concreto y comprobable, y el número depende de cinco variables que pueden moverlo entre doscientos y cincuenta mil. Entender el modelo sirve para algo más que decidir: sirve para saber cuál de las cinco variables puedes cambiar tú.
- Describir el modelo de coste de SVG y el de canvas por separado.
- Explicar por qué existe un cruce y qué lo determina.
- Enumerar las cinco variables que desplazan el umbral.
- Medir el umbral de tu caso concreto con un experimento reproducible.
Dos modelos de coste distintos
Un SVG y un lienzo no son dos formas de hacer lo mismo con distinto rendimiento: su coste crece con cosas distintas.
El coste de SVG crece con el número de nodos. Cada elemento es un nodo del DOM, y sobre cada nodo el navegador hace, en cada fotograma en que algo cambia: resolución de estilo (¿qué reglas CSS le aplican?), cálculo de geometría, construcción de la lista de pintado, y mantenimiento del árbol de accesibilidad. Ese trabajo es aproximadamente lineal en el número de nodos y no depende de cuántos píxeles ocupe cada uno. Diez mil círculos de dos píxeles cuestan aproximadamente lo mismo que diez mil círculos de cien.
El coste del lienzo crece con las llamadas de dibujo y con los píxeles pintados. No existe el concepto de elemento: hay una secuencia de operaciones sobre un mapa de bits. Diez mil fillRect pequeños son diez mil llamadas de función que rellenan poco; diez mil rectángulos que ocupan la pantalla entera son diez mil llamadas que rellenan muchísimo, y esa segunda cifra sí importa.
De ahí sale el cruce. Con pocos elementos, SVG gana por goleada, porque el trabajo por nodo es despreciable y a cambio te regala eventos, CSS, texto seleccionable y accesibilidad. Con muchos, el coste por nodo domina y el lienzo gana, porque no tiene coste por nodo en absoluto.
Y hay una asimetría fundamental: el coste de SVG lo paga el navegador y no lo controlas; el coste del lienzo lo pagas tú y lo controlas entero. Puedes decidir no redibujar lo que no ha cambiado, agrupar por color para evitar cambios de estado, o dibujar en un buffer y copiarlo. Con SVG, el trabajo por nodo lo hace el motor a su manera.
De dónde sale el número
El millar no es una constante de la especificación: es el punto donde, en hardware de gama media y para contenido que cambia, el trabajo por nodo empieza a comerse el presupuesto de un fotograma.
La cuenta aproximada: un fotograma a sesenta imágenes por segundo dispone de 16,7 milisegundos, de los cuales el trabajo de tu código, el recálculo de estilo, el layout, el pintado y la composición tienen que caber todos. Si el recálculo de estilo y el layout de un nodo SVG cuestan del orden de unos pocos microsegundos (que es el orden correcto en hardware actual, y varía mucho según la complejidad de tus selectores), unos pocos miles de nodos ya consumen el presupuesto entero antes de que hayas pintado nada.
Por eso el umbral que se cita suele ser:
- Por debajo de unos 1.000 nodos: SVG sin pensarlo.
- Entre 1.000 y 10.000: zona gris, depende de las variables de la sección siguiente.
- Por encima de 10.000: canvas para los datos, con la excepción de contenido completamente estático.
Esa última excepción es importante y se olvida. Un SVG estático de cincuenta mil nodos se dibuja una vez y se queda quieto: el coste se paga en la carga y luego el navegador no vuelve a tocarlo. Es lento de abrir y perfectamente utilizable después. El umbral bajo aplica al contenido que cambia.
Las cinco variables
Uno: si cambia y con qué frecuencia. Es la que más pesa. Un gráfico estático admite un orden de magnitud más de nodos que uno animado. Y dentro de lo que cambia, importa qué cambia: modificar un transform es más barato que modificar la geometría, porque puede resolverse en la fase de composición sin rehacer el layout.
Dos: la complejidad de cada nodo. Aquí está la confusión más frecuente de todo el tema, y es la del callout: el umbral cuenta nodos, no puntos. Una polilínea de cincuenta mil vértices es un nodo. Un gráfico de líneas con veinte series de mil puntos cada una son veinte nodos, y va perfectamente en SVG. Un gráfico de dispersión con veinte mil puntos son veinte mil nodos, y no.
Tres: los filtros, las máscaras y los recortes. Un filter por elemento es de lo más caro que hay en SVG: obliga a rasterizar el elemento en un buffer aparte, aplicar el pipeline de primitivas y componer. Cien elementos con sombra pueden costar más que diez mil sin ella. Lo mismo con las máscaras. Si aplicas un efecto, aplícalo a un grupo, no a cada hijo, como se explica en la región del filtro.
Cuatro: la complejidad de tus selectores CSS. El recálculo de estilo es proporcional al coste de decidir qué reglas aplican a cada nodo. Reglas con descendientes profundos, con :nth-child o con :has() sobre miles de nodos cambian el orden de magnitud del recálculo. Un SVG generado que ponga atributos de presentación en lugar de clases evita ese trabajo por completo.
Cinco: el hardware del lector. El umbral en un portátil de desarrollo y en un móvil de gama media difieren por un factor que ronda el cinco o el diez. Si tu producto se usa en móviles, mide en un móvil.
Medirlo en tu caso
Diez minutos de medición valen más que cualquier regla general. El experimento mínimo:
function generarSvg(n) {
const NS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(NS, 'svg');
svg.setAttribute('viewBox', '0 0 800 600');
svg.setAttribute('width', '800');
svg.setAttribute('height', '600');
const nodos = [];
for (let i = 0; i < n; i++) {
const c = document.createElementNS(NS, 'circle');
c.setAttribute('r', 2);
c.setAttribute('fill', '#89b4fa');
svg.appendChild(c);
nodos.push(c);
}
document.body.appendChild(svg);
return nodos;
}
function medir(nodos, fotogramas = 60) {
return new Promise(resolve => {
const tiempos = [];
let previo = performance.now();
let k = 0;
function paso(t) {
tiempos.push(t - previo);
previo = t;
// Mover todo: fuerza recalculo de estilo y layout de cada nodo
for (const c of nodos) {
c.setAttribute('cx', Math.random() * 800);
c.setAttribute('cy', Math.random() * 600);
}
if (++k < fotogramas) requestAnimationFrame(paso);
else {
tiempos.sort((a, b) => a - b);
resolve(tiempos[Math.floor(tiempos.length * 0.95)]); // p95
}
}
requestAnimationFrame(paso);
});
}
Y la misma medición contra un lienzo:
function medirLienzo(n, fotogramas = 60) {
const lienzo = document.createElement('canvas');
const dpr = devicePixelRatio;
lienzo.width = 800 * dpr; lienzo.height = 600 * dpr;
lienzo.style.cssText = 'width:800px;height:600px';
document.body.appendChild(lienzo);
const ctx = lienzo.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return new Promise(resolve => {
const tiempos = [];
let previo = performance.now(), k = 0;
function paso(t) {
tiempos.push(t - previo);
previo = t;
ctx.clearRect(0, 0, 800, 600);
ctx.fillStyle = '#89b4fa';
for (let i = 0; i < n; i++) {
ctx.beginPath();
ctx.arc(Math.random() * 800, Math.random() * 600, 2, 0, Math.PI * 2);
ctx.fill();
}
if (++k < fotogramas) requestAnimationFrame(paso);
else { tiempos.sort((a, b) => a - b); resolve(tiempos[Math.floor(tiempos.length * 0.95)]); }
}
requestAnimationFrame(paso);
});
}
Ejecuta las dos con 500, 1.000, 2.000, 5.000, 10.000 y 20.000, y quédate con el n donde el percentil 95 del intervalo entre fotogramas supera los 16,7 milisegundos. Ese es tu umbral, con tu contenido y en tu dispositivo.
Dos avisos sobre el experimento. Usa el percentil 95 y no la media: un gráfico que va a 60 imágenes por segundo con un tirón cada segundo se percibe peor que uno estable a 40. Y mide en el móvil más malo que soportes, no en tu máquina.
Una observación que suele salir de esta medición y sorprende: el lienzo también tiene un techo, y no está tan lejos. Cien mil arc con su beginPath y su fill cuestan bastante, porque cada uno es una llamada al rasterizador. Cuando llegues ahí, las salidas son dibujar puntos con fillRect en lugar de arc, agrupar en un solo Path2D, o subir a WebGL.
La pregunta «cuántos elementos aguanta SVG» tiene una trampa: el número de elementos no lo imponen los datos, lo impone cómo escribes el marcado. Y hay margen de maniobra de un orden de magnitud.
El caso obvio es el gráfico de líneas: mil puntos son un path con mil comandos, es decir, un nodo. Nadie dibuja mil line separados. Pero la misma idea se aplica al gráfico de dispersión, y ahí casi nadie la usa.
Truco uno: subrutas de longitud cero con extremo redondeado. La especificación de SVG establece que una subruta de longitud cero no se dibuja salvo que stroke-linecap valga round o square, en cuyo caso se pinta el extremo. Es decir, un path con un M por punto y un comando de longitud nula dibuja un punto por cada uno, todos en un solo elemento:
const d = puntos.map(p => `M${p.x} ${p.y}h0`).join('');
ruta.setAttribute('d', d);
ruta.setAttribute('stroke', '#89b4fa');
ruta.setAttribute('stroke-width', 5); // el diametro del punto
ruta.setAttribute('stroke-linecap', 'round');
ruta.setAttribute('fill', 'none');Veinte mil puntos, un nodo. El recálculo de estilo pasa de veinte mil evaluaciones a una.
Truco dos: un path con una subruta cerrada por marca. Si necesitas formas de verdad (círculos con radio variable, cuadrados, triángulos), puedes emitir una subruta por marca dentro del mismo d. Sigue siendo un nodo. Es exactamente lo que hacen los generadores de símbolos de las bibliotecas cuando se les pide una capa entera.
Truco tres: agrupar por color. Si tus marcas tienen cinco colores, cinco path, uno por color. Cinco nodos para veinte mil marcas.
Lo que pierdes con los tres, y hay que decirlo claro: la individualidad. Ya no hay un elemento por dato, así que se acabaron el :hover por CSS, los eventos por elemento, el title por marca y el foco por teclado. Es decir, has renunciado exactamente a lo que hacía atractivo el SVG, y a cambio has ganado el rendimiento que te ofrecía el lienzo.
Y esa observación es la que de verdad importa: si tienes que recurrir a estos trucos, el umbral te está diciendo que uses un lienzo. Un SVG que ha renunciado a la interacción por elemento y a la semántica es un lienzo escrito en XML, más lento y más pesado. Los trucos son la respuesta correcta en un caso concreto: cuando necesitas exportación vectorial de una capa densa (un informe en PDF con un gráfico de dispersión de veinte mil puntos que hay que poder imprimir a cualquier tamaño) y el rendimiento en pantalla te da igual porque no cambia. Ahí valen oro. Para un gráfico interactivo en pantalla, son un desvío que acaba en el mismo sitio.
Ejecuta las dos mediciones de este artículo en tu portátil y en tu móvil, y anota los cuatro umbrales. Después repite la de SVG añadiendo una sombra por elemento y con una hoja de estilos que tenga una regla con :nth-child, y mira cuánto se desploma. Los seis números cabrán en un post-it y valdrán para todas las decisiones de este tipo que tomes en el próximo año.