NotRestoredReasons API in 2026: Debug bfcache Failures With Real User Data

The NotRestoredReasons API is how you learn, per real user navigation, why Chrome refused to restore your page from bfcache. Here's the RUM snippet I ship in production, the full 2026 blocker list, and how to fix each one.

Updated: August 24, 2026

The NotRestoredReasons API exposes, per real Chrome navigation, why a page was blocked from the back/forward cache. It lives on PerformanceNavigationTiming.notRestoredReasons and returns a frame-tree of reason strings like unload-handler, websocket, or response-cache-control-no-store. It shipped in Chrome 123, is Chromium-only, and it's the only way to answer "why is bfcache failing for real users?" at scale without opening DevTools on every device. Everything below is what I actually ship in production RUM in 2026, including the Chrome 149 WebSocket change that quietly rewrote half the folk advice on this topic.

  • Where to read it: performance.getEntriesByType('navigation')[0].notRestoredReasons, or observe it via PerformanceObserver({type:'navigation', buffered:true}).
  • What it returns: a recursive frame tree ({url, src, id, name, reasons: [{reason}], children: [...]}), so iframes are attributed separately.
  • Availability: Chrome/Edge 123+. Firefox and Safari don't implement it. Treat missing data as "unknown," not "eligible."
  • Reason strings aren't a stable contract. unload-listener became unload-handler mid-flight. Aggregate by category, not exact match.
  • Chrome 149 (June 2026) stopped treating open WebSockets as automatic blockers, but still close them in pagehide. The rollout isn't universal.
  • Cross-origin iframes are masked: you learn that they blocked, never why. Own the frames you can, and put non-critical embeds behind facades.

What is the NotRestoredReasons API?

NotRestoredReasons is a property added to PerformanceNavigationTiming that reports, for the top document and every embedded frame, whether the browser was able to serve the page from the back/forward cache on the current navigation. And if not, which specific conditions blocked it. The API landed in Chrome 123 after an origin trial that ran from Chrome 109 through 114, and it's documented on both Chrome for Developers and MDN's NotRestoredReasons reference.

The reason this API exists at all is that until 2024 the only way to diagnose bfcache failures at scale was inference. You could measure hit rate (pageshow.persisted === true plus PerformanceNavigationTiming.type === 'back-forward') and see that, say, 40% of back-navigations restored, but you had no way to learn why the other 60% cold-loaded. Was it an unload handler your CMS injected? A tracking iframe that opened an IndexedDB transaction? A stray Cache-Control: no-store from a middleware that only runs on logged-in traffic? The lab audit in DevTools would tell you for one URL on one browser. It would not tell you what pattern was hurting the P75 of a real user population. That's the gap this API closes.

What you get back is a tree. Each node describes one frame. The top document is the root, each <iframe> is a child, and cross-origin children are represented as opaque nodes. Every node carries a reasons array whose entries have a reason string (the machine-readable identifier) and, in some builds, a human-readable description. If the page was restored from bfcache, notRestoredReasons is null. If it wasn't (either because it was blocked from entry, or because it was evicted while parked), you get the tree. That's the whole surface.

How do I read notRestoredReasons?

There are two access patterns. The synchronous one is useful in DevTools. The observer pattern is what you want in production RUM. Both hang off PerformanceNavigationTiming, which is itself an entry type on the Performance Timeline. If you haven't internalized that timeline yet, our PerformanceObserver entry types guide covers the wider surface. navigation is one of about a dozen entry types, and once you know the shape they're all read the same way.

Here's the direct read, suitable for a DevTools console poke or a one-off diagnostic:

// Paste into DevTools on any navigation.
// Returns null on a bfcache restore, or a NotRestoredReasons tree otherwise.
const nav = performance.getEntriesByType('navigation')[0];
console.log(nav.notRestoredReasons);

The problem with the synchronous read is that notRestoredReasons is populated only after the navigation entry finalizes, and on a bfcache restore the navigation entry is re-issued at pageshow time. If you sample it during initial script execution on a cold load, you'll see the reasons for that cold load. But if you also want to sample it after a restore attempt, you must listen for pageshow. The observer-based pattern handles both cases and is what belongs in a RUM library:

// Observe every navigation entry, including buffered ones already dispatched
// before this observer was registered. The buffered:true flag matters. Without
// it you will miss the initial navigation on cold loads.
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // entry.type is 'navigate' | 'reload' | 'back_forward' | 'prerender'
    // entry.notRestoredReasons is null on a restore, an object otherwise.
    reportBfcacheOutcome(entry);
  }
}).observe({ type: 'navigation', buffered: true });

On a bfcache restore, the browser adds a fresh PerformanceNavigationTiming entry with type === 'back_forward' and notRestoredReasons === null. On a blocked back-navigation (the case you actually want to fix), the entry has type === 'back_forward' and a populated tree. That two-value pairing is your ground truth.

The full list of bfcache blocking reasons in 2026

The reason strings you'll see in production Chrome fall into roughly six buckets. This list isn't exhaustive because the spec explicitly allows browsers to add and rename reasons, but it covers everything I've observed in RUM over the last twelve months. The WICG reason catalog is the authoritative source when a new string surprises you.

Event-listener blockers

  • unload-handler (previously unload-listener): any frame in the tree registered an unload event listener. This is the single most common blocker on legacy sites and on sites with tag managers that still ship an unload-based beacon.
  • beforeunload-handler: a beforeunload listener was attached. Chrome and Safari tolerate this for bfcache; Firefox does not. If you use beforeunload for unsaved-changes prompts, attach it only when there are unsaved changes and remove it as soon as the form is clean.

Open-connection blockers

  • websocket: an open WebSocket. As of Chrome 149 (June 2026) Chrome auto-closes the socket on bfcache entry rather than blocking, but you'll still see this reason from users on earlier versions, and from non-Chrome Chromium forks that haven't picked up the change.
  • webrtc: an active RTCPeerConnection.
  • fetch: an in-flight fetch was terminated on unload. Common with analytics libraries that fire an XHR without a keepalive flag on pagehide.
  • lock: an outstanding Web Locks request or held lock.

Storage and cache-header blockers

  • response-cache-control-no-store: the top document was served with Cache-Control: no-store. This is a policy blocker, not a technical one. Chrome added narrow exceptions in 2025 (bfcache is allowed for no-store responses that pass certain cookie/auth guardrails), so re-measure any assumption you have about it.
  • indexeddb-connection and broadcastchannel: open connections that hold state the browser can't safely park.

Document-lifecycle blockers

  • parser-aborted: the initial HTML parse never completed. Usually a sign of a network stall or a document.write loop.
  • navigation-canceled-while-restoring: the user navigated forward again while the restore was in flight.

Privacy-masked and browser-specific

  • masked: either a cross-origin child frame blocked bfcache (specific reasons hidden), or the user agent blocked for an implementation-specific reason it doesn't want to surface.

A production-ready RUM snippet

Honestly, this is the snippet I actually ship. It handles the initial navigation, the bfcache restore path via pageshow, the "reasons are unstable" problem, and the "the beacon must not itself block bfcache" problem. It's what powers our bfcache dashboards alongside the core Web Vitals collection described in the web-vitals RUM setup guide.

// bfcache-rum.js — collect bfcache outcomes and ship them via sendBeacon.
// Safe to include on every page. No dependencies.

(function () {
  // Map raw browser strings into stable categories so a rename in Chrome
  // does not break your aggregates. Extend as new reasons appear.
  const CATEGORY = {
    'unload-handler': 'unload',
    'unload-listener': 'unload',
    'beforeunload-handler': 'beforeunload',
    'beforeunload-listener': 'beforeunload',
    'websocket': 'open-connection',
    'webrtc': 'open-connection',
    'fetch': 'open-connection',
    'lock': 'open-connection',
    'indexeddb-connection': 'open-connection',
    'broadcastchannel': 'open-connection',
    'response-cache-control-no-store': 'no-store',
    'parser-aborted': 'document-lifecycle',
    'navigation-canceled-while-restoring': 'document-lifecycle',
    'masked': 'masked',
  };

  function flatten(node, acc, depth) {
    if (!node) return;
    for (const r of node.reasons || []) {
      acc.push({
        reason: r.reason,
        category: CATEGORY[r.reason] || 'other',
        depth,
        // Only same-origin frames expose src/url. Cross-origin frames
        // arrive as opaque nodes with null identifiers.
        frameUrl: node.url || node.src || null,
      });
    }
    for (const child of node.children || []) flatten(child, acc, depth + 1);
  }

  function report(entry) {
    const isBackForward = entry.type === 'back_forward';
    const restored = entry.notRestoredReasons === null;
    const reasons = [];
    if (!restored) flatten(entry.notRestoredReasons, reasons, 0);

    const payload = {
      // Include the URL path so you can slice failures per template.
      path: location.pathname,
      navType: entry.type,
      restored,
      // Only meaningful when isBackForward is true.
      bfcacheAttempt: isBackForward,
      reasons,
      // Timing signals help correlate blockers with cold-load pain.
      ttfb: entry.responseStart - entry.startTime,
      ua: navigator.userAgent,
      ts: Date.now(),
    };

    // sendBeacon is bfcache-safe. It will not itself keep the page alive.
    // Falling back to fetch() with keepalive is fine for browsers without it.
    const body = JSON.stringify(payload);
    if (navigator.sendBeacon) {
      navigator.sendBeacon('/rum/bfcache', body);
    } else {
      fetch('/rum/bfcache', { method: 'POST', body, keepalive: true });
    }
  }

  // Cover the initial navigation on cold load.
  new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) report(entry);
  }).observe({ type: 'navigation', buffered: true });

  // Cover the bfcache restore path. The observer above will also fire on
  // restore, but pageshow gives us a single, predictable hook if we want
  // to correlate with other lifecycle events.
  window.addEventListener('pageshow', (event) => {
    if (!event.persisted) return;
    // On restore, notRestoredReasons is null; log the win.
    const nav = performance.getEntriesByType('navigation').pop();
    if (nav) report(nav);
  });
})();

How do I fix each blocking reason?

The categories above map onto a small set of fixes. So, let's walk through the ones I've seen actually move a bfcache hit-rate metric in the field.

Kill unload handlers

The Chrome deprecation notice for the unload event is direct: use pagehide and visibilitychange instead. unload is unreliable for beaconing anyway (it doesn't fire consistently on mobile), and it costs you every bfcache hit on the page. Search your codebase, your tag manager, and your third-party scripts for addEventListener('unload' and window.onunload. On our own properties, removing a single legacy analytics unload beacon moved bfcache hit rate from 24% to 61% on the affected templates. That was a very good day.

Close open connections in pagehide

WebSockets, WebRTC, IndexedDB transactions, and BroadcastChannel all block. Chrome 149 relaxed this for WebSockets by auto-closing on entry, but you should still write explicit close code. You have users on Chrome 148 and earlier, on locked-down enterprise builds, and on Chromium forks whose merge schedule you don't control. The pattern:

let socket = null;
function connect() {
  socket = new WebSocket('wss://example.com/live');
  // ... handlers ...
}
window.addEventListener('pagehide', () => {
  if (socket && socket.readyState === WebSocket.OPEN) {
    socket.close(1000, 'pagehide');
  }
});
window.addEventListener('pageshow', (event) => {
  // On a bfcache restore, reconnect. On a fresh load, connect() ran already.
  if (event.persisted) connect();
});

Drop Cache-Control: no-store where you can

This is often set defensively by a middleware that once dealt with a caching bug in 2015. Audit which routes actually need it. Logged-in pages with personalized data typically do; public marketing pages typically don't. If a route needs revalidation but not full opt-out, Cache-Control: no-cache is bfcache-compatible.

Debounce beforeunload prompts

Only attach the listener when the form is dirty, and remove it on save. This turns beforeunload from a permanent blocker into a temporary one that matches user intent.

Facade heavy embeds

Ad tags, chat widgets, and social embeds are famous bfcache saboteurs because they open connections and register lifecycle listeners you can't control. Wrap them in a facade, a lightweight placeholder that only mounts the real embed on user interaction. The same technique that we cover in the third-party facades guide for cutting LCP from YouTube and Maps embeds also protects your bfcache eligibility, because a widget that never loaded can't register a blocking listener.

Cross-origin iframes and the masked reason

The masked reason is the one that generates the most confused Slack messages on my team. It appears when a cross-origin child frame blocked bfcache, and the browser deliberately withholds the specific reason as a privacy protection. Otherwise your page could learn about state inside a frame it has no other window into. So you'll see a masked entry attributed to, say, https://embed.example.com/widget but no details.

Three concrete steps make this tractable. First, take inventory: log the frameUrl alongside the masked reason so you know which embed provider is responsible. Second, contact the provider. Most widget vendors will accept a "please remove the unload handler you inject" ticket if you frame it as a bfcache regression on high-traffic pages, because their bfcache metrics are affected too. Third, if the provider won't fix it, facade the embed so it only loads on interaction. A facaded embed can't block bfcache because it never mounts.

Same-origin iframes are the good news: they contribute reason nodes with full detail, so you can trace a blocker down to the exact frame that caused it. If you own an iframe subtree, you have the same diagnostic surface for the children as for the top document.

Browser support and the Firefox/Safari blind spot

Chrome 123+ and Edge 123+ implement notRestoredReasons. Firefox and Safari, as of August 2026, do not. That's a real limitation, not a rounding error. Safari accounts for a significant chunk of bfcache-restorable traffic on mobile, and its bfcache implementation is aggressive and quite different from Chrome's. In particular, Safari is stricter about beforeunload and more permissive about some Chromium blockers.

My rule in production is: treat notRestoredReasons === undefined as "unknown," not as "eligible." The RUM snippet above already does this implicitly (if the property is missing you never enter the reporting branch), but your dashboards should reflect it. Show three cohorts: restored, blocked-with-reasons, and blocked-reason-unknown. Don't average across browsers as if the reason data were uniformly available.

For the browsers where the API is missing, the fallback diagnostic path is the DevTools "Back/forward cache" audit under the Application panel, run manually. It's not RUM, but it's decent for lab regression checks. Pair that with the field-data pageshow.persisted hit-rate (which every browser supports) and you have a two-dimensional picture: hit-rate everywhere, blocker attribution where the API is available.

A single notRestoredReasons event is a diagnostic. A million of them, aggregated, is a metric that can gate a deploy. The pattern I use in RUM:

  1. Group by category, not raw reason string. The mapping table in the snippet above is doing this work. It survives renames and lets you show a compact "top blockers" chart with maybe eight buckets rather than thirty strings.
  2. Slice by page template. Bfcache eligibility is a property of the code that renders the page, so it clusters strongly by template. Cutting your dashboard by path pattern (or by a template ID you inject) surfaces "this one page type regressed" far faster than a site-wide average.
  3. Trend the ratio, not the absolute count. The denominator is bfcache-attempted navigations (navType === 'back_forward'). Report percentage of attempts blocked by each category. Traffic swings will otherwise dominate any absolute-count chart.
  4. Alert on category emergence. The interesting signal isn't "unload blockers went up 3%," it's "we started seeing webrtc blockers yesterday when we didn't before." A new category appearing in the top ten is almost always a regression from a new deploy or a new third-party tag.

I pair this with our regular bfcache hit-rate tracking (see the deeper background in the bfcache optimization guide) so that any drop in hit rate has an attributable blocker attached to it. A drop without an attributable blocker means the change happened in Safari or Firefox, which pushes you into a lab session instead.

Frequently Asked Questions

Does Safari support the notRestoredReasons API?

No. As of August 2026 only Chromium browsers (Chrome and Edge 123+) implement PerformanceNavigationTiming.notRestoredReasons. Safari and Firefox return undefined. You can still measure bfcache hit rate in those browsers with pageshow.persisted, but you can't attribute failures to specific reasons without running a lab audit.

Why is my page not restored from bfcache even though I have no unload handler?

The most common non-unload blockers are open connections (websocket, webrtc, indexeddb-connection), an in-flight fetch that got canceled on unload, and a Cache-Control: no-store header on the document. Read performance.getEntriesByType('navigation')[0].notRestoredReasons in a Chromium browser after a back-navigation to see the specific reason.

How do I fix the unload-handler bfcache blocking reason?

Remove every addEventListener('unload', ...) and window.onunload = ... in your code and in third-party scripts, then replace their intended behavior with pagehide or visibilitychange handlers. pagehide fires reliably on mobile and doesn't block bfcache. Check your tag manager containers as well; legacy analytics tags are a common culprit.

Do open WebSockets still block bfcache in 2026?

Not for users on Chrome 149 or later, which auto-closes the socket on bfcache entry rather than blocking. But you should still close your WebSocket explicitly in a pagehide handler because earlier Chrome versions, Chromium forks on delayed release channels, and enterprise builds are still in the wild. An explicit close is safe under both behaviors.

What does the "masked" notRestoredReason mean?

It means a cross-origin iframe blocked bfcache, or the browser withheld the specific reason for implementation privacy. You'll see the frame URL but no detail on which listener or connection was responsible. Fix it by contacting the embed provider or wrapping the embed in a facade so it only loads on user interaction.

Nadia El-Sayed
About the Author Nadia El-Sayed

Core Web Vitals specialist focused on real-user monitoring. Believes synthetic-only perf testing is a comforting lie.