Cómo Optimizar el bfcache en 2026: Diagnosticar el Hit Rate y Corregir NotRestoredReasons
Cómo optimizar el bfcache en 2026: usa NotRestoredReasons para diagnosticar por qué Chrome expulsa tu página, arregla los 8 bloqueadores comunes (unload, WebSocket, no-store) y sube tu hit rate al 60% con impacto directo en LCP e INP.
El bfcache (back/forward cache) es una optimización del navegador que almacena una instantánea completa de la página en memoria cuando el usuario sale de ella, de modo que al pulsar Atrás o Adelante la restaura en menos de 100 ms, con LCP e INP prácticamente en cero. Para optimizarlo en 2026 hay que eliminar bloqueadores (WebSockets abiertos, unload handlers, Cache-Control: no-store sin excepciones) y medir el hit rate en producción con la NotRestoredReasons API. Un hit rate saludable en Chrome ronda el 60–70% en desktop y 50–60% en móvil.
El bfcache convierte una navegación repetida en una restauración instantánea (<100 ms), colapsando LCP, FCP y en la práctica INP a valores despreciables.
La PerformanceNavigationTiming.notRestoredReasons API, estable en Chrome 125+, expone exactamente por qué una página fue expulsada del bfcache.
Los ocho bloqueadores más comunes son: unload, beforeunload con preventDefault, Cache-Control: no-store, WebSocket/WebRTC abiertos, keepalive fetch pendientes, transacciones IndexedDB, permisos de sensores y window.opener.
Desde Chrome 123 el navegador permite bfcache incluso con Cache-Control: no-store bajo ciertas condiciones (sin cookies HttpOnly nuevas, sin cambios de credenciales, TTL corto).
Sin instrumentar bfcache con RUM, tu p75 de LCP reportado a CrUX ignora las restauraciones instantáneas y subestima tu ganancia real.
El objetivo realista para 2026 en sitios de ecommerce/media es 60% de hit rate; por debajo del 40% hay bloqueadores prevenibles.
¿Qué es el bfcache y por qué importa para Core Web Vitals?
El back/forward cache es una caché en memoria (separada del HTTP cache y de la caché de disco) donde el navegador guarda el árbol DOM, el heap de JavaScript, el estado del event loop pausado y la pila de layout de la página que el usuario acaba de abandonar. Cuando el usuario pulsa Atrás o Adelante, en lugar de re-parsear HTML, re-ejecutar scripts y re-pintar, el navegador simplemente re-emplaza el frame activo y reanuda los timers. En mis auditorías en Shopify, esta transición se mide típicamente en 80–150 ms, comparado con 1.5–3 s para una carga normal, incluso con HTTP cache tibio.
Para las métricas de Core Web Vitals el impacto es enorme y a menudo mal entendido. Cuando la página se restaura desde bfcache, Chrome dispara un evento pageshow con event.persisted === true, y la biblioteca web-vitals.js para RUM en 2026 reporta un nuevo LCP (a partir del primer pintado sincrónico, que suele ser 0–16 ms) y un nuevo FCP. Este LCP restaurado sí cuenta en el p75 que ve CrUX y por tanto en tu Search Console. Es una de las palancas más baratas para bajar el p75 de LCP en cualquier sitio con navegación multi-página.
Firefox lo implementa desde 2019 con lógica similar, y Safari desde iOS 8 bajo el nombre de Page Cache. Chrome fue el último de los grandes en habilitarlo por defecto (versión 96, 2021) y desde entonces ha ampliado agresivamente los casos elegibles. El más reciente, en Chrome 123, con no-store condicional.
Cómo diagnosticar el bfcache con la NotRestoredReasons API
Honestamente, la forma correcta y actualizada de saber por qué el navegador rechazó restaurar tu página es leer PerformanceNavigationTiming.notRestoredReasons, disponible en Chrome 125+ como API estable. Devuelve null si la navegación sí vino del bfcache; en caso contrario, un árbol con el frame raíz y sus subframes, cada uno con una lista de códigos como WebSocket, unload-listener o MainResourceHasCacheControlNoStore.
// Reporta bloqueadores de bfcache al backend en cada carga.
// Ejecutar tan pronto como la página esté idle.
function reportBfcacheReasons() {
const nav = performance.getEntriesByType('navigation')[0];
if (!nav || !('notRestoredReasons' in nav)) return; // API no soportada
const reasons = nav.notRestoredReasons;
if (reasons === null) {
// La página vino del bfcache: nada que reportar salvo el hit.
navigator.sendBeacon('/rum/bfcache', JSON.stringify({ hit: true }));
return;
}
// Aplanar el árbol frame + subframes en una lista única de motivos.
function flatten(node, out = []) {
if (node.reasons) out.push(...node.reasons.map(r => r.reason));
(node.children || []).forEach(child => flatten(child, out));
return out;
}
const flatReasons = flatten(reasons);
navigator.sendBeacon('/rum/bfcache', JSON.stringify({
hit: false,
url: location.pathname,
reasons: flatReasons,
blocked: reasons.blocked === true,
}));
}
// pageshow con event.persisted === false = navegación normal (posible fallo bfcache)
addEventListener('pageshow', (event) => {
if (!event.persisted) requestIdleCallback(reportBfcacheReasons);
});
El campo reasons en cada nodo del árbol es una lista de objetos { reason: string }. La especificación en el MDN NotRestoredReasons API lista los códigos normativos; algunos códigos específicos de Chrome (como PermissionRequestManager) se están estandarizando en el WICG. En Firefox esta API aún está en draft, así que en producción normalmente se instrumenta como una feature detection con fallback silencioso.
Las 8 causas más comunes de bfcache bloqueado en 2026
Después de auditar decenas de sitios Series B/C durante los últimos dieciocho meses, los códigos que aparecen una y otra vez en notRestoredReasons son estos ocho, ordenados por frecuencia real en mis auditorías:
unload-listener: cualquier addEventListener('unload', ...) en la página o en un iframe hijo descalifica el bfcache en Chrome y en Firefox. Es el bloqueador número uno porque analytics antiguos, herramientas de A/B testing y polyfills lo siguen inyectando.
MainResourceHasCacheControlNoStore: header Cache-Control: no-store en la respuesta del documento. Ver la siguiente sección para la excepción de Chrome 123+.
WebSocket / WebRTC: una conexión abierta en el momento de la navegación. Hay que cerrarla en pagehide.
KeepaliveRequest: un fetch(url, { keepalive: true }) aún pendiente. Común en implementaciones de logging que hacen beacons largos.
IdleManager o PermissionRequestManager: permisos activos de geolocalización, cámara, o notificaciones.
beforeunload con preventDefault: solo bloquea si realmente cancela el evento (típico en formularios con "¿Salir sin guardar?").
SharedWorker: una shared worker con conexiones activas.
BroadcastChannel: abierto sin cerrar en pagehide.
El patrón correcto para casi todos estos es escuchar pagehide (que sí se dispara antes de la entrada al bfcache) y cerrar recursos ahí, en lugar de unload (que expulsa la página):
// Patrón correcto: cerrar recursos en pagehide, no en unload.
let socket = new WebSocket('wss://api.example.com/live');
addEventListener('pagehide', (event) => {
// event.persisted === true: la página va al bfcache.
// Cierra el socket para no bloquear la entrada.
if (socket.readyState === WebSocket.OPEN) socket.close();
});
addEventListener('pageshow', (event) => {
// Al volver del bfcache, reabrir el socket.
if (event.persisted) {
socket = new WebSocket('wss://api.example.com/live');
}
});
// NUNCA hacer esto en 2026, porque expulsa la página del bfcache:
// addEventListener('unload', () => socket.close());
Cache-Control: no-store y bfcache: la excepción de Chrome 123+
Durante años, la respuesta corta a "¿Cache-Control: no-store rompe el bfcache?" fue "sí, siempre". Desde Chrome 123 (marzo de 2024) esto cambió: Chrome ahora permite bfcache incluso con no-store, siempre que se cumplan estas condiciones durante el tiempo que la página está en la caché:
No cambian las cookies HttpOnly de forma que alteren la identidad del usuario (el navegador vigila el jar de cookies).
No hay un cambio de credenciales HTTP en el mismo origen (por ejemplo, un logout desde otra pestaña).
La página no lleva más de tres minutos en bfcache (TTL reducido específicamente para respuestas no-store).
El usuario no vuelve desde una página que se sirve HTTPS mixed-content bloqueado.
Si tu backend renderiza dashboards autenticados con Cache-Control: no-store, private, ya no necesitas eliminar el header para ganar bfcache. Solo necesitas asegurar que la sesión no muta entre el pagehide y el pageshow. El campo MainResourceHasCacheControlNoStoreDeviceBoundSessionTerminated aparece en notRestoredReasons cuando la sesión sí cambió y por eso se expulsó la página.
Firefox y Safari a día de hoy siguen respetando no-store como bloqueo absoluto. Si tu tráfico móvil viene mayoritariamente de Safari (típico en ecommerce US), esta excepción de Chrome ayuda menos que en desktop. La guía oficial de bfcache en web.dev tiene la tabla actualizada de compatibilidad por navegador.
Cómo probar el bfcache en Chrome DevTools
Chrome DevTools trae un tester específico de bfcache desde 2022 y, en 2026, sigue siendo la forma más rápida de auditar una página. Los pasos exactos:
Abre DevTools (F12), pestaña Application.
En el panel izquierdo, expande Background services y haz clic en Back/forward cache.
Pulsa el botón Test back/forward cache. Chrome navegará a chrome://terms y volverá automáticamente para simular la navegación.
El resultado será Service Worker unregistered + Successfully served from back/forward cache, o una lista de razones bajo Actionable, Page support pending o Not actionable.
Los códigos bajo Actionable son los que tú puedes arreglar: quitar unload handlers, cerrar sockets, revisar headers. Los Not actionable son limitaciones del navegador (por ejemplo, cierta extensión inyectada). Los Page support pending son casos donde Chrome está trabajando en soporte y tarde o temprano se resolverán.
Para automatizar esto en CI, Lighthouse desde la versión 11 incluye la auditoría bf-cache. Yo la incluyo en el mismo pipeline donde ya corre Lighthouse CI con presupuestos YAML. Un umbral razonable es fallar el build si aparece cualquier razón actionable en la página home o en el detalle de producto.
# lighthouserc.yml: bloquear PR si hay bfcache actionable
ci:
assert:
assertions:
# Falla si Lighthouse detecta cualquier razón actionable.
'bf-cache': ['error', { minScore: 1 }]
# Presupuestos existentes de Core Web Vitals
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }]
'interaction-to-next-paint': ['error', { maxNumericValue: 200 }]
Qué hit rate de bfcache es bueno y cómo medirlo con RUM
La pregunta que me hacen en cada engagement es "¿qué hit rate debería tener?". Los datos públicos del equipo de Chrome apuntan a que sitios bien optimizados alcanzan un 60% en desktop y un 50% en móvil, con outliers de contenido (Wikipedia, blogs de noticias sin login) llegando al 80%. Un ecommerce típico con checkout autenticado suele arrancar en 15–25% y, con trabajo, puede llegar al 55–65%.
La medición real se hace agregando la señal hit: true/false del beacon del ejemplo anterior sobre todas las navegaciones que son de tipo back_forward (PerformanceNavigationTiming.type === 'back_forward'). En SQL:
-- BigQuery / Warehouse: hit rate diario, solo navegaciones back/forward.
SELECT
DATE(timestamp) AS day,
COUNTIF(hit = true) / COUNT(*) AS bfcache_hit_rate,
COUNT(*) AS back_forward_navigations
FROM rum_events
WHERE event_type = 'bfcache'
AND navigation_type = 'back_forward'
GROUP BY day
ORDER BY day DESC;
Segmenta por navegador (Safari suele estar 10–15 puntos por debajo por su manejo estricto de no-store) y por tipo de página (home vs. producto vs. checkout). Los checkouts autenticados serán tu peor cohorte, y probablemente aceptable: la seguridad de sesión pesa más que la optimización aquí.
Impacto del bfcache en LCP, INP y CLS
El efecto del bfcache sobre las tres métricas de Core Web Vitals es dispar y merece entenderse por separado, sobre todo si estás priorizando dónde invertir semanas de ingeniería.
LCP: la ganancia más grande
En una restauración desde bfcache, el LCP se mide desde el momento en que Chrome muestra el primer frame de la página restaurada, no desde una carga fresca. En la práctica el valor está entre 0 ms y el tiempo del primer rAF (~16 ms). Para un sitio con 40% de tráfico de navegación back/forward, subir el hit rate de 20% a 60% puede bajar el p75 de LCP en 300–700 ms sin tocar el critical path. Es, kilómetros por delante, la mejora de LCP con mejor ratio esfuerzo/impacto que conozco. Complementa (no reemplaza) el trabajo de optimización de imágenes para LCP con AVIF y fetchpriority, que sigue siendo la palanca principal en cargas frescas.
INP: mejora indirecta
El INP se recalcula tras la restauración y las interacciones inmediatas suelen tener un next paint rapidísimo porque no compiten con parsing ni hidratación. Sin embargo, si las interacciones que dominaban tu INP ocurrían tras la primera carga (por ejemplo, un click en un carrusel que dispara JS pesado), bfcache no lo arregla: solo lo evita en las visitas restauradas. La depuración de INP con LoAF y scheduler.yield() sigue siendo obligatoria; bfcache aporta que el problema no aparezca en las restauraciones.
CLS: neutral o levemente positivo
Las restauraciones desde bfcache no re-ejecutan el layout inicial, así que layout shifts causados por fuentes tardías, imágenes sin width/height o widgets asíncronos no vuelven a ocurrir. El CLS reportado en la sesión restaurada suele ser 0. Ojo: si el layout depende de datos que se refrescan en pageshow (por ejemplo, un carrito que hace fetch al restaurar), puedes reintroducir shifts, así que reserva altura fija con min-height.
La regla que sigo en producción: si el sitio tiene >25% de navegación back/forward (típico de media, ecommerce con navegación por categorías, docs), bfcache está entre las tres primeras optimizaciones a auditar. Si es <10% (SPAs puras, single-page checkouts), el ROI es marginal y otras cosas como reducir el TTFB con Early Hints 103 o mejorar el critical CSS dan más.
Preguntas frecuentes
¿Por qué mi bfcache no funciona en Chrome aunque quité los unload handlers?
Probablemente hay un bloqueador secundario. Abre DevTools → Application → Back/forward cache y ejecuta el test. Los sospechosos habituales tras unload son: Cache-Control: no-store en la respuesta principal, un WebSocket abierto que no cierras en pagehide, un keepalive fetch pendiente, o un iframe hijo (widget de chat, ad) que lleva su propio unload. La NotRestoredReasons API te dará el código exacto.
¿Cuánto tiempo permanece una página en el bfcache?
En Chrome, hasta 10 minutos para páginas con Cache-Control normal, y solo 3 minutos si la respuesta llevaba no-store (excepción de Chrome 123+). Firefox usa un TTL de 30 minutos. Safari no publica un TTL fijo pero suele descartar entradas cuando la memoria se satura o tras cerrar y reabrir la pestaña.
¿bfcache funciona en Single Page Applications?
Solo si la navegación entre "páginas" cruza un documento completo (MPA o hard navigation). Las rutas internas de una SPA que usan History API sin recargar no entran ni salen del bfcache; el propio documento se cachea al salir del origin. Para navegación instantánea dentro de una SPA la herramienta correcta es la Speculation Rules API.
¿Cómo afecta el Service Worker al bfcache?
Un Service Worker registrado no bloquea el bfcache en Chrome desde 2023. Sin embargo, si el SW se actualiza mientras la página está cacheada y la nueva versión toma control, la página puede ser expulsada. Para evitarlo, usa skipWaiting() con criterio y evita clients.claim() agresivo.
¿Puedo forzar que una página no entre al bfcache?
Sí, aunque casi nunca es lo correcto. Enviar Cache-Control: no-store junto con cambios de sesión seguros (para evitar la excepción de Chrome 123+) es el mecanismo estándar. En páginas post-logout, por ejemplo, quieres esto para que pulsar Atrás no muestre datos autenticados. Otra opción es registrar un unload handler explícito, pero es un antipatrón.
Priya is a frontend performance engineer with 11 years of experience untangling slow React and Next.js apps. She spent four years at Shopify on the Storefront Performance team, where she drove a project that cut median LCP across the Online Store theme platform from 3.4s to 1.8s by rewriting the critical render path and killing third-party tag bloat. Before that she shipped checkout perf work at Klarna.
These days she runs an independent practice helping Series B/C ecommerce companies hit a 75th-percentile INP under 200ms before they bother with redesigns. She has a soft spot for Lighthouse CI in pull-request gates and writes most of her perf budgets in YAML. Outside work she's slowly restoring a 1998 Honda CB400 and arguing with her partner about whether RUM beats lab data (it does).
Aprende a implementar compresión con diccionario compartido (SDC) en 2026: dcb (Brotli), dcz (Zstandard), Use-As-Dictionary y passthrough en Cloudflare, con ejemplos de código y métricas RUM.
Los scripts de terceros (GTM, Intercom, píxeles, chat) son la causa número uno de INP y LCP degradados. Esta guía práctica muestra cómo auditarlos con Lighthouse y third-party-web, moverlos al Web Worker con Partytown, aplicar el patrón facade a embeds pesados y bloquear regresiones con presupuestos en Lighthouse CI.
Implementa RUM de Core Web Vitals con web-vitals.js v5 y la CrUX API. Recolecta LCP, INP y CLS reales, calcula p75 por dispositivo y compara contra el CrUX oficial en menos de 30 líneas.