← All articles

[ Blog ]

July 11, 2026

10 min read

Category: Insights

Why Your WebGL Hero Disappears on Browser Back

Your Next.js WebGL background is fine on first load and Link nav, then blank after the browser Back button? Here's the popstate root cause and the R3F rebuild fix.

WebGLReact Three FiberNext.jsDebuggingGSAP
Why Your WebGL Hero Disappears on Browser Back

Here's a bug that ships to production more often than it should: your WebGL hero disappears on browser back. First load is perfect. Client-side <Link> navigation works. Forward works. Then a user hits the browser's native Back button, lands back on your homepage, and the canvas is blank — the background is gone, FPS is normal, and there's not a single console error to chase. I spent this week tracking exactly this on a site whose hero is built by an R3F preloader, so let me save you the afternoon.

The short version: a scene that's initialized once by a boot preloader has no rebuild path when the user returns to the page that owns it. In-app navigation and popstate/traverse take different code paths, and almost everyone's transition code handles the first and forgets the second.

The symptom, precisely

Reproduce it in this exact order — the ordering is the whole tell:

  1. Hard-load the homepage. WebGL hero renders. Good.
  2. Click a <Link> to /blog. Navigate away. Good.
  3. Press the browser Back button (not an in-app back link). You're on / again.
  4. The canvas is blank. No hero. No error. requestAnimationFrame is still running, FPS is fine — there's just nothing in the scene.

If step 2 is an in-app link and step 3 is the native Back/Forward button, that asymmetry is the fingerprint. The transition system was tested by clicking around; it was never tested with the browser chrome. This is the single most common reason a next.js webgl background is gone after the back button.

Root cause: built once, never rebuilt

Most premium sites build the hero scene inside a boot preloader — the intro that runs on the very first load, samples an image or decodes a texture, assembles the particle field, then plays its reveal. To keep soft navigations cheap, that scene is deliberately left behind on nav-away. The pattern usually looks like this:

// PreloaderScene.tsx — the naive version
'use client'
import { usePathname } from 'next/navigation'
import { useEffect, useRef, useState } from 'react'

export function PreloaderScene({ initialPath }: { initialPath: string }) {
  const pathname = usePathname()
  const [done, setDone] = useState(false)

  useEffect(() => {
    // "Left it behind on soft nav" — unmount once we leave the owner route.
    if (pathname !== initialPath) setDone(true)
  }, [pathname, initialPath])

  if (done) return null // ← the trap

  return <HeroCanvas />
}

Read the effect carefully. It only ever sets done to true. The moment pathname !== initialPath, the scene tears down — correct, that's the intended optimization. But there is no branch that flips done back to false when you return to initialPath. Once the flag latches, if (done) return null keeps the scene unmounted forever. First navigation away kills it permanently, and the most visible trigger is the native Back button because that's the natural way a user re-enters the origin route.

It's not a WebGL bug at all. The GL context, the shaders, the useFrame loop — all fine. The component that hosts them simply never re-mounts. Verify this yourself: log inside HeroCanvas render. On return, it never runs. Case closed.

Why the browser Back button is the case people miss

There are three distinct ways to land back on the owner route, and they do not all flow through your <Link> handler:

Entry pathMechanismFires popstate?Navigation API navigationType
Click an in-app <Link>App Router pushNopush
Programmatic router.back()History traverseYestraverse
Native Back / Forward buttonHistory traverseYestraverse
Full reloadDocument loadNo (fresh doc)reload

Custom preloader, transition, and SPA-nav code routinely intercepts the click — it wraps <Link>, it animates on router.push. But the browser's Back/Forward buttons don't go through any of that. They emit a popstate event, and in modern browsers a navigate event with navigationType: 'traverse'. If your rebuild logic lives only in the Link wrapper, traverse silently skips it. That's exactly why the hero survives forward-nav but dies on Back.

usePathname() does update on traverse (App Router keeps it in sync), so a pathname effect is the right hook — you just have to make it handle the return, not only the departure.

The fix: an explicit rebuild-on-return path

The mental model: the boot preloader owns the first render, and a pathname effect owns every re-entry. On return you don't replay the whole intro flight — you re-establish the scene and snap straight to its landed state.

// PreloaderScene.tsx — the corrected version
'use client'
import { usePathname } from 'next/navigation'
import { useEffect, useRef, useState } from 'react'

export function PreloaderScene({ initialPath }: { initialPath: string }) {
  const pathname = usePathname()
  const [mounted, setMounted] = useState(true)
  const bootedRef = useRef(false) // gate: don't double-run on first render

  useEffect(() => {
    // First effect run belongs to the boot preloader — skip it here.
    if (!bootedRef.current) {
      bootedRef.current = true
      return
    }

    if (pathname !== initialPath) {
      // Leaving the owner route → leave the scene behind (the optimization).
      setMounted(false)
    } else {
      // Returning to the owner route → rebuild explicitly.
      reestablishScene()
      setMounted(true)
    }
  }, [pathname, initialPath])

  if (!mounted) return null

  return <HeroCanvas skipIntro={bootedRef.current} />
}

The bootedRef gate matters: without it the effect runs on the initial render too and you'd double-fire the setup the boot already did (a flash, or two competing timelines). Skip the first pass; let the boot own it.

reestablishScene() is where the real work lives. It re-does whatever the boot did to produce the scene, then jumps to the end state instead of animating from zero:

// reestablishScene.ts
import gsap from 'gsap'

export function reestablishScene() {
  // 1) Respect the SAME guards the boot used — don't rebuild what boot skipped.
  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
  const isMobile = window.matchMedia('(max-width: 768px)').matches
  const supportsWebGL = !!document.createElement('canvas').getContext('webgl2')
  if (isMobile || !supportsWebGL) return // boot never built it here → nothing to restore

  // 2) Rebuild the scene's INPUTS — re-sample / re-decode the source the boot consumed.
  //    (A decoded texture / sampled image tone may have been GC'd with the old mount.)
  rebuildSceneInputs()

  // 3) Snap to the persisted landed state — skip the intro flight.
  const target = readPersistedHeroState() // scroll-independent "settled" pose
  applyHeroState(target, { immediate: true })

  // 4) Quick fade-in so the swap isn't a hard pop.
  gsap.killTweensOf('.hero-canvas')
  gsap.fromTo(
    '.hero-canvas',
    { autoAlpha: reduceMotion ? 1 : 0 },
    { autoAlpha: 1, duration: reduceMotion ? 0 : 0.4, ease: 'power2.out' },
  )
}

Four rules make this robust:

  • Re-establish inputs, don't assume they survived. When the old mount was torn down, decoded textures and sampled image data can be garbage-collected. On return you often have to re-decode/re-sample — restoring only React state gives you a scene with empty buffers (blank again).
  • Snap to the landed state, skip the flight. The intro's job is a first-impression reveal. On a Back-button return the user has already seen it; replaying a multi-second assembly feels broken. Jump to the settled pose, then a 400 ms fade.
  • Honor the boot's guards. If the boot skipped WebGL on mobile or under reduced-motion, reestablishScene() must skip too — otherwise you conjure a scene on a device that was intentionally spared it.
  • Gate the initial render. The bootedRef guard keeps boot and return from both firing on load.

Catch the traverse the pathname effect can't see

usePathname() covers App Router transitions, but if you have imperative logic tied to real browser history — a scroll snapshot, an audio state, a Lenis position — subscribe to the traverse directly. This is the belt-and-braces layer:

// useTraverseRebuild.ts
import { useEffect } from 'react'

export function useTraverseRebuild(onTraverseReturn: () => void, ownerPath: string) {
  useEffect(() => {
    // Modern browsers: the Navigation API distinguishes traverse cleanly.
    const nav = (window as unknown as { navigation?: EventTarget }).navigation
    if (nav) {
      const handler = (e: Event) => {
        const ev = e as unknown as { navigationType?: string; destination?: { url: string } }
        if (ev.navigationType === 'traverse') {
          const url = ev.destination ? new URL(ev.destination.url) : null
          if (url?.pathname === ownerPath) onTraverseReturn()
        }
      }
      nav.addEventListener('navigate', handler)
      return () => nav.removeEventListener('navigate', handler)
    }

    // Fallback (Safari/Firefox without Navigation API): popstate.
    const onPop = () => {
      if (window.location.pathname === ownerPath) onTraverseReturn()
    }
    window.addEventListener('popstate', onPop)
    return () => window.removeEventListener('popstate', onPop)
  }, [onTraverseReturn, ownerPath])
}

The Navigation API gives you a clean traverse signal in Chromium; popstate is the universal fallback everywhere else. Between the pathname effect and this hook, every re-entry path is covered.

The real lesson: always test the native Back button

The bug wasn't the WebGL. It was a testing blind spot. Custom preloaders, page transitions, and SPA nav code get exercised by clicking around the site — Link clicks and forward navigation — and pass. The browser's native Back/Forward buttons drive a different mechanism (popstate / traverse) that click-testing never touches. Any component or scene initialized once on boot needs an explicit rebuild-on-return path, and the only way you'll catch its absence is to actually press Back.

Two habits that keep this out of production:

  • Add Back/Forward to your manual QA script. Load → Link away → native Back → is everything alive? Then Forward. It takes ten seconds and catches an entire class of transition bugs.
  • Verify WebGL in a real, foregrounded tab. An automation/headless tab that's backgrounded reports document.visibilityState === 'hidden', which pauses requestAnimationFrame — R3F then looks "hung" for reasons that have nothing to do with your code. Drive it with a visible Playwright page where RAF actually runs, and confirm with a screenshot rather than assuming the code is right.

If this reads like the kind of debugging your project needs — the WebGL runs fine but something about navigation, transitions, or lifecycle is subtly broken — that's exactly the work I do. See the creative development services, and browse live WebGL work in the projects archive.

For the deeper foundations behind scenes like this, see React Three Fiber for beginners and Three.js performance optimization. For a sibling debugging story, read why scroll-scrubbed video stutters and how to fix the keyframes, and to keep re-entries cheap, Core Web Vitals for animation-heavy sites.

FAQ

Why does my R3F canvas go blank only after the browser Back button?

Because Link navigation and the native Back button take different code paths. Your transition logic likely rebuilds on <Link>/push but not on popstate/traverse. A scene built once at boot with no rebuild-on-return branch stays unmounted after the first departure, and Back is the natural way users re-enter the route.

Why is there no console error when the WebGL background is gone?

Because nothing threw. The GL context and render loop are healthy — the host component simply never re-mounted, so there's no scene to draw. A blank-but-error-free canvas almost always means a lifecycle/mount problem, not a shader or WebGL bug.

Does usePathname() update on the browser Back button in Next.js?

Yes. Next.js App Router keeps usePathname() in sync on history traverse, so a pathname effect is a valid hook to detect a return. You just have to handle the return branch (reset + rebuild), not only the departure branch.

How do I detect back/forward specifically vs a normal navigation?

Use the Navigation API's navigate event and check navigationType === 'traverse' (both back and forward), with window.addEventListener('popstate', …) as the fallback for browsers without it. Compare the destination pathname to your scene's owner route before rebuilding.

Why does replaying my intro animation on return feel broken?

The intro is a first-impression reveal; on a Back-button return the user has already seen it. Replaying a multi-second assembly reads as a stutter or a regression. Re-establish the scene and snap straight to its settled state, then a short (~400 ms) fade-in.

Related posts