Next.js bfcache Not Restoring Page on Back Navigation Fix

Next.js page failing back/forward cache and re-rendering on browser back? Most causes are unload listeners, open WebSockets, or Cache-Control no-store.
PerformanceCore Web VitalsNext.js
May 15, 20265 min read945 words

The Problem

I ran into this on a client project running a high-traffic content site on Next.js 16. Their CrUX report showed Next Paint after Back/Forward (NPBF) at 1.4s p75 on mobile, dragging their overall score down. Lighthouse flagged "Page prevented back/forward cache restoration" with a list of reasons that read like a wall of internal Chrome strings: MainResourceHasCacheControlNoStore, UnloadHandler, WebSocketPresent. The page itself was a static blog article. Nothing on it should have prevented bfcache.

If your Next.js page renders cleanly on first load but feels like a fresh navigation when the user hits the browser back button, you are missing the bfcache restore. The browser is rebuilding the page from scratch instead of restoring it from memory.

Why It Happens

The back/forward cache (bfcache) is a browser-level snapshot of the page's full DOM, JS heap, and scroll position. When a user navigates away and then back, an eligible page restores in under 100ms. An ineligible page is rebuilt with a full network round trip and a fresh React hydration cycle.

Three things in a typical Next.js app push you out of bfcache without you noticing:

  1. Cache-Control: no-store on the document response. Any route that opts into dynamic rendering (a Server Component reading cookies, a route segment with dynamic = 'force-dynamic', a cookies() call inside a layout) sets Cache-Control: no-store on the HTML response by default. Chrome refuses to bfcache pages with no-store in the response. The page renders fine, the cache miss is silent, NPBF tanks.
  2. Lingering unload event listeners. Analytics snippets, session replay tools, and a surprising number of "track when user leaves" libraries register a window.addEventListener('unload', ...). Chrome treats any unload listener as a hard bfcache disqualifier, even if the listener is empty. beforeunload is fine if it does not actually prevent navigation; unload is not.
  3. Open WebSocket or EventSource connections. Live-update widgets (chat, presence, dashboards) hold open connections that the browser cannot freeze. The page is ineligible until the connection closes. Most Next.js apps inherit this from a Pusher, Ably, or Liveblocks integration that opens a socket on every page mount and never closes it on pagehide.

The Chrome bfcache eligibility list is the source of truth, and it gets longer every Chrome release.

The Fix

You need to do three things: identify which disqualifier hit your page, eliminate it at the source, and re-test from a real device against CrUX-style conditions.

Step 1: Read the disqualification reason from the Performance panel. In Chrome DevTools open the Application panel, click "Back/forward cache" in the left rail, and use the navigate-then-back button. The panel reports the verdict and lists every reason:

  • MainResourceHasCacheControlNoStore → response header
  • UnloadHandlerwindow.unload listener
  • WebSocketPresent → open socket
  • OutstandingNetworkRequestFetch → in-flight fetch on unload

For Next.js, the first is the dominant cause on dynamic routes.

Step 2: Stop sending Cache-Control: no-store on dynamic routes you want bfcached. A blog page that reads cookies for a personalised banner does not need to be uncacheable at the document level. Override the header in next.config.ts:

import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        source: '/blog/:slug*',
        headers: [
          { key: 'Cache-Control', value: 'private, max-age=0, must-revalidate' },
        ],
      },
    ]
  },
}

export default nextConfig

private, max-age=0, must-revalidate keeps the response uncached at the CDN but does not include no-store, so Chrome can bfcache the rendered DOM. The trade-off is safe: Chrome restores from a per-tab snapshot, not a CDN edge cache, so you do not leak personalised content across users.

Step 3: Replace unload with pagehide and close sockets in it. Audit every window.addEventListener('unload', ...) in your codebase and the bundles your analytics vendors ship. Switch them to pagehide, which is bfcache-compatible:

'use client'
import { useEffect } from 'react'

export function PresenceClient({ channel }: { channel: WebSocket }) {
  useEffect(() => {
    function onHide(event: PageTransitionEvent) {
      if (event.persisted) return
      channel.close(1000, 'pagehide')
    }
    window.addEventListener('pagehide', onHide)
    return () => window.removeEventListener('pagehide', onHide)
  }, [channel])

  return null
}

The event.persisted check is the key. When the page is being put into bfcache, event.persisted is true and you should not close the socket because the page is coming back. When the user navigates away for real, it is false and you tear down. Without the check you close the socket every time, defeating the cache restore.

Step 4: Re-test from the field. DevTools reflects the current page only. Query CrUX BigQuery for navigate_back_forward_cache on your origin and watch the share of cached back navigations rise over two CrUX windows.

The Lesson

bfcache is the cheapest win on the back navigation path because the work is done by the browser for free, but only if you stop sending the headers and listeners that disqualify the page. Cache-Control: no-store, unload handlers, and unclosed sockets cover most Next.js sites. Fix them and NPBF, INP-on-restore, and LCP-on-restore all improve in one CrUX window.

If your Next.js site is failing bfcache and you want a sweep of every disqualifier across the route tree, that is the kind of work I do. See my services. For another field-data pattern, see INP field data worse than lab fix.

Back to blogStart a project