STORM

SOFTWARE  DEVELOPER

SYSTEM  INITIALIZING

[ VIEW PORTFOLIO ]

01
Back to Blog
Browser API

Why sessionStorage Breaks Across Tabs (and the Right Fix)

May 12, 2026 7 min read

You have a splash screen or an intro animation on your site. It shows once when the user lands. Perfect. Then the user opens your blog in a new tab — and the splash plays again. They open another page — splash again.

This is not a bug in your logic. It is a fundamental property of sessionStorage that catches almost every developer off guard the first time.


What sessionStorage Actually Means

The MDN definition says sessionStorage data is cleared when the browser session ends. Most developers read that and assume it means something like a user login session. It does not.

The browser session in the Web Storage specification means exactly one browser tab or window. Each tab has its own completely isolated sessionStorage context. A value you write in Tab 1 is invisible to Tab 2, Tab 3, or any other tab — even if they are on the exact same page.

This is by design. sessionStorage was built for forms and wizard-style flows where you need per-tab isolation. But it is the wrong tool when you need shared state across all tabs of the same origin.


Why the Splash Screen Breaks

Here is what happens with a naive implementation:

  • › User opens site in Tab 1 — sessionStorage is empty — splash shows
  • › User clicks to enter — code writes storm_entered = "1" to sessionStorage of Tab 1
  • › User opens your blog in a new tab (Tab 2) — Tab 2 has its own fresh, empty sessionStorage
  • › Code checks sessionStorage for storm_entered — it is not there — splash shows again

The flag was written correctly. The check is correct. But sessionStorage is per-tab, so Tab 2 never sees what Tab 1 wrote.


Option A — localStorage with Tab-Count Handshake (Recommended)

This is the approach that matches the exact behavior most sites need: show the splash once while any tab is open, reset when all tabs close.

The mechanism uses three keys:

  • › storm_tab_id in sessionStorage — a unique UUID for this specific tab, cleared automatically when the tab closes
  • › storm_open_tabs in localStorage — a JSON array of all currently active tab IDs, shared across all tabs
  • › storm_splash_shown in localStorage — a boolean flag marking that the splash was dismissed this session, also shared

How it works step by step:

  • › Tab opens — if no storm_tab_id in sessionStorage, this is a fresh tab
  • › Generate a UUID, write it to sessionStorage.storm_tab_id
  • › Add the UUID to localStorage.storm_open_tabs (the shared list)
  • › Register a beforeunload listener: on tab close, remove this UUID from storm_open_tabs; if the list is now empty, clear storm_splash_shown
  • › Check localStorage.storm_splash_shown — if set, skip the splash; otherwise show it
  • › When user dismisses the splash, write storm_splash_shown = "1" to localStorage

Now opening a new tab reads localStorage.storm_splash_shown (which is shared) and finds it already set. No splash. When the last tab closes, beforeunload fires, the tab list empties, and storm_splash_shown is deleted. The next browser session starts clean.

javascript
const TAB_ID_KEY = 'storm_tab_id'
const TABS_KEY   = 'storm_open_tabs'
const SHOWN_KEY  = 'storm_splash_shown'

useEffect(() => {
  // If this tab was already registered (component re-mount), just read the flag.
  if (sessionStorage.getItem(TAB_ID_KEY)) {
    if (localStorage.getItem(SHOWN_KEY)) setVisible(false)
    return
  }

  const tabId = crypto.randomUUID()
  sessionStorage.setItem(TAB_ID_KEY, tabId)

  const tabs = JSON.parse(localStorage.getItem(TABS_KEY) || '[]')
  tabs.push(tabId)
  localStorage.setItem(TABS_KEY, JSON.stringify(tabs))

  // Must NOT be removed on component unmount — it must live for the full tab lifetime.
  window.addEventListener('beforeunload', () => {
    const current = JSON.parse(localStorage.getItem(TABS_KEY) || '[]')
    const remaining = current.filter(id => id !== tabId)
    if (remaining.length === 0) {
      localStorage.removeItem(SHOWN_KEY)
      localStorage.removeItem(TABS_KEY)
    } else {
      localStorage.setItem(TABS_KEY, JSON.stringify(remaining))
    }
  })

  if (localStorage.getItem(SHOWN_KEY)) setVisible(false)
}, [])

function handleDismiss() {
  localStorage.setItem(SHOWN_KEY, '1')
  setVisible(false)
}

One important detail: any other component that conditionally starts based on whether the splash was shown must also be updated. If it previously checked sessionStorage.getItem('storm_entered'), change it to localStorage.getItem('storm_splash_shown'). Otherwise it will not start when the splash is skipped on a new tab.


Option B — BroadcastChannel API

BroadcastChannel is a modern browser API that lets open tabs of the same origin send messages to each other in real time. When one tab dismisses the splash, it broadcasts a message and all other open tabs receive it.

javascript
const channel = new BroadcastChannel('storm_splash')

// When splash is dismissed:
function handleDismiss() {
  sessionStorage.setItem('storm_entered', '1')
  channel.postMessage({ type: 'splash_dismissed' })
  setVisible(false)
}

// On mount — listen for dismissal from other tabs:
channel.onmessage = (event) => {
  if (event.data.type === 'splash_dismissed') {
    setVisible(false)
  }
}

This approach works well for real-time sync between already-open tabs. If Tab 1 dismisses the splash, Tab 2 (if already open) hides it instantly.

The limitation: BroadcastChannel only delivers messages to tabs that are already open and listening. It cannot reach a tab that opens after the message was sent. So if you dismiss the splash in Tab 1 and then open Tab 2, Tab 2 starts with an empty sessionStorage, no message arrives (the channel is not live yet), and the splash shows again.

You can combine BroadcastChannel with a sessionStorage check to patch this: on Tab 2 open, query existing tabs via BroadcastChannel for the current state. But this adds latency and complexity — you have to wait for a response before deciding whether to show the splash, which introduces a flash of content or a timing race.


Comparing the Two Approaches

ConcernOption A (localStorage + tabs)Option B (BroadcastChannel)
Works for newly opened tabsYes — reads shared localStorage flagNo — message already sent, new tab misses it
Real-time sync to existing tabsNo — passive read onlyYes — instant message delivery
Resets when all tabs closeYes — beforeunload clears the flagYes — sessionStorage cleared naturally
Browser supportUniversalAll modern browsers, no IE
Implementation complexityLow to mediumMedium to high (query/response pattern needed)
Crash resiliencePartial — beforeunload may not fire on crashSame limitation

Why the beforeunload Limitation Is Acceptable

Both approaches have the same crash edge case: if the browser crashes or is force-killed, beforeunload does not fire. The tab IDs stay in localStorage.storm_open_tabs, storm_splash_shown never gets cleared, and the next browser session skips the splash.

For a splash screen this is acceptable. You can add a TTL check if needed — store a timestamp alongside the shown flag and reset it if it is older than 24 hours. But for most portfolios and marketing sites, the crash recovery is unnecessary complexity.


Key Takeaways

  • sessionStorage is scoped per browser tab, not per browser session — never use it for cross-tab shared state
  • localStorage is shared across all tabs of the same origin and is the right primitive for cross-tab flags
  • Track active tabs with a JSON array in localStorage and beforeunload to detect when all tabs close
  • Any component that reads the "splash shown" flag must be updated to read from the same localStorage key
  • BroadcastChannel is better for real-time tab sync, but cannot retroactively notify newly opened tabs

The Web Storage spec is clear about this — sessionStorage is per-tab by design. It is not a bug to fix in the browser. It is a choice you make about which storage primitive matches your actual requirement. Cross-tab state belongs in localStorage.

Back to Blog
PLAYING