Why every navigation deserves a second look

Back/forward cache (bfcache) is a browser optimization that makes back and forward navigation instantaneous. For users on slow networks or low-end devices, the difference is often dramatic. But bfcache is not something developers can take for granted: pages must be eligible, and the features you use can quietly disqualify them. Understanding how bfcache works and how to keep your pages compatible is the difference between a navigation that feels instant and one that forces a full reload.

All major browsers support bfcache. Chrome has included it since version 96, and it has long been available in Firefox and Safari.

What bfcache actually stores

When a user navigates away, the browser can defer destroying the page. Instead of tearing down the JavaScript context, it freezes execution and keeps a snapshot of the entire page in memory—including the JavaScript heap. If the user returns via back or forward navigation, the page is made visible again and execution resumes, producing near-instant loading.

This is fundamentally different from the HTTP cache. The HTTP cache stores only the responses for previously made requests, and it is rare that every resource a page needs is served from it. A bfcache restore, by contrast, restores the complete page state, so it always beats even the most aggressively HTTP-cached navigation.

Without bfcache enabled A new request is initiated to load the previous page, and, depending on how well that page has been optimized for repeat visits, the browser might have to re-download, re-parse, and re-execute some (or all) of resources it just downloaded.
With bfcache enabled Loading the previous page is essentially instant, because the entire page can be restored from memory, without having to go to the network at all.
Using bfcache makes pages load much more quickly during back and forward navigation.

bfcache also saves data because no resources need to be re-downloaded. That matters more than it might seem: browser usage data shows roughly 1 in 10 desktop navigations and 1 in 5 mobile navigations are back or forward navigations. Eliminating the load for those navigations translates into billions of pages per day that no longer need to transfer data or wait for resources.

What happens when a page is frozen

Freezing a page introduces complications. The browser must decide what to do with pending work when the page is frozen and then unfrozen later. In practice, browsers pause pending timers, unresolved promises, and almost all tasks in the JavaScript task queues. When a page is restored from bfcache, those tasks resume.

For timers and promises this is low risk. But some operations have cross-page effects. An IndexedDB transaction, for example, can affect other tabs open on the same origin, because multiple tabs can access the same databases simultaneously. Browsers will generally avoid caching a page that is in the middle of an IndexedDB transaction or is using APIs that could impact other pages. The practical implication: certain APIs and states make a page ineligible for bfcache, and you need to know what those are to keep your pages cacheable.

iframes and single-page apps

Embedded iframes complicate bfcache in two ways. First, iframe content itself is not independently cached. Navigating within an iframe to another URL does not put the previous iframe content in bfcache. When the user goes back, the browser performs a back navigation within the iframe, but that navigation does not use bfcache. However, if the main frame is restored from bfcache, any embedded iframes are restored to the state they were in when the page entered bfcache.

Second, iframes can block the main frame from using bfcache. If an embedded iframe uses APIs that disqualify a page from caching, the main frame is blocked as well. You can prevent this by using Permissions Policy or sandbox attributes on the iframe.

Single-page apps have a different relationship with bfcache. Because bfcache applies only to browser-managed navigations, it does not apply to soft navigations within an SPA. But it still helps when a user navigates back to an SPA, because the app can be restored from bfcache rather than initialized from scratch.

Observing bfcache in your code

Browsers apply bfcache automatically, but you need to detect when it happens—both to measure its impact and to ensure your pages are eligible. The primary events are the page transition events pageshow and pagehide, both supported broadly across browsers. The Page Lifecycle events freeze and resume also fire on bfcache entry and restore, but they are only supported in Chromium-based browsers, and they also fire in other situations, such as when a background tab is frozen to save CPU.

Detecting a bfcache restore

The pageshow event fires right after the load event on initial page load, and it also fires whenever a page is restored from bfcache. Its persisted property tells you which case you are in: true means a bfcache restore.

window.addEventListener('pageshow', (event) => {
  if (event.persisted) {
    console.log('This page was restored from the bfcache.');
  } else {
    console.log('This page was loaded normally.');
  }
});

In browsers that support the Page Lifecycle API, the resume event fires before pageshow on a bfcache restore, and also when a user revisits a frozen background tab. If you need to update state after a freeze, use resume. If you want to measure bfcache hit rate, use pageshow. In some cases you need both.

Detecting entry into the cache

The pagehide event fires when a page unloads or when the browser attempts to put it into bfcache. It also exposes a persisted property. If the value is false, the page is definitely not entering bfcache. But a true value is only an intention, not a guarantee. The browser may still discard the page for other reasons.

window.addEventListener('pagehide', (event) => {
  if (event.persisted) {
    console.log('This page *might* be entering the bfcache.');
  } else {
    console.log('This page will unload normally and be discarded.');
  }
});

Similarly, the freeze event fires after pagehide when persisted is true, but again it only indicates the browser intends to cache the page. Final eligibility depends on other factors that can still force the page to be discarded.

Making Your Pages Eligible for Back/Forward Cache

Not every page ends up in the back/forward cache (bfcache), and those that do don't stay there permanently. Understanding what qualifies a page for caching—and what disqualifies it—is key to maximizing your cache-hit rate. The following practices will help ensure the browser can cache your pages whenever a user navigates away.

Drop the unload Event

The single most impactful change you can make is to stop using the unload event entirely. The event predates bfcache, and many existing pages assume that once unload fires, the page ceases to exist. That assumption is dated, as the event doesn't always fire when a user navigates away. This puts browsers in a difficult spot: caching pages with an unload listener improves performance but risks breaking functionality.

Desktop browsers handle this differently. Chrome and Firefox disqualify pages that register an unload listener, which is safe but excludes a large portion of the web. Safari attempts to cache some pages with the listener but won't fire unload on navigation—making the event unreliable. On mobile, Chrome and Safari will try caching these pages because unload has consistently been unreliable there anyway. Firefox excludes them, except on iOS, where all browsers use WebKit and behave like Safari.

Use the pagehide event instead. It fires in all situations where unload would, and it also fires when a page goes into the bfcache. Lighthouse includes a no-unload-listeners audit that warns about any unload handlers, including those added by third-party libraries. Chrome is also working toward deprecating the event entirely.

Block unload handlers with Permission Policy

Sites that don't rely on unload can prevent handlers from being added via a Permission Policy. This stops third parties or browser extensions from inadvertently registering unload listeners and making the page ineligible for bfcache.

Permissions-Policy: unload=()

Add beforeunload Listeners Only When Needed

The beforeunload event no longer disqualifies pages from bfcache in modern browsers, though it did previously and remains unreliable. It has legitimate uses—such as warning users about unsaved changes—but only add the listener when a user has unsaved changes and remove it once those changes are saved.

Avoid adding it unconditionally:

window.addEventListener('beforeunload', (event) => {
  if (pageHasUnsavedChanges()) {
    event.preventDefault();
    return event.returnValue = 'Are you sure you want to exit?';
  }
});

Instead, conditionally attach and detach it:

function beforeUnloadListener(event) {
  event.preventDefault();
  return event.returnValue = 'Are you sure you want to exit?';
};

// A function that invokes a callback when the page has unsaved changes.
onPageHasUnsavedChanges(() => {
  window.addEventListener('beforeunload', beforeUnloadListener);
});

// A function that invokes a callback when the page's unsaved changes are resolved.
onAllChangesSaved(() => {
  window.removeEventListener('beforeunload', beforeUnloadListener);
});

Limit Cache-Control: no-store

The Cache-Control: no-store HTTP header tells browsers to avoid storing the response in any HTTP cache. It's appropriate for pages with sensitive user data, like logged-in views. But because bfcache is technically not an HTTP cache, browsers have historically still chosen not to cache pages that set this header—even though work is underway to change that behavior in Chrome while preserving privacy.

If your page doesn't contain truly sensitive information, prefer Cache-Control: no-cache or Cache-Control: max-age=0. These directives force revalidation before serving and don't hurt bfcache eligibility. Keep in mind that a page restored from bfcache comes from memory, not the HTTP cache, so revalidation headers won't apply. This is usually acceptable because restores are instant and pages rarely sit in bfcache long enough to become stale. For content that changes constantly, refresh it after restore using the pageshow event.

Refresh State After a Bfcache Restore

If your site maintains user state—especially sensitive data—make sure to update or clear it when a user returns via bfcache. For instance, if someone updates their shopping cart and then presses back to the checkout page, a cached copy could display outdated totals. Similarly, a user signing out on a shared computer could leave private information exposed when the next person hits the back button.

Always update the page when pageshow fires and event.persisted is true:

window.addEventListener('pageshow', (event) => {
  if (event.persisted) {
    // Do any checks and updates to the page
  }
});

Sometimes you need a full reload instead. Check for a site-specific cookie in the pageshow event and reload if it's missing:

window.addEventListener('pageshow', (event) => {
  if (event.persisted && !document.cookie.match(/my-cookie)) {
    // Force a reload if the user has logged out.
    location.reload();
  }
});

A reload preserves the history entry and allows forward navigation, but in some cases a redirect is more appropriate.

Refresh Ads Without Giving Up Bfcache

Publishers often want fresh ads on every back/forward navigation. Making the page ineligible for bfcache via Cache-Control: no-store is a poor tradeoff. Better to keep the page cacheable and refresh only the ads. Google Publisher Tag (GPT), for instance, automatically refreshes visible ad slots after a bfcache restore. For libraries that don't, detect the restore via pageshow and trigger an ad refresh manually.

Avoid window.opener References

Opening a page with window.open() or target=_blank without rel="noopener" gives the opener a reference to the new window. Beyond being a security risk, a non-null window.opener reference makes the page unsafe for bfcache, since the browser can't risk breaking pages that access it.

Use rel="noopener" wherever possible. It's now the default in all modern browsers. If your site needs to control an opened window via window.postMessage() or direct references, neither the opener nor the opened page can be cached.

Close Open Connections Before Navigation

When a page is stored in bfcache, all scheduled JavaScript tasks are paused until the page is restored. Tasks that only touch DOM APIs or page-local state aren't a problem. But tasks tied to APIs shared across tabs—like IndexedDB, Web Locks, or WebSockets—can stall code in other tabs while paused.

As a result, some browsers keep pages out of bfcache if they have:

  • An open IndexedDB connection
  • In-progress fetch() or XMLHttpRequest requests
  • An open WebSocket or WebRTC connection (Chrome as of version 149 and Safari don't block on WebSockets, but other browsers do)

If your page uses these APIs, close connections and disconnect observers during the pagehide or freeze event. Reopen them on restore via pageshow or resume, or rely on the error and close events to auto-reconnect. Be careful not to open duplicate connections if you use multiple events.

The example below closes an IndexedDB connection in pagehide to keep the page bfcache-eligible:

let dbPromise;
function openDB() {
  if (!dbPromise) {
    dbPromise = new Promise((resolve, reject) => {
      const req = indexedDB.open('my-db', 1);
      req.onupgradeneeded = () => req.result.createObjectStore('keyval');
      req.onerror = () => reject(req.error);
      req.onsuccess = () => resolve(req.result);
    });
  }
  return dbPromise;
}

// Close the connection to the database when the user leaves.
window.addEventListener('pagehide', () => {
  if (dbPromise) {
    dbPromise.then(db => db.close());
    dbPromise = null;
  }
});

// Open the connection when the page is loaded or restored from bfcache.
window.addEventListener('pageshow', () => openDB());

Testing Cachability with DevTools

Chrome DevTools provides a dedicated tool to verify bfcache eligibility and highlight blocking issues. To run it:

  1. Navigate to the page in Chrome.
  2. Open DevTools and select Application > Back-forward Cache.
  3. Click Run Test. DevTools will navigate away and back to check if the page can be restored.
Back-forward cache panel in DevTools
The Back-forward Cache panel in DevTools.

A successful test shows "Restored from back-forward cache".

DevTools reporting a page was successfully restored from bfcache
A successfully restored page.

If the test fails, the panel lists the reason. Issues you can fix are marked as Actionable.

DevTools reporting failure to restore a page from bfcache
A failed bfcache test with an actionable result.

In this example, an unload listener makes the page ineligible. Replace it with pagehide:

window.addEventListener('pagehide', ...);

Avoid the original pattern:

window.addEventListener('unload', ...);

Lighthouse 10.0 also includes a bfcache audit that performs a similar test automatically.

Why bfcache skews your analytics

As Chrome rolls out bfcache to more users, analytics dashboards may show fewer reported pageviews. This undercounting is already happening in other browsers that support bfcache, since most analytics libraries don't treat bfcache restores as new pageviews.

To capture restores as pageviews, listen for the pageshow event and inspect the persisted property. The pattern below shows an implementation for Google Analytics; other tools typically follow similar logic:

// Send a pageview when the page is first loaded.
// This happens by default just by loading gtag
gtag('config', 'TAG_ID');

window.addEventListener('pageshow', (event) => {
  // Send another pageview if the page is restored from bfcache.
  if (event.persisted) {
    gtag('event', 'page_view');
  }
});

Computing your bfcache hit ratio

Tracking how often bfcache is actually used helps identify pages that block it. Measure the navigation type when a page loads:

// Send a navigation_type when the page is first loaded.
// To do this disable the default pageview so you can manually send it
// supplemented with the additional detail.
gtag('config', 'TAG_ID', { send_page_view: false });
gtag('event', 'page_view', {
   'navigation_type': performance.getEntriesByType('navigation')[0].type;
});

window.addEventListener('pageshow', (event) => {
  if (event.persisted) {
    // Send another pageview if the page is restored from bfcache.
    gtag('event', 'page_view', {
      'navigation_type': 'back_forward_cache';
    });
  }
});

Divide the count of back_forward_cache navigations by the total of back_forward plus back_forward_cache navigations to get the hit ratio.

Expect the ratio to fall well below 100%. Many scenarios outside your control skip bfcache entirely:

  • the user quits and restarts the browser
  • the user duplicates a tab
  • the user closes and reopens a tab

Some browsers preserve the original navigation type in these cases, so they may still report back_forward. Additionally, browsers discard bfcache entries after a period to free memory.

Still, monitoring the ratio helps detect pages that consistently opt themselves out. Chrome provides the NotRestoredReasons API to expose the specific blocking reasons. Navigation types, including bfcache counts, are also now available in CrUX without custom instrumentation.

Performance measurement distortions

Bfcache also skews field performance data, especially page-load metrics. Restores replace what would have been full page loads, shrinking the total count. Crucially, the lost page loads tend to be among the fastest in the dataset: back and forward navigations are repeat visits, and repeat loads benefit from HTTP caching. The result is a slower-looking distribution, even though real user experience improves.

Two strategies address this. For non-user-centric metrics like Time to First Byte (TTFB), annotate every metric with its navigation type (navigate, reload, back_forward, prerender) and monitor within those buckets. For user-centric metrics such as the Core Web Vitals, report a value that matches what the user actually experiences.

Core Web Vitals after a restore

Since users perceive bfcache restores as fast navigations, the Core Web Vitals should reflect that. Reporting tools, including the Chrome User Experience Report, already count bfcache restores as separate visits. There are no dedicated APIs for post-restore metrics, but existing web APIs can approximate them:

  • Largest Contentful Paint (LCP): use the delta between the pageshow timestamp and the next painted frame. Since all elements paint simultaneously on a restore, LCP and First Contentful Paint are equal.
  • Interaction to Next Paint (INP): keep the current Performance Observer but reset the stored INP value to 0.
  • Cumulative Layout Shift (CLS): keep the existing Performance Observer but reset the stored CLS value to 0.

Detail on per-metric effects is available in the individual Core Web Vitals guides. The web-vitals JavaScript library already handles bfcache restores in the metrics it reports, and its pull request history shows a concrete implementation.

Further reading