Instagram Wipes localStorage on Navigation. Here's How We Keep Multiplayer Sessions Alive.
A teacher shares a bingo game link to her class group chat. Half the students open it in Instagram's in-app browser. They tap a cell, switch to check a notification, come back. Their game session is gone. They're staring at a fresh board with none of their claims.
This happened in production on BingWow within the first week of launch.
The root cause
Instagram, TikTok, Snapchat, and Facebook Messenger all use in-app WebView browsers. These are not Safari or Chrome. They're stripped-down renderers with restrictions that vary by platform and OS version.
The critical one: some WebViews clear localStorage on navigation events. Not every time. Not on every device. But often enough that "store session in localStorage" is a broken architecture for any app where users arrive via social media links.
Our game sessions stored the player ID, room code, and display name in localStorage. When the WebView cleared it, the server couldn't match the returning player to their board. The player re-entered as a new anonymous participant.
The dual-write pattern
The fix is writing to both localStorage and sessionStorage on every save, and reading from localStorage first with sessionStorage as the fallback:
// lib/safe-storage.ts
export function dualGet(key: string): string | null {
if (typeof window === 'undefined') return null;
try {
return localStorage.getItem(key) ?? sessionStorage.getItem(key);
} catch {
try { return sessionStorage.getItem(key); } catch { return null; }
}
}
export function dualSet(key: string, value: string): void {
try { localStorage.setItem(key, value); } catch {}
try { sessionStorage.setItem(key, value); } catch {}
}
export function dualRemove(key: string): void {
try { localStorage.removeItem(key); } catch {}
try { sessionStorage.removeItem(key); } catch {}
}
sessionStorage survives the scenarios where localStorage gets wiped. It's scoped to the tab's lifetime, which in a WebView means "until the user closes the in-app browser or force-quits the app." For a bingo game that lasts 10-30 minutes, tab lifetime is plenty.
Why every operation is wrapped in try/catch
Safari Private Browsing mode throws a QuotaExceededError on any setItem call. The storage quota is zero bytes. Older Samsung Internet builds throw on getItem if storage was disabled in settings. Some enterprise MDM-managed Chromebooks throw SecurityError on storage access entirely.
The try/catch blocks are not defensive programming paranoia. They're the result of real crash reports from real users on real devices.
The priority order: localStorage write → sessionStorage write → silent failure. A failed write is better than a crashed game. If both writes fail, the player gets a new session on refresh — annoying but playable. If the code throws, the game is bricked.
Per-room namespaced keys
A separate pattern that interacts with the dual-write: every session key includes the room code:
// lib/session.ts
export function saveSession(data: {
player_id: string;
room_code: string;
room_id: string;
display_name: string;
is_host: boolean | string;
}): void {
dualSet(`bingwow_session_${data.room_code}`, JSON.stringify({
playerId: data.player_id,
roomId: data.room_id,
roomCode: data.room_code,
displayName: data.display_name,
isHost: data.is_host === true || data.is_host === 'true',
}));
}
The key is bingwow_session_ABCD where ABCD is the four-character room code. If a player has two games open in different tabs (it happens during testing), each tab's session is independent. No cross-contamination.
This namespacing also prevents the stale-session bug: without it, a player who joins Room A, then Room B, then returns to Room A's tab finds Room B's credentials in bingwow_session and sends taps to the wrong room.
When NOT to dual-write
Not everything needs the sessionStorage mirror. We keep two tiers:
// localOnly — single-tab data, no resilience needed
export function localGet(key: string): string | null { ... }
export function localSet(key: string, value: string): void { ... }
// dual — cross-navigation data, WebView-resilient
export function dualGet(key: string): string | null { ... }
export function dualSet(key: string, value: string): void { ... }
The edit buffer (unsaved changes while creating a card) uses localOnly. If the buffer is lost, the user re-types a few words. The game session (player identity in a live multiplayer room) uses dual. If the session is lost, the player loses all their claims and has to rejoin as a stranger.
The distinction matters because sessionStorage is per-tab. The edit buffer should persist across tabs (start editing on your phone, finish on your laptop). The game session should NOT persist across tabs (two tabs with the same session = two players fighting over one identity).
Measuring the fix
Before the dual-write: ~8% of Instagram-referred sessions ended with a "session lost" recovery flow within the first 5 minutes. After: <1%. The remaining 1% is genuine cold starts (user closed the in-app browser entirely and re-opened the link).
The pattern works because sessionStorage survival guarantees are stronger than localStorage across the specific set of WebViews our users arrive from. This could change — if TikTok's WebView starts clearing sessionStorage too, we'd need to move to URL-parameter session tokens. But for now, dual-write catches 87% of the drops that single-write missed.
The code above is production code from BingWow, a free multiplayer bingo platform. Try it: pick any card from bingwow.com/cards, share the link with a friend, and play. The session management described here runs on every game — whether it's a classroom activity at bingwow.com/for/teachers or a watch party opened from an Instagram story.
If you're building anything that users reach via social media links, test in at least Instagram's and TikTok's in-app browsers. localStorage is not as reliable as the MDN docs imply.
Top comments (0)