Incident: Two recordings started in separate browser tabs on the same device resulted in one recording killing the other, but the UI continued to show the killed recording as "active".
Affected Meetings:
- Meeting 1:
meeting_1762864213167_td07i9w3w - Meeting 2:
meeting_1762867368498_riizxm5te
Device: Same (confirmed by matching User-Agent)
Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36
Root Cause: Shared localStorage key collision causing session state overwrite without frontend notification.
| Time (UTC) | Meeting 1 (td07i9w3w) | Meeting 2 (riizxm5te) | localStorage State |
|---|---|---|---|
| 2025-11-11 13:25:04 | Not created yet | ❌ Transcription error (DB schema issue) | Empty |
| 2025-11-11 13:26:37 | Not created yet | ✅ Meeting created | Empty |
| 2025-11-11 15:50:31 | Active | livekit-session: meeting_2 |
|
| 2025-11-12 05:45:16 | 🔴 Slow API calls | Active | livekit-session: meeting_2 |
| 2025-11-12 05:59:17 | ❌ DB timeout errors | Active | livekit-session: meeting_2 |
| 2025-11-12 06:39:22 | Active | 🔴 Slow API call | livekit-session: meeting_2 |
| 2025-11-12 06:45:51 | Active | livekit-session: meeting_2 |
Duration Analysis:
- Meeting 1: 14 hours 55 minutes (2025-11-11 15:50 → 2025-11-12 06:45)
- Meeting 2: 17 hours 14 minutes (2025-11-11 13:25 → 2025-11-12 06:39)
- Overlap: ~14 hours 55 minutes (entire Meeting 1 lifespan)
Storage Key Definition (src/services/livekit/livekitConfig.ts:68-71):
export const STORAGE_KEYS = {
SESSION: 'livekit-session', // ❌ NOT keyed by meeting ID
LAST_MEETING_ID: 'livekit-last-meeting',
};Both tabs write to the same localStorage key:
localStorage.setItem('livekit-session', {
meetingId: 'meeting_1762864213167_td07i9w3w',
roomName: 'room-meeting-td07i9w3w',
egressId: 'EG_abc123',
participantName: 'User',
recordingStartedAt: '2025-11-11T15:50:31Z'
});localStorage.setItem('livekit-session', {
meetingId: 'meeting_1762867368498_riizxm5te', // ← Different meeting!
roomName: 'room-meeting-riizxm5te',
egressId: 'EG_xyz789',
participantName: 'User',
recordingStartedAt: '2025-11-11T13:26:37Z'
});When Tab 1 tries to recover or access session:
// Tab 1 code (LiveKitSessionManager.ts:40-42)
loadSession(): SessionState | null {
const stored = localStorage.getItem(STORAGE_KEYS.SESSION);
// ❌ Returns Meeting 2's session data!
return JSON.parse(stored);
}Result: Tab 1 believes it's recording Meeting 2's session.
sequenceDiagram
participant Tab1 as Tab 1 (Meeting 1)
participant Tab2 as Tab 2 (Meeting 2)
participant LS as localStorage
participant LK as LiveKit Server
participant UI as Frontend UI
Note over Tab1,Tab2: User has two tabs open
Tab2->>LS: saveSession(meeting_2)
LS->>LS: localStorage['livekit-session'] = meeting_2
Tab2->>LK: startRecording(meeting_2)
LK-->>Tab2: egressId: EG_xyz789
Note over Tab2,UI: Recording active ✅
Note over Tab1: User starts recording in Tab 1
Tab1->>LS: saveSession(meeting_1)
LS->>LS: localStorage['livekit-session'] = meeting_1
Note over LS: ⚠️ OVERWRITES meeting_2 session!
Tab1->>LK: startRecording(meeting_1)
LK-->>Tab1: egressId: EG_abc123
Note over Tab1,UI: Recording active ✅
Note over Tab2: Tab 2 tries to stop recording
Tab2->>LS: loadSession()
LS-->>Tab2: ❌ Returns meeting_1 session!
Tab2->>LK: stopRecording(EG_abc123)
Note over Tab2,LK: ❌ STOPS WRONG RECORDING!
LK->>LK: Stop Meeting 1's egress
LK-->>Tab1: (no notification)
Note over Tab1,UI: Tab 1 UI still shows "Recording..." ❌
Note over Tab2,UI: Tab 2 thinks it stopped successfully ✅
Tab1->>LK: Tries to use meeting_1
LK-->>Tab1: ❌ Egress not found / already stopped
| Action | Tab 1 State | Tab 2 State | localStorage['livekit-session'] | Result |
|---|---|---|---|---|
| Initial | Idle | Idle | null |
✅ Clean state |
| Tab 2 starts recording | Idle | Recording meeting_2 | { meetingId: meeting_2, egressId: EG_xyz } |
✅ Tab 2 works |
| Tab 1 starts recording | Recording meeting_1 | Recording meeting_2 | { meetingId: meeting_1, egressId: EG_abc } |
❌ OVERWRITE |
| Tab 2 tries to stop | Recording meeting_1 | Stopping | { meetingId: meeting_1, egressId: EG_abc } |
❌ WRONG SESSION |
| Tab 2 stops recording | ❌ Recording stopped (no UI update) | ✅ Stopped | { meetingId: meeting_1, egressId: EG_abc } |
❌ TAB 1 KILLED |
| Tab 1 UI still shows recording | ❌ UI thinks recording | Stopped | { meetingId: meeting_1, egressId: EG_abc } |
❌ GHOST RECORDING |
Problem: No meeting-specific storage keys
saveSession(session: SessionState): void {
localStorage.setItem(STORAGE_KEYS.SESSION, JSON.stringify(session));
// ❌ Always uses same key regardless of meetingId
}Problem: Singleton service doesn't track per-tab state
class LiveKitService {
private static instance: LiveKitService; // ❌ Singleton
private room: Room | null = null; // ❌ Single room instance
private recordingState: RecordingState; // ❌ Single recording state
}Problem: No detection of external session changes
// No mechanism to detect when localStorage is overwritten by another tab
// No StorageEvent listener for cross-tab synchronizationProblem: No validation that egress ID matches expected meeting
// State machine transitions to "recording" without verifying:
// 1. Is this the correct meeting ID?
// 2. Is this egressId still valid?
// 3. Has another tab taken over the session?- ❌ Silent Failure: Recording stops without UI feedback
- ❌ Data Loss Risk: User believes recording is active but no audio is being captured
- ❌ Confusion: Timer keeps running, mic indicator shows active, but no actual recording
- ❌ Trust Erosion: Platform appears unreliable
- ❌ Orphaned Egress: Meeting 1's egress stopped prematurely
- ❌ Incomplete Audio: Meeting 1 may have partial/no audio file
- ❌ Database Inconsistency: Meeting status shows "recording" but no active egress
- ❌ Resource Waste: Frontend continues monitoring dead recording
- ❌ Meeting Data Loss: Critical meeting might not be recorded
- ❌ Re-recording Required: User must start over (if they notice)
- ❌ Support Burden: Users will report "recordings not saving"
Approach: Make localStorage keys unique per meeting
Implementation:
// livekitConfig.ts
export const STORAGE_KEYS = {
SESSION: (meetingId: string) => `livekit-session-${meetingId}`,
LAST_MEETING_ID: 'livekit-last-meeting',
};
// LiveKitSessionManager.ts
saveSession(session: SessionState): void {
const key = STORAGE_KEYS.SESSION(session.meetingId);
localStorage.setItem(key, JSON.stringify(session));
}
loadSession(meetingId: string): SessionState | null {
const key = STORAGE_KEYS.SESSION(meetingId);
const stored = localStorage.getItem(key);
// ...
}Pros:
- ✅ Complete isolation between tabs
- ✅ Session recovery works correctly per meeting
- ✅ No cross-tab interference
- ✅ Minimal code changes
Cons:
⚠️ Need to pass meetingId to all session methods⚠️ Orphaned keys if meetings deleted (minor)
Effort: Low (2-4 hours)
Approach: Use tab-specific storage instead of shared localStorage
Implementation:
// Replace all localStorage calls with sessionStorage
sessionStorage.setItem(STORAGE_KEYS.SESSION, JSON.stringify(session));Pros:
- ✅ Automatic per-tab isolation
- ✅ No cross-tab interference by design
- ✅ Simple implementation (search & replace)
- ✅ No orphaned data (cleared when tab closes)
Cons:
- ❌ Loses session on page refresh (critical for recovery!)
- ❌ Can't recover from browser crash
- ❌ No cross-tab synchronization possible
Effort: Very Low (1 hour) Verdict: ❌ NOT RECOMMENDED (breaks session recovery feature)
Approach: Detect when another tab overwrites session
Implementation:
// useRecordingMachine.ts or LiveKitService.ts
useEffect(() => {
const handleStorageChange = (event: StorageEvent) => {
if (event.key === STORAGE_KEYS.SESSION) {
const newSession = event.newValue ? JSON.parse(event.newValue) : null;
// If session changed and it's not our meeting
if (newSession && newSession.meetingId !== meetingId) {
console.warn('⚠️ Session hijacked by another tab!');
// Show warning to user
setError('Another recording started in a different tab. This recording may be affected.');
// Or auto-stop this recording
stopRecording();
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [meetingId]);Pros:
- ✅ Alerts user to conflict
- ✅ Can prevent data loss
- ✅ Works with Solution 1 or standalone
Cons:
⚠️ Reactive (damage already done)⚠️ Doesn't prevent the collision
Effort: Low (1-2 hours) Verdict: ✅ RECOMMENDED as defense-in-depth
Approach: Block starting second recording if one is active
Implementation:
// Check all possible meeting IDs in localStorage
function hasActiveRecording(): boolean {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith('livekit-session-')) {
const session = JSON.parse(localStorage.getItem(key) || '{}');
if (session.egressId) {
return true; // Active recording found
}
}
}
return false;
}
// In startRecording:
if (hasActiveRecording()) {
throw new Error('Cannot start recording: Another recording is already active in a different tab');
}Pros:
- ✅ Prevents collision entirely
- ✅ Clear user feedback
Cons:
- ❌ Restricts legitimate use case (simultaneous meetings)
- ❌ UX friction
- ❌ Doesn't solve cross-device scenario
Effort: Medium (4-6 hours) Verdict: ❌ NOT RECOMMENDED (too restrictive)
Approach: Track room keeper per meeting to detect conflicts
Implementation:
class LiveKitService {
private static instances = new Map<string, LiveKitService>();
static getInstance(meetingId: string): LiveKitService {
if (!this.instances.has(meetingId)) {
this.instances.set(meetingId, new LiveKitService());
}
return this.instances.get(meetingId)!;
}
}Pros:
- ✅ Complete isolation per meeting
- ✅ Proper singleton per meeting
Cons:
- ❌ Major refactor (singleton → multi-instance)
- ❌ High complexity
- ❌ Risk of memory leaks
Effort: High (2-3 days)
Verdict:
- ✅ Solution 1: Per-Meeting Storage Keys (2-4 hours)
- ✅ Solution 3: StorageEvent Listener (1-2 hours)
Total Effort: 3-6 hours Risk: Low Impact: Completely fixes the issue
⚠️ Add UI indicator when multiple recordings detected⚠️ Add meeting-specific debugging logs⚠️ Add analytics to track dual-recording attempts
-
Two Tabs - Different Meetings
- Open Tab 1, create Meeting A, start recording
- Open Tab 2, create Meeting B, start recording
- Stop Meeting A in Tab 1
- Verify: Meeting B still recording
- Stop Meeting B in Tab 2
- Verify: Both completed successfully
-
Two Tabs - Sequential
- Open Tab 1, start recording, stop
- Open Tab 2, start recording
- Verify: No interference
-
Page Refresh During Recording
- Start recording in Tab 1
- Refresh Tab 1
- Verify: Session recovery works (correct meeting)
-
Browser Crash Recovery
- Start recording
- Kill browser (not graceful close)
- Reopen browser, navigate to app
- Verify: Orphaned recording auto-recovery
describe('Session Storage Isolation', () => {
it('should store sessions with meeting-specific keys', () => {
const session1 = { meetingId: 'meeting_1', egressId: 'EG_1' };
const session2 = { meetingId: 'meeting_2', egressId: 'EG_2' };
sessionManager.saveSession(session1);
sessionManager.saveSession(session2);
const loaded1 = sessionManager.loadSession('meeting_1');
const loaded2 = sessionManager.loadSession('meeting_2');
expect(loaded1.egressId).toBe('EG_1');
expect(loaded2.egressId).toBe('EG_2');
});
it('should detect storage hijacking', () => {
const spy = jest.fn();
const component = renderWithListeners(spy);
// Simulate another tab overwriting storage
window.dispatchEvent(new StorageEvent('storage', {
key: 'livekit-session',
newValue: JSON.stringify({ meetingId: 'different' })
}));
expect(spy).toHaveBeenCalledWith('Session hijacked');
});
});-
Dual Recording Attempts
analytics.track('dual_recording_detected', { meeting_1: oldMeetingId, meeting_2: newMeetingId, time_between: timeDiff });
-
Session Recovery Failures
analytics.track('session_recovery_failed', { meeting_id: meetingId, reason: 'session_mismatch' });
-
StorageEvent Triggers
analytics.track('storage_conflict_detected', { hijacked_meeting: oldMeetingId, hijacking_meeting: newMeetingId });
// Add meeting ID to all logs
const log = createLogger('LiveKitService', { meetingId });
log.warn('⚠️ [Meeting ${meetingId}] Session overwritten by ${newMeetingId}');| Component | File Path | Lines |
|---|---|---|
| Storage Keys | src/services/livekit/livekitConfig.ts |
68-71 |
| Session Manager | src/services/livekit/LiveKitSessionManager.ts |
1-142 |
| LiveKit Service | src/services/livekit/LiveKitService.ts |
28-50 |
| Recording Controls | src/hooks/useRecordingControls.ts |
53-615 |
| Recording Machine | src/machines/recordingMachine.ts |
566-590 |
- localStorage MDN
- StorageEvent MDN
- LiveKit Egress Docs
- Meeting Log Files:
meeting_1762864213167_td07i9w3w.json(1.2MB)meeting_1762867368498_riizxm5te.json(1.2MB)
Document Version: 1.0 Created: 2025-11-12 Author: RCA Analysis Tool Status: Ready for Review