El retraso de presentación: cuando el cambio es caro de pintar
Qué ocurre entre el final del manejador y el fotograma en pantalla, por qué el tamaño del DOM domina esta parte, y las técnicas que reducen el coste de renderizar un cambio sin cambiar lo que se muestra.
La tercera parte del INP es la que más gente ignora y la que en aplicaciones maduras suele ser la mayor. Tu manejador termina en cinco milisegundos, el perfil no muestra ninguna función tuya, y entre el final del código y el píxel en pantalla pasan doscientos milisegundos de trabajo del motor. Es una parte que no se arregla optimizando JavaScript, porque no hay JavaScript que optimizar: se arregla cambiando qué le pides al motor que renderice.
- Enumerar el trabajo que ocurre entre el fin del manejador y la presentación.
- Explicar por qué el tamaño del DOM afecta al coste de cualquier cambio.
- Aplicar contención y virtualización para acotar el alcance del renderizado.
- Detectar el trabajo ajeno que se cuela en el retraso de presentación.
Qué ocurre en ese hueco
Terminado el último manejador, el navegador vacía las microtareas y decide actualizar el fotograma. Ese ciclo tiene estas etapas:
Devoluciones de fotograma. Las de requestAnimationFrame se ejecutan aquí. Si tu código o el de una biblioteca ha programado alguna, su coste está en el retraso de presentación.
Recálculo de estilo. Para cada elemento invalidado, resolver qué valor gana en cada propiedad. El coste escala con el número de elementos afectados por el cambio, no con el número de propiedades que tocaste.
Layout. Calcular tamaño y posición. Es la etapa más cara y la que más se propaga: un cambio de tamaño en un elemento puede obligar a recalcular a sus hermanos, a sus ancestros y a sus descendientes.
Pintura. Generar las órdenes de dibujo de las capas afectadas.
Composición. Combinar las capas y presentar.
Además, aquí se ejecutan las devoluciones de ResizeObserver y IntersectionObserver, y si alguna modifica el DOM, el ciclo puede repetirse.
Por qué el tamaño del DOM lo domina todo
La invalidación de estilo y el layout tienen un alcance, y el alcance depende de la estructura del documento más que del cambio que hiciste.
Un ejemplo que lo hace evidente: añadir una clase a un elemento. En un documento de 400 nodos cuesta menos de un milisegundo. En uno de 12.000 nodos con selectores complejos puede costar 60. La operación es la misma; lo que cambia es cuántos elementos hay que reevaluar.
Las cifras de referencia para el número de nodos, que las auditorías automáticas usan como umbral: por debajo de 1.400 elementos es cómodo, por encima de 800 nodos con más de 32 niveles de profundidad ya hay aviso, y por encima de varios miles el coste de cada cambio empieza a ser el problema dominante. La profundidad importa tanto como la cantidad, porque la propagación del layout sube y baja por el árbol.
La comprobación es de una línea:
console.log({
nodos: document.querySelectorAll('*').length,
profundidadMax: Math.max(...[...document.querySelectorAll('*')].map((e) => {
let d = 0, n = e;
while ((n = n.parentElement)) d++;
return d;
})),
});
Si el resultado supera los tres o cuatro mil nodos, el retraso de presentación va a ser un problema recurrente en toda la aplicación, y ninguna optimización puntual lo resolverá.
Acotar el alcance
Tres técnicas, de menor a mayor esfuerzo.
La contención de CSS. Declarar que los cambios dentro de un subárbol no afectan a nada de fuera permite al motor saltarse el resto del documento:
.tarjeta {
contain: layout style;
}
.panel-lista {
content-visibility: auto;
contain-intrinsic-size: auto 800px;
}
contain: layout promete que el layout interno no afecta al exterior, y con eso el motor puede recalcular solo ese subárbol. content-visibility: auto va más allá: omite por completo el renderizado del contenido que está fuera de la pantalla, y contain-intrinsic-size le da el tamaño estimado para que la barra de desplazamiento no salte. La mejora en documentos largos es grande y el coste de escribirlo es de dos declaraciones.
Hay un matiz de accesibilidad importante: el contenido omitido por content-visibility sigue siendo buscable con la búsqueda del navegador y accesible para las tecnologías de asistencia, porque el motor lo renderiza bajo demanda. Lo que no funciona bien es el enlace a un ancla interna dentro de contenido omitido en algunas versiones, así que conviene probarlo.
La virtualización. Renderizar solo las filas visibles de una lista larga y reciclar los nodos al desplazarse. Convierte un DOM de 10.000 nodos en uno de 40. Es la solución correcta para listas realmente largas, y tiene costes de accesibilidad y de búsqueda en página que hay que gestionar.
Reducir nodos por diseño. La opción menos glamurosa y a menudo la más efectiva. Los árboles de 12.000 nodos casi nunca tienen 12.000 elementos de información: tienen 800 elementos envueltos en quince capas de contenedores que existen por conveniencia de maquetación. Con las capacidades modernas de CSS, muchas de esas capas se eliminan.
El trabajo ajeno que se cuela
El retraso de presentación incluye todo lo que ocurra entre el fin del manejador y el fotograma, no solo el renderizado de tu cambio. Cuatro cosas se cuelan ahí con frecuencia:
Devoluciones de requestAnimationFrame de bibliotecas. Una biblioteca de animación con un bucle activo ejecuta su devolución en cada fotograma, incluido este.
Observadores de redimensionado que reaccionan a tu cambio. Si tu cambio altera el tamaño de algo observado, la devolución se ejecuta en el mismo ciclo, y si esa devolución vuelve a cambiar el DOM, hay una segunda pasada.
Transiciones y animaciones CSS que arrancan con el cambio. El primer fotograma de una transición tiene un coste de preparación de capas que se paga aquí.
El trabajo diferido mal colocado. Si has movido trabajo pesado a un requestAnimationFrame creyendo que lo estabas difiriendo, lo has puesto exactamente dentro del retraso de presentación. Es el error del que hablaba la lección sobre setTimeout.
Las transiciones de vista del mismo documento son una de las capacidades más agradables que ha llegado al navegador, y tienen una interacción con el INP que casi nadie anticipa y que puede empeorar la métrica de forma dramática.
Lo que hace document.startViewTransition(): captura una instantánea del estado visual actual, ejecuta tu devolución que modifica el DOM, captura el estado nuevo, y anima entre las dos. Las dos capturas son operaciones de pintura de la página completa, y ocurren dentro del ciclo de renderizado que sigue a tu manejador, es decir, dentro del retraso de presentación.
En una página con un DOM grande, cada captura puede costar entre 30 y 120 milisegundos. Dos capturas más el renderizado del cambio son fácilmente 200 milisegundos añadidos a una interacción que sin la transición costaba 40. La animación se ve preciosa y el INP pasa de bueno a malo.
Tres mitigaciones, y las tres importan.
Uno: acota qué se captura. Por defecto se captura la raíz del documento entero. Con view-transition-name en los elementos concretos que quieres animar, y con la contención adecuada, se reduce mucho el área capturada:
.panel-detalle { view-transition-name: detalle; contain: layout paint; }Dos: respeta la preferencia de movimiento reducido, y hazlo saltándote la transición entera, no solo su duración. No es solo accesibilidad: es también la vía de escape para dispositivos lentos.
const reducido = matchMedia('(prefers-reduced-motion: reduce)');
function actualizarVista(cambiar) {
if (reducido.matches || !document.startViewTransition) {
cambiar();
return;
}
document.startViewTransition(cambiar);
}Tres, y es la que de verdad decide: mide el INP con y sin la transición, en el dispositivo de referencia. Si la transición añade más de 100 milisegundos al percentil 75 de esa interacción, la decisión es de producto: ¿vale la animación lo que cuesta? A veces sí, en una navegación entre páginas donde el usuario ya esperaba una pausa. Casi nunca en una interacción de respuesta inmediata como abrir un desplegable o marcar una casilla.
Y un criterio que funciona bien como norma de equipo: transiciones de vista para cambios de contexto, nunca para respuestas inmediatas. Cambiar de pantalla, abrir un detalle, navegar: sí. Filtrar, expandir, seleccionar, escribir: no.
Un apunte sobre el estado de la característica, para no dar por sentado más de lo que hay: las transiciones de vista dentro del mismo documento están disponibles de forma amplia desde 2025; las transiciones entre documentos llegaron después y no están en todos los motores. Cualquier uso debe ir tras la comprobación de document.startViewTransition, que además es la vía natural de degradación.
El diagnóstico
Cuando el retraso de presentación domina, el camino más corto es este.
Uno: cuenta los nodos. Si son miles, ese es el problema de fondo y todo lo demás es paliativo.
Dos: graba un perfil de la interacción y mira el ciclo de renderizado posterior al manejador. El panel desglosa recálculo de estilo, layout, pintura y composición con sus tiempos. La etapa más ancha te dice dónde atacar.
Tres: busca invitados en la fiesta. Devoluciones de fotograma, observadores y transiciones que se ejecuten en ese mismo ciclo y que no tengan nada que ver con tu cambio.
Cuatro: prueba la contención. Añadir contain: layout style al contenedor que cambia y volver a medir es un experimento de treinta segundos que a veces resuelve el problema entero.
Coge una interacción de tu aplicación cuyo retraso de presentación supere los 100 milisegundos. Cuenta los nodos del documento y anota la etapa más cara del ciclo de renderizado en el perfil. Aplica contain: layout style al contenedor afectado y mide de nuevo. Después prueba content-visibility: auto con contain-intrinsic-size en las secciones fuera de pantalla y mide otra vez. Anota las tres cifras: en documentos grandes, la tercera suele ser menos de la mitad de la primera.