Cobertura de CSS: por qué siempre sale un número escandaloso
Por qué una hoja de estilos aparece con el ochenta por ciento sin usar aunque esté bien, qué es realmente el CSS crítico, y qué hacer con el resultado.
La primera vez que alguien ejecuta el panel de cobertura sobre una aplicación real, el número de CSS sin usar está entre el setenta y el noventa y cinco por ciento. La reacción natural es de alarma, y la conclusión que se suele sacar —que hay una cantidad enorme de CSS muerto— es casi siempre falsa. Ese número es alto en proyectos bien hechos y en proyectos mal hechos, y entender por qué es la única forma de extraer algo útil de él.
- Explicar las cinco razones por las que el CSS aparece mayoritariamente sin usar.
- Distinguir CSS no crítico de CSS muerto.
- Aplicar la técnica de acumular cobertura entre varias rutas y estados.
- Decidir entre extraer el crítico, dividir por ruta, o no hacer nada.
Las cinco razones del número alto
Uno: se mide una página, y el CSS es de todo el sitio. Una hoja de estilos que cubre veinte pantallas se carga entera en cada una, y en cada una se usa la vigésima parte. El número no dice que sobre: dice que está todo junto.
Dos: los estados no se alcanzan. Reglas de hover, de foco, de activo, de deshabilitado, de error, de carga, de selección. Todas están en la hoja y ninguna se aplica mientras nadie interactúe. En una auditoría de carga sin interacción, todos los estados de todos los componentes cuentan como no usados.
Tres: las consultas de medios no se cumplen. Todo lo que aplica a otros tamaños de ventana, a impresión, a modo oscuro si no lo tienes activo, a movimiento reducido, a colores forzados. Cuenta como no usado y es imprescindible.
Cuatro: el contenido condicional no está. Los estilos de un componente que solo aparece si hay datos, si el usuario tiene un permiso, o si se cumple una condición de negocio.
Cinco: los frameworks de utilidades y las librerías de componentes traen todo. Si usas una librería de componentes completa y empleas seis de ellos, el resto viene en la hoja.
De las cinco, solo la primera y la quinta apuntan a algo accionable, y ninguna apunta a borrar código.
La conclusión “hay que borrar el CSS no usado” es incorrecta en la inmensa mayoría de los casos, y la herramienta que la sugiere no tiene forma de saber que se equivoca. Antes de borrar una sola regla hay que comprobar los cinco puntos anteriores, y después buscar el selector en el código. Las herramientas de purga automática de CSS existen y tienen el mismo problema amplificado, con el agravante de que actúan solas: cualquier clase construida dinámicamente en tiempo de ejecución es invisible para ellas y desaparece.
Acumular entre rutas y estados
El número de una sola página es poco informativo. Lo que sí informa es la intersección de varias sesiones: lo que no se usa en ninguna ruta ni en ningún estado sí es sospechoso de verdad.
El panel no acumula entre sesiones, así que hay que hacerlo a mano. Este acumulador registra qué selectores han casado alguna vez, y se puede ir ejecutando a medida que se navega.
// Acumulador de selectores que han casado alguna vez, persistido entre rutas
(() => {
const CLAVE = '__cobertura_css_acumulada';
const cargar = () => new Set(JSON.parse(sessionStorage.getItem(CLAVE) || '[]'));
const guardar = (conjunto) => sessionStorage.setItem(CLAVE, JSON.stringify([...conjunto]));
const recorrerReglas = (lista, visita) => {
for (const r of lista) {
if (r.cssRules) { recorrerReglas(r.cssRules, visita); continue; }
if (r.selectorText) visita(r);
}
};
window.muestrear = () => {
const usados = cargar();
let nuevos = 0;
for (const hoja of document.styleSheets) {
let reglas; try { reglas = hoja.cssRules; } catch { continue; }
recorrerReglas(reglas, (r) => {
let casa = false;
try { casa = document.querySelector(r.selectorText) !== null; }
catch { casa = true; }
if (casa && !usados.has(r.selectorText)) { usados.add(r.selectorText); nuevos++; }
});
}
guardar(usados);
console.log('Muestra tomada en', location.pathname, '| selectores nuevos:', nuevos,
'| acumulados:', usados.size);
return usados.size;
};
window.informeCobertura = () => {
const usados = cargar();
const nuncaUsados = [];
let total = 0;
for (const hoja of document.styleSheets) {
let reglas; try { reglas = hoja.cssRules; } catch { continue; }
recorrerReglas(reglas, (r) => {
total++;
if (!usados.has(r.selectorText)) {
nuncaUsados.push({
hoja: (hoja.href || 'en linea').split('/').pop(),
selector: r.selectorText.slice(0, 70),
declaraciones: r.style.length
});
}
});
}
console.log('Reglas totales:', total, '| casaron alguna vez:', usados.size,
'| nunca:', nuncaUsados.length);
console.table(nuncaUsados.slice(0, 80));
console.warn('Sigue sin ser una lista de borrado: faltan estados y otros tamanos.');
return nuncaUsados;
};
window.reiniciarCobertura = () => { sessionStorage.removeItem(CLAVE); console.log('Acumulador vaciado.'); };
console.log('Usa muestrear() en cada ruta y estado. Luego informeCobertura().');
})();
El procedimiento con este acumulador es: recorrer todas las rutas de la aplicación tomando una muestra en cada una, y en las principales, tomar muestras adicionales con distintos estados —hover forzado con el inspector, formularios con errores, listas vacías— y con distintos anchos de ventana. Después, el informe.
Lo que quede tras ese recorrido es una lista mucho más creíble, aunque sigue sin ser una lista de borrado.
Qué hacer con el resultado
Hay tres decisiones posibles y la tercera es más frecuente de lo que parece.
Extraer el CSS crítico. Poner en el documento, en línea, los estilos necesarios para pintar lo que se ve al entrar, y cargar el resto sin bloquear el render. Elimina el bloqueo de la primera pintura, que es el problema real del CSS grande. No reduce el peso total, y esa es la clave: el problema del CSS no es que pese, es que bloquea.
Dividir por ruta. Que cada pantalla cargue sus estilos. Encaja de forma natural cuando los estilos están junto a los componentes, y es la solución estructural. Tiene una contrapartida: más ficheros, más peticiones, y posibles saltos de disposición si un estilo llega tarde.
No hacer nada. Si tu hoja comprimida son treinta kilobytes y no bloquea nada crítico, el noventa por ciento sin usar es irrelevante. Optimizarlo no cambiaría ninguna métrica y consumiría tiempo que rinde más en otro sitio. Esta decisión es correcta muchas más veces de las que se toma.
El criterio para elegir es medir, no estimar: mira en el perfil si el CSS está retrasando la primera pintura. Si no lo está, el número de cobertura no es un problema, por escandaloso que parezca.
El CSS crítico bien entendido
La técnica es potente y su implementación ingenua causa más daño del que evita, así que merece precisión.
Lo crítico se define por el área visible inicial, y esa área depende del tamaño de la ventana. El crítico de un móvil y el de un escritorio no son el mismo conjunto, y extraerlo con un solo tamaño produce contenido sin estilar en el otro.
El resto tiene que llegar antes de que el usuario interactúe. La carga no bloqueante del resto de la hoja debe ocurrir cuanto antes; si se retrasa demasiado, la primera interacción se encuentra con estilos incompletos.
Duplicar tiene coste. Los estilos críticos van en el documento y también en la hoja completa, así que se transfieren dos veces. Con un crítico pequeño es despreciable; con uno grande, contraproducente.
<!-- Patron de CSS critico con carga no bloqueante del resto -->
<style>
/* Solo lo necesario para pintar la primera pantalla, en linea */
:root { --fondo: #11111b; --texto: #cdd6f4; }
body { margin: 0; background: var(--fondo); color: var(--texto); font: 16px/1.5 system-ui; }
header { padding: 16px; }
.heroe { min-height: 60vh; display: grid; place-items: center; }
</style>
<!-- El resto llega sin bloquear el render -->
<link rel="stylesheet" href="/estilos-completos.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="/estilos-completos.css"></noscript>
El truco del medio de impresión es el patrón habitual: el navegador descarga la hoja con prioridad baja y sin bloquear el render porque cree que es para imprimir, y al cargarse se cambia el medio para que aplique. El elemento de respaldo cubre el caso sin scripts.
Hay una razón más profunda para preocuparse por el CSS que no se usa, y no tiene que ver con los kilobytes sino con el trabajo de emparejar reglas con elementos. Cuando el navegador calcula los estilos de un elemento, tiene que determinar qué reglas le aplican, y aunque los motores son extremadamente listos para eso —indexan por la parte derecha del selector, descartan por clase y por etiqueta antes de evaluar nada— el coste sigue creciendo con el número de reglas y, sobre todo, con la complejidad de los selectores. En una hoja con veinte mil reglas, cada recálculo de estilo de cada elemento paga ese índice. Y los recálculos no ocurren una vez: ocurren cada vez que cambia una clase, cada vez que se inserta un nodo, cada vez que se modifica una variable heredada. Ahí está el coste real y ahí está la diferencia entre dos hojas del mismo tamaño: una hoja con muchas reglas simples es barata, y una con pocas reglas muy complejas puede ser cara. Los selectores que más cuestan son los que obligan a recorrer el árbol hacia arriba o hacia los lados —descendientes largos, hermanos generales— y los que dependen de condiciones que solo se pueden evaluar mirando otros elementos. De ahí salen dos consejos que valen más que cualquier purga automática. El primero: si el recálculo de estilo aparece como un bloque grande en tu perfil, el problema no es el peso de la hoja sino su forma, y la solución es simplificar selectores y reducir la profundidad, no borrar reglas sin usar. El segundo, y el que más rendimiento da a largo plazo: el aislamiento estructural vale más que la limpieza. Estilos acotados a su componente, con selectores de un solo nivel, hacen que el trabajo de emparejar sea trivial y además hacen que el CSS no usado sea irrelevante, porque nunca compite por nada. Un sistema donde cada componente trae sus estilos y nadie escribe selectores que atraviesan el árbol tiene números de cobertura igual de escandalosos que cualquier otro y un coste de recálculo mucho menor. Esa es la métrica que había que mirar desde el principio.