No medir, en vez de medir mejor: las alternativas reales
Las mediciones de JavaScript que el CSS moderno ya resuelve, cómo cachear una medida y dejar que un observador la invalide, y los casos donde medir es inevitable.
Buena parte del código que mide el DOM se escribió cuando el CSS no sabía hacer lo que ahora hace. Sigue ahí porque funciona y nadie lo revisa, y cada línea de ese código es un layout forzado en un manejador de eventos. Antes de aplicar el patrón de dos fases conviene hacer un inventario: cuántas de tus mediciones existen por una carencia real y cuántas por inercia histórica. En una aplicación de cinco años la proporción suele sorprender.
- Sustituir las mediciones de JavaScript más frecuentes por la construcción de CSS equivalente.
- Cachear una medida e invalidarla correctamente con un observador en vez de releerla.
- Aplicar FLIP agrupado para animar cambios de layout con dos lecturas en total.
- Decidir con criterio cuándo un layout forzado no merece ninguna acción.
Las mediciones que el CSS ya sabe hacer
Este es el inventario, ordenado por frecuencia con la que aparece en código real.
Igualar alturas de tarjetas. El clásico absoluto: medir la más alta y aplicar esa altura a todas. Es innecesario desde que existe el layout en rejilla, donde los elementos de una fila comparten altura por defecto porque align-items vale stretch.
.tarjetas {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
}
Decidir cuántas columnas caben. Medir el contenedor y calcular. Lo hace auto-fill con minmax, como arriba, sin una sola lectura. Y si lo que necesitas es cambiar de disposición y no solo de número de columnas, la container query lo hace en función del contenedor real, no del viewport.
.panel { container-type: inline-size; }
@container (width < 480px) {
.panel .contenido { grid-template-columns: 1fr; }
}
Truncar texto a N líneas. Medir el ancho del texto con getComputedTextLength o con un elemento fantasma es de lo más caro que se puede hacer. Para una línea basta text-overflow: ellipsis; para varias, el bloque de tres declaraciones que soportan todos los motores:
.resumen {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}
Reservar el hueco de una imagen o un vídeo. Medir y aplicar un padding-top porcentual era el truco de 2015. Hoy es una propiedad:
img, video { aspect-ratio: 16 / 9; width: 100%; height: auto; }
Pegar una cabecera al hacer scroll. El manejador de scroll que lee getBoundingClientRect() en cada evento es sustituible por position: sticky. Y si además necesitas saber cuándo se ha quedado pegada para cambiarle la sombra, la solución sin lecturas es un centinela y un IntersectionObserver:
// Un div de 1px justo antes de la cabecera. Cuando deja de ser visible,
// la cabecera esta pegada. Cero lecturas de geometria.
const centinela = document.querySelector('.centinela');
const cabecera = document.querySelector('.cabecera');
new IntersectionObserver(
([entrada]) => cabecera.classList.toggle('pegada', !entrada.isIntersecting),
{ threshold: 0 }
).observe(centinela);
Colocar un popover o un tooltip respecto a su disparador. El caso que más código de medición genera. El anchor positioning lo resuelve en CSS, y desde que Firefox 147 lo implementó en enero de 2026 está disponible en los cuatro motores. El reposicionamiento automático con @position-try es más reciente y necesita Safari 18.4 o superior, así que va detrás de una detección:
.disparador { anchor-name: --disparador; }
.popover {
position: absolute;
position-anchor: --disparador;
top: anchor(bottom);
left: anchor(left);
margin-top: 8px;
}
@supports (position-try-fallbacks: flip-block) {
.popover { position-try-fallbacks: flip-block; }
}
Cada una de estas sustituciones elimina no solo el coste sino una clase entera de bugs: la desincronización entre el momento de la medida y el momento del uso. El CSS recalcula cuando cambia algo; tu medida cacheada, no.
Cachear la medida y dejar que un observador la invalide
Cuando la medición es genuinamente necesaria, la siguiente pregunta es con qué frecuencia cambia el valor. Casi siempre la respuesta es “casi nunca”, y sin embargo el código lo relee en cada evento.
El patrón correcto es guardar el valor y suscribirse a lo que puede invalidarlo:
class MedidorDeFila {
constructor(contenedor) {
this.contenedor = contenedor;
this.alto = 0;
this.ancho = 0;
// El callback de ResizeObserver se ejecuta DESPUES del layout:
// leer contentRect no fuerza nada.
this.ro = new ResizeObserver(([entrada]) => {
this.ancho = entrada.contentRect.width;
this.alto = entrada.borderBoxSize?.[0]?.blockSize ?? entrada.contentRect.height;
this.contenedor.dispatchEvent(new CustomEvent('medidas'));
});
this.ro.observe(contenedor);
}
destruir() {
this.ro.disconnect();
}
}
Dos detalles que importan. contentRect excluye el borde y el padding; borderBoxSize los incluye, y es lo que normalmente quieres si vas a comparar con offsetHeight. Y disconnect() no es opcional: un ResizeObserver mantiene una referencia fuerte a los elementos observados, así que un observador olvidado es una fuga de memoria con un subárbol entero colgando.
Para geometría relativa al viewport —posición, visibilidad, intersección— el observador correcto es IntersectionObserver, cuyas entradas traen boundingClientRect, intersectionRect y rootBounds ya calculados. Un carrusel que decide qué diapositiva está activa leyendo posiciones en cada evento de scroll hace decenas de layouts por segundo; el mismo carrusel con un observador por diapositiva hace cero.
Y para pasar el valor medido al CSS, una variable en el contenedor gana a escribir estilos en línea en N hijos: una escritura invalida un subárbol, N escrituras invalidan N veces y engordan el DOM.
contenedor.style.setProperty('--alto-fila', alto + 'px');
FLIP: medir dos veces para animar N elementos
Hay un caso en el que medir es estructuralmente inevitable: animar un cambio de layout. Si reordenas una lista, el navegador salta de la posición vieja a la nueva sin transición, porque no existe forma de interpolar entre dos resultados de layout. La técnica FLIP —First, Last, Invert, Play— resuelve esto midiendo antes y después y animando la diferencia con transform, que es gratis porque vive en la etapa de composición.
Lo importante para esta lección es cómo se agrupa. La versión ingenua mide, muta y anima elemento por elemento: dos layouts por elemento. La versión correcta hace dos layouts en total, sea cual sea el número de elementos.
function flipAgrupado(elementos, mutar, opciones = {}) {
const { duracion = 300, curva = 'cubic-bezier(0.2, 0, 0, 1)' } = opciones;
// FASE 1: leer todas las posiciones iniciales. Un layout.
const primeras = elementos.map((el) => el.getBoundingClientRect());
// FASE 2: mutar. Ninguna lectura aqui dentro.
mutar();
// FASE 3: leer todas las posiciones finales. Un layout.
const ultimas = elementos.map((el) => el.getBoundingClientRect());
// FASE 4: animar. transform y opacity no tocan el layout.
elementos.forEach((el, i) => {
const a = primeras[i];
const b = ultimas[i];
const dx = a.left - b.left;
const dy = a.top - b.top;
if (dx === 0 && dy === 0) return;
el.animate(
[
{ transform: 'translate(' + dx + 'px,' + dy + 'px)' },
{ transform: 'none' },
],
{ duration: duracion, easing: curva }
);
});
}
Uso:
const filas = Array.from(lista.children);
flipAgrupado(filas, () => {
filas
.slice()
.sort((a, b) => a.dataset.precio - b.dataset.precio)
.forEach((f) => lista.appendChild(f));
});
Nótese que la fase 4 no fuerza nada: element.animate() no lee geometría, y transform no invalida el layout. Toda la disciplina está en no colar una lectura dentro de mutar().
Para el mismo efecto sin escribir nada de esto, las View Transitions de mismo documento —Baseline desde 2025— hacen exactamente el trabajo de FLIP dentro del navegador, con capturas del árbol antes y después. Cuando encajan, encajan mejor; cuando necesitas control fino sobre qué se anima y con qué curva, FLIP sigue siendo la herramienta.
Un layout forzado es caro cuando se repite o cuando ocurre en el camino crítico. Fuera de esas dos condiciones, es ruido. Una lectura de getBoundingClientRect que sucede una vez al montar un componente, sobre un árbol de doscientos nodos, cuesta del orden de una décima de milisegundo: nunca vas a notarla, jamás aparecerá en un perfil, y reescribir el componente para evitarla es tiempo tirado que además introduce riesgo de regresión. El criterio para decidir tiene tres preguntas, en este orden. ¿Se repite? Si está en un bucle, en un manejador de scroll, de mousemove, de resize o de input, sí, y hay que arreglarlo, punto. ¿Está en el camino de una interacción? Si ocurre entre el clic del usuario y el fotograma que responde, cuenta directamente para el INP y hay que arreglarlo aunque ocurra una sola vez. ¿Sobre cuánto árbol? Un layout forzado sobre un subárbol de veinte nodos aislado con contain cuesta microsegundos; el mismo sobre un documento de ocho mil nodos cuesta decenas de milisegundos, y la diferencia no está en tu código sino en dónde lo montaste. Si las tres respuestas son benignas, deja el código como está y anota el sitio por si algún día el componente se mueve a una lista. La razón por la que insisto es que he visto el patrón contrario arruinar sprints enteros: alguien activa el detector, salen cuarenta avisos, y el equipo dedica dos semanas a eliminarlos todos cuando treinta y siete eran inocuos y los tres que importaban estaban en el mismo manejador de scroll. El objetivo no es cero layouts forzados. El objetivo es cero layouts forzados repetidos, y ese suele ser un día de trabajo.
El caso de las mutaciones masivas
Queda un patrón que aparece cuando hay que insertar o modificar miles de nodos y que la gente resuelve a menudo con una receta discutible: poner el contenedor en display: none, hacer las mutaciones y volver a mostrarlo. La idea es que un contenedor sin caja no genera trabajo de layout al mutarlo.
Funciona, pero tiene dos costes que se olvidan. El primero es que ocultar y volver a mostrar fuerza la destrucción y la reconstrucción de todo el árbol de layout del subárbol, no un layout incremental; con pocos nodos sale perdiendo. El segundo es que el subárbol oculto sale del árbol de accesibilidad y pierde el estado de scroll y de foco.
La alternativa que casi siempre gana es construir fuera del documento y conectar una vez:
const fragmento = document.createDocumentFragment();
for (const dato of datos) {
const fila = plantilla.content.cloneNode(true);
fila.querySelector('.nombre').textContent = dato.nombre;
fragmento.appendChild(fila);
}
lista.appendChild(fragmento); // una sola invalidacion
Un DocumentFragment no está conectado al documento, así que construirlo no genera cajas ni invalida nada. Al insertarlo, el navegador hace un solo trabajo de layout para todo el bloque. Y si el número de nodos es tan grande que ni siquiera un layout único es aceptable, el problema ya no es el thrashing: es la cantidad de nodos, y eso tiene su propio tratamiento.
- Busca en tu código todas las llamadas a
getBoundingClientRect,offsetWidthyoffsetHeight. Clasifícalas: sustituibles por CSS, sustituibles por observador, o genuinamente necesarias. - Sustituye un manejador de
scrollcon lecturas por el patrón del centinela conIntersectionObserver. - Implementa
flipAgrupadoy anima el reordenamiento de una lista de cien elementos. Verifica en el perfil que hay exactamente dos eventos de layout. - Compara el coste de insertar 2000 filas una a una frente a insertarlas con un
DocumentFragment. - Aplica el criterio de las tres preguntas a los avisos que te dio el detector de desarrollo y descarta los inocuos por escrito. Justifica cada descarte.