All posts
Engineering21 Sep 2026 · 3 min read

Every tab switch was a page reload

JPJean Perez

The line is window.location.assign(tab.href), in apps/web/lib/shell/use-app-tabs.ts, and until 21 September it ran on every tab switch in the app shell. Click WTF, click DASH, click Studio: every one of them reloaded the whole document, no exceptions.

The reason was real, and it was written down once. /ffmpeg-studio needs SharedArrayBuffer for its multi-threaded FFmpeg core, which needs crossOriginIsolated, which needs COOP/COEP headers, which are fixed at document creation and can't change on a client-side route change. Commit 2495064 (BMU-115, 10 September) says it plainly: "Navigation is full document loads, not <Link>. /ffmpeg-studio is COOP/COEP isolated; a client-side route change would carry that isolation onto the destination and block the cross-origin media the other tools load." Correct, as far as it goes.

What it doesn't say is how far it goes. Six tool routes make 36 ordered pairs. Only 10 of them actually cross the isolation boundary: five leaving /ffmpeg-studio, five entering it. The other 26, WTF to DASH, HLS to Corruption Check, and so on, paid for a full reload anyway, because navigate() drew no distinction between them. The rule was written when Studio was the only tool route in the shell. Five more tools got ported onto the same shell afterward, and the rule was never revisited.

There's a second reason, never written down anywhere: each tool keeps its state in a module-level Zustand store (useWtfStore, useHlsStore, useStudioStore and so on). A store is scoped to the document, not the tab. Two tabs of the same tool in one document would silently share one store, so a fresh document per switch was also the only thing keeping two WTF tabs independent of each other.

Two conditions for a soft switch

The fix replaced the blanket reload with navigateToTab(), in a new lib/shell/shell-navigation.ts. It falls back to window.location.assign unless both of these hold:

  • isIsolatedRoute(destination) matches the document's actual window.crossOriginIsolated bit, not a guess.
  • the destination's ?tabid= is either already claimed by this document for that tool, or unclaimed.

Claims are never released, even when a tab closes. A stale claim costs one extra reload later; releasing one would let a new tab silently inherit a closed tab's store, which is worse.

Measured in Chrome against a real next dev --turbopack build, with five seeded tabs and real clicks on the tab strip: a WTF-to-DASH switch dropped /api/trpc requests from one to zero and needed no document load at all, where a full reload had cost 755 ms on navigation.loadEventEnd. A Studio switch still forced a full reload in both directions, exactly as it should. Two tabs of the same tool still forced one too.

The flash the fix revealed

The first pass of measurements said the client-side switch was clean. That measurement was a single sample taken 2,000 ms after the click, long after any transient would have settled. Measured again, per DOM mutation, a signed-in user actually saw this on every switch:

+0ms    real         "<my email> / Free"
+543ms  PLACEHOLDER  "Jean Perez / Pro · 12-day streak"
+722ms  real         "<my email> / Free"

179 ms of a hard-coded prototype placeholder, on every switch, for a signed-in user. hooks/use-auth.ts held the Supabase session in component-local state, re-resolving through getSession() on every mount. A full reload had hidden this completely; there's nothing on screen to flash when the whole page is already blank. The client-side switch just made it visible.

The fix, shipped the next day, is a per-document session cache in lib/auth/session-cache.ts: useAuth seeds its state from the cache through lazy initializers instead of starting from nothing on every mount. Measured with the cache disabled as a control and then restored: the placeholder showed up on every switch with the cache off, and on none of three consecutive switches with it on.

What's still true

document.querySelector('aside') === previousAsideNode is still false after a client-side switch. AppShell is rendered inside each page's own client component rather than a shared layout, so React still unmounts one shell subtree and mounts the next on every tab change, 25 DOM mutations for a WTF-to-DASH switch. The redirect is gone. The sidebar not remounting at all is a separate, larger change: it would mean inverting the header's live crumb/segments/primary props into a context shared across twelve pages, and it's tracked as BMU-299 rather than done.

That ticket became the seed of a bigger rearchitecture, a twelve-part App Router shell epic, still in Backlog with three of its twelve tickets in progress. Nothing about this week's fix depends on that epic landing. BMU-299 is where the shell stops remounting. Until then a tab switch is cheaper than it was, and still not free.