How to Fix: Browser back button on dynamic page causing wrong render of page on IOS (IPhone).

7 min read

Safari on iPhone shows the wrong dynamic page after tapping the browser back button because the page is being restored from cache with stale client state.

On iOS Safari, navigating back through dynamic routes can reuse a previously rendered view from the browser’s back-forward cache instead of rebuilding the page from the latest route state. The result is a mismatched screen: the URL changes, but the UI still shows content from another page, or lifecycle code does not run as expected. In apps with client-side routing, dynamic segments, and stateful components, this bug usually appears when page state is not reset on route change or when Safari restores a frozen DOM tree.

Understanding the Root Cause

This issue typically happens because iOS Safari handles browser history differently from desktop browsers. When a user taps the back button, Safari may restore the previous page from BFCache instead of performing a full reload. That behavior is fast, but it can break assumptions in apps that depend on route-change effects to fetch data, reset state, or remount dynamic views.

In a dynamic page flow such as Home, Countdown, Result, and another detail page, the visible screen may be controlled by a combination of:

  • Client-side router state
  • Dynamic route params
  • Cached component state
  • Effects that only run on first mount

If the page component stays mounted, or Safari restores an older DOM snapshot, then one of these failures occurs:

  • The route param changes but the component does not remount.
  • The page uses stale React state from the previous route.
  • Data fetching is tied to an effect with the wrong dependency list.
  • Navigation listeners do not react to Safari’s pageshow event after BFCache restore.

In practice, the wrong render often comes from a dynamic page component that expects a fresh mount on every history navigation, while Safari instead restores the previous UI tree exactly as it was.

Step-by-Step Solution

The safest fix is to combine three protections:

  1. Force dynamic page content to react to the current route key.
  2. Reset local state when the route or slug changes.
  3. Handle Safari BFCache restores using the pageshow event.

1. Remount the dynamic page when the route changes

If your page is driven by a dynamic segment such as an id, slug, or step value, give the rendered page a stable key based on the current route. This forces React to create a fresh component instance when the URL changes.

import { useRouter } from 'next/router'
export default function DynamicPageWrapper() {
  const router = useRouter()
  const routeKey = router.asPath

  return <DynamicPage key={routeKey} />
}

If the bug is inside a route-specific child component, key that child instead of the whole layout.

function DynamicPage() {
  const router = useRouter()

  return (
    <section>
      <PageContent key={router.asPath} />
    </section>
  )
}

2. Reset state whenever the route parameter changes

A common source of the wrong render is state that survives route changes. If your screen stores derived UI state such as selected tab, countdown mode, loaded entity, or animation progress, reset it when the route param changes.

import { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
export default function DynamicPage() {
  const router = useRouter()
  const { id } = router.query
  const [pageData, setPageData] = useState(null)
  const [isReady, setIsReady] = useState(false)

  useEffect(() => {
    setPageData(null)
    setIsReady(false)

    if (!id) return

    const load = async () => {
      const res = await fetch(`/api/page/${id}`)
      const data = await res.json()
      setPageData(data)
      setIsReady(true)
    }

    load()
  }, [id])

  if (!isReady) return <div>Loading...</div>

  return <div>{pageData?.title}</div>
}

The important part is the dependency array. If the effect depends on id, slug, or router.asPath, it will rerun when the user returns via browser history.

3. Detect BFCache restore on iPhone Safari

Safari may restore a page from cache without running the same navigation flow as a fresh route change. Add a pageshow listener and refresh route-dependent state when the page is restored.

import { useEffect } from 'react'
import { useRouter } from 'next/router'
export function useSafariPageRestore() {
  const router = useRouter()

  useEffect(() => {
    const handlePageShow = (event) => {
      if (event.persisted) {
        router.replace(router.asPath)
      }
    }

    window.addEventListener('pageshow', handlePageShow)
    return () => window.removeEventListener('pageshow', handlePageShow)
  }, [router])
}

Use the hook in the affected page:

export default function DynamicPage() {
  useSafariPageRestore()

  return <div>...</div>
}

If a full route replacement is too aggressive, use the event only to clear stale state and refetch the current page data.

useEffect(() => {
  const handlePageShow = (event) => {
    if (event.persisted) {
      setPageData(null)
      refetchCurrentRoute()
    }
  }

  window.addEventListener('pageshow', handlePageShow)
  return () => window.removeEventListener('pageshow', handlePageShow)
}, [])

4. Make sure route-derived data is not initialized only once

A subtle bug appears when local state is initialized from props or router params but never updated afterward.

const [currentId] = useState(router.query.id)

This is unsafe for dynamic routes because currentId will keep the original value. Replace that pattern with direct route usage or synchronized state.

const currentId = router.query.id

Or:

const [currentId, setCurrentId] = useState(null)
useEffect(() => {
  if (router.query.id) {
    setCurrentId(router.query.id)
  }
}, [router.query.id])

5. Verify that transitions, timers, and media state are cleaned up

If the dynamic page includes countdowns, intervals, carousels, or media playback, Safari history restore can preserve those resources in an inconsistent state. Clean them up on unmount and rebuild them on route change.

useEffect(() => {
  const timer = setInterval(() => {
    // countdown logic
  }, 1000)

  return () => clearInterval(timer)
}, [router.asPath])

6. If needed, disable shallow assumptions in navigation

If your app navigates with shallow routing or assumes the same component can safely represent multiple dynamic pages, test whether the issue disappears when each route triggers a complete state refresh.

router.push(`/countdown/${id}`, undefined, { shallow: false })

This does not fix BFCache by itself, but it reduces state leakage between dynamic pages.

For most apps, this combined structure is the most reliable:

import { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
function usePageRestore(onRestore) {
  useEffect(() => {
    const handlePageShow = (event) => {
      if (event.persisted) onRestore()
    }

    window.addEventListener('pageshow', handlePageShow)
    return () => window.removeEventListener('pageshow', handlePageShow)
  }, [onRestore])
}
function DynamicScreen() {
  const router = useRouter()
  const [data, setData] = useState(null)
  const routeKey = router.asPath
  const itemId = router.query.id
  const loadData = async () => {
    if (!itemId) return
    const res = await fetch(`/api/items/${itemId}`)
    const json = await res.json()
    setData(json)
  }
  useEffect(() => {
    setData(null)
    loadData()
  }, [itemId])
  usePageRestore(() => {
    setData(null)
    loadData()
  })
  if (!data) return <div>Loading...</div>
  return <div key={routeKey}>{data.title}</div>
}

This pattern addresses both route reuse and Safari cache restore.

If you want to inspect the affected project, review the repository here: reproduction code.

Common Edge Cases

  • State stored in a parent layout: Even if the child page remounts, stale state in a shared layout can still render the wrong content. Check wrappers, providers, and persistent shells.
  • Query params arriving late: In client-side routing, router.query may be empty on first render. Guard your effects so they do not fetch with undefined params.
  • Animation libraries preserving DOM state: Transition libraries can keep nodes alive across navigations. Ensure exit and enter flows fully reset on route changes.
  • Shallow routing: If shallow navigation is enabled, data-fetching assumptions may break because the page does not fully rerender.
  • Global stores: Zustand, Redux, Context, or custom stores may keep values from the previous route. Reset route-scoped store slices when the dynamic key changes.
  • SSR and hydration mismatch: If the server rendered one route and the restored client state points to another, Safari can expose UI inconsistencies that look like a back-button bug.
  • Timers and media: Countdown pages are especially vulnerable because intervals, audio, and progress values may continue from a previous screen unless explicitly cleaned up.

FAQ

Why does this bug happen mainly on iPhone Safari?

Because iOS Safari aggressively uses the browser’s back-forward cache. Instead of rebuilding the page from scratch, it restores a prior DOM and JavaScript state snapshot, which can conflict with dynamic route logic.

Is forcing a remount with a key enough to fix it?

Not always. A route-based key helps when the issue comes from component reuse, but Safari BFCache restore can still return stale state. In most cases, you also need a pageshow handler and proper state reset logic.

Should I force a full page reload on back navigation?

Only as a last resort. A full reload is heavier and degrades UX. Prefer targeted fixes: remount dynamic views, refetch using the active route param, and respond to event.persisted on pageshow. Use reload only if the page contains highly stateful behavior that cannot be safely restored.

Leave a Reply

Your email address will not be published. Required fields are marked *