Core Web Vitals por ruta con Soft Navigations

Core Web Vitals por ruta con Soft Navigations

Los Core Web Vitals se diseñaron para medir lo que percibe el usuario, no los detalles técnicos de cómo está construido el sitio. La idea era buena, pero hay una arquitectura enorme que nunca entró del todo en el modelo: la Single Page Application.

En una SPA hay un solo "page load" real. Después de eso, el usuario navega entre rutas y el navegador no se entera de nada: cambia el DOM, history.pushState() actualiza la URL, el back y el forward funcionan, y para el usuario eso fue una navegación. Para el timeline de performance, no pasó nada.

Las consecuencias las conocés si alguna vez miraste RUM de una SPA:

  • LCP se finaliza en la primera interacción del usuario. O sea: medís el LCP de la primera pantalla y nada más. Las otras quince rutas que visitó el usuario no tienen LCP.
  • INP y CLS se acumulan durante toda la vida de la pestaña. Si alguien deja la app abierta cuatro horas, el CLS que reportás es la suma de todo, sin forma estándar de decir "esto pasó en /checkout".
  • Todo lo que hoy ves partido por ruta en tu proveedor de RUM está construido sobre heurísticas propias, distintas en cada vendor y no comparables entre sí.

Desde Chrome 151 (28 de julio de 2026) esto tiene una definición en la plataforma y dos entradas nuevas en el Performance Timeline. Y viene habilitado por defecto: no hay flag ni origin trial.

Qué es una "soft navigation"

La definición que propone Chrome tiene tres condiciones, y las tres tienen que cumplirse:

  1. La navegación es iniciada por una acción del usuario.
  2. Resulta en un cambio de URL visible para el usuario.
  3. La interacción produce un paint visible.

Las tres juntas dejan afuera un replaceState disparado por un timer de analytics (no hubo interacción) y un filtro que cambia query params sin repintar (no hubo paint). El equipo de Chrome avisa que la definición va a dar tanto falsos positivos como falsos negativos, y está pidiendo feedback en el repo de la especificación.

Lo interesante es que la definición es agnóstica del framework. No importa si usás el Navigation API, history.pushState(), React Router, el router de Next o algo propio: si se cumplen las tres condiciones, el navegador emite la entry.

Las dos entries nuevas

soft-navigation

Se emite cada vez que Chrome detecta una navegación por software. Trae:

  • name: la URL nueva.
  • navigationId: un identificador único de esa navegación (la misma URL visitada dos veces son dos navigationId distintos).
  • interactionId: el id de la interacción que la disparó.
  • startTime: el momento de esa interacción.
  • paintTime / presentationTime: el FCP de esa navegación.
  • getLargestInteractionContentfulPaint(): una función que devuelve el mayor paint asociado a esa navegación.

interaction-contentful-paint

Se emite después de cualquier interacción que produzca un paint con contenido, haya navegación o no. Adentro trae un largestContentfulPaint que es lo que se usa como LCP de una soft navigation.

Además, se agregó navigationId a un montón de entries existentes (first-paint, first-contentful-paint, largest-contentful-paint, interaction-contentful-paint, first-input-delay, event, layout-shift), para poder atribuirlas a la navegación correcta.

Observarlas es lo de siempre:

const observer = new PerformanceObserver(list => {
  for (const entry of list.getEntries()) {
    console.log(entry.name, entry.navigationId, entry.startTime);
  }
});

observer.observe({ type: 'soft-navigation', buffered: true });

Y la detección de soporte:

if (PerformanceObserver.supportedEntryTypes.includes('soft-navigation')) {
  // Medir soft navigations
}

Usá siempre un PerformanceObserver y no getEntriesByType(): el buffer se limita a las primeras 50 entries, y una app de sesión larga se come ese límite sin despeinarse.

Las trampas

El startTime es relativo a la navegación dura

Todos los timings del Performance Timeline se miden desde el page load original. Si el usuario navegó a /pedidos a los 45 segundos de abrir la app y el LCP de esa ruta se registró a los 45,8 segundos, el número crudo que vas a ver es 45800, no 800.

Entonces: al LCP de la soft navigation le restás el startTime de su soft-navigation entry.

Otro detalle que cambia respecto a las navegaciones duras: el startTime de una soft navigation es el momento de la interacción (el click), no el del "commit" de la navegación. Eso significa que el tiempo que tarda tu event handler en ejecutarse entra en la métrica. Es más honesto respecto de lo que percibe el usuario, pero no es comparable uno a uno con el load de una página normal.

Mapear por interactionId, no por navigationId

Uno esperaría agrupar por navigationId, pero hay dos problemas:

  1. Un interaction-contentful-paint puede emitirse antes de que se complete la soft navigation (si el paint ocurre antes de que se actualice la URL). En ese caso lleva el navigationId viejo.
  2. Los interaction-contentful-paint se siguen emitiendo para interacciones posteriores que no son navegaciones. Esos no deberían contar como LCP.

Por eso la recomendación es mapear los interaction-contentful-paint a la soft-navigation por interactionId, que resuelve los dos casos de una: descarta los paints de interacciones que no navegaron y recupera los que llegaron con el id anterior. Y para los paints que ocurrieron antes de que se emitiera la entry de navegación, procesás primero getLargestInteractionContentfulPaint().

El TTFB no existe

¿Cuál es el TTFB de una ruta que ya estaba en memoria? La pregunta no tiene una respuesta buena: puede haber sido un prefetch de hace diez segundos, o tres requests, o ninguno.

La recomendación es reportar 0, igual que se hace con las restauraciones desde bfcache. Es lo que hace la librería web-vitals.

El contenido que no cambia no cuenta

El LCP de una soft navigation se calcula solo con paints nuevos asociados a la interacción que navegó. Si tu app tiene un banner enorme arriba que es el elemento LCP del load inicial y solo cambia el texto de abajo al navegar, el LCP de las soft navigations va a ser ese texto, no el banner.

Consecuencia práctica: la misma URL puede tener LCP distinto según cómo se llegó. Si el usuario entra directo por deep link, el banner es un paint nuevo y es candidato a LCP; si llegó navegando dentro de la app, no. Es el mismo fenómeno que ya pasa con un ancla que te deja a mitad de página en una navegación dura, pero ahora lo vas a ver en los datos.

Por eso vale la pena mandar el navigationType en el beacon, para poder segmentar después.

Hacelo con web-vitals, no a mano

Se puede implementar todo esto a mano, pero entre el mapeo por interactionId, los tiempos relativos, el reset de métricas en cada navegación y el reporte contra la URL correcta, es fácil equivocarse y no darse cuenta. La librería web-vitals tiene soporte desde la v6.0.0 y resuelve los detalles:

import { onTTFB, onFCP, onLCP, onCLS, onINP } from 'web-vitals';

function reportHardNav(metric) {
  beacon('/rum', { ...metric, slice: 'page' });
}

function reportSoftNav(metric) {
  // metric incluye navigationId y navigationURL
  beacon('/rum', { ...metric, slice: 'soft-nav' });
}

// Medición tradicional, comparable con el histórico y con otros navegadores
onTTFB(reportHardNav);
onFCP(reportHardNav);
onLCP(reportHardNav);
onCLS(reportHardNav);
onINP(reportHardNav);

// Medición por soft navigation
onTTFB(reportSoftNav, { reportSoftNavs: true });
onFCP(reportSoftNav, { reportSoftNavs: true });
onLCP(reportSoftNav, { reportSoftNavs: true });
onCLS(reportSoftNav, { reportSoftNavs: true });
onINP(reportSoftNav, { reportSoftNavs: true });

Sí, esto reporta dos veces y almacena dos veces. Es a propósito: la medición por soft navigation solo existe en Chromium, así que si tirás la tradicional perdés comparabilidad con Safari, con Firefox y con tu propio histórico. Durante la transición conviene convivir con las dos.

Lo que reporta para cada soft navigation:

MétricaQué mide
TTFBSiempre 0
FCPPrimer paint con contenido de la interacción que navegó, relativo al inicio de la soft navigation
LCPMayor paint con contenido de esa interacción; se sigue actualizando hasta que te vas de la ruta
INPEl INP medido entre las dos navegaciones (no es la peor interacción: descarta outliers en sesiones largas)
CLSLa mayor ventana de shifts entre las dos navegaciones

Un detalle sobre INP que confunde: la interacción que dispara la navegación suele quedar atribuida a la ruta anterior, no a la nueva. Tiene sentido si lo pensás como un click en un link: el costo de ese click pertenece a la página donde se hizo.

En DevTools

El panel de Performance ya entiende soft navigations: aparecen en Live Metrics, y en el trace hay markers e insights por navegación. Para debuggear una ruta puntual es bastante más cómodo que leer entries en la consola.

Estado

Esto solo lo medís en Chromium. Las posiciones de Mozilla y WebKit sobre Soft Navigations e Interaction Contentful Paint siguen abiertas. Tampoco está definido todavía cómo va a reportarse esto en CrUX ni cuándo entra formalmente al programa de Core Web Vitals.

Aun así, yo lo adoptaría ya. Un LCP de 4 segundos en /productos/:id lo sufren todos tus usuarios; que Safari no te lo pueda medir no lo hace menos real.

Actualizá a web-vitals v6, activá reportSoftNavs, y preguntale a tu proveedor de RUM si ya soporta el estándar o si sigue con heurísticas propias. La respuesta a esa pregunta te dice bastante sobre cuánto confiar en el dashboard que estás mirando hoy.

Comments

Share your thoughts and join the discussion

Loading comments…


Related Posts

Responsive iframes

Responsive iframes

11 min read

Que un iframe se ajuste al alto de su contenido es uno de los pedidos más viejos de la plataforma, y siempre lo resolvimos con postMessage. Chrome 154 lo trae como un doble opt-in: una propiedad CSS del lado del padre y un meta del lado del hijo.