Skip to content

Instantly share code, notes, and snippets.

@ChakshuGautam
Created November 12, 2025 06:54
Show Gist options
  • Select an option

  • Save ChakshuGautam/ab4dbd8f04f464e545dceeb2bbc4a901 to your computer and use it in GitHub Desktop.

Select an option

Save ChakshuGautam/ab4dbd8f04f464e545dceeb2bbc4a901 to your computer and use it in GitHub Desktop.
RCA: Dual Recording Session Collision in MoM v2 - Two browser tabs caused localStorage collision resulting in one recording silently killing the other

Root Cause Analysis: Dual Recording Session Collision

Executive Summary

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.


Timeline Comparison

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 ⚠️ First log entry 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 ⚠️ Last log entry 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)

Root Cause: localStorage Collision

Code Analysis

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',
};

The Problem

Both tabs write to the same localStorage key:

Tab 1 (Meeting 1) starts recording:

localStorage.setItem('livekit-session', {
  meetingId: 'meeting_1762864213167_td07i9w3w',
  roomName: 'room-meeting-td07i9w3w',
  egressId: 'EG_abc123',
  participantName: 'User',
  recordingStartedAt: '2025-11-11T15:50:31Z'
});

Tab 2 (Meeting 2) starts recording (OVERWRITES Tab 1):

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'
});

Consequence: Silent Session Hijacking

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.


Failure Sequence Diagram

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
Loading

localStorage State Table

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

Affected Components

1. Session Manager (src/services/livekit/LiveKitSessionManager.ts)

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
}

2. LiveKit Service (src/services/livekit/LiveKitService.ts)

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
}

3. Recording Controls (src/hooks/useRecordingControls.ts)

Problem: No detection of external session changes

// No mechanism to detect when localStorage is overwritten by another tab
// No StorageEvent listener for cross-tab synchronization

4. Frontend State Machine (src/machines/recordingMachine.ts)

Problem: 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?

Impact Assessment

User Experience Impact

  • 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

Technical Impact

  • 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

Business Impact

  • 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"

Potential Solutions

Solution 1: Per-Meeting Storage Keys ⭐ Recommended

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)


Solution 2: Use sessionStorage Instead ⭐ Alternative

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)


Solution 3: Add StorageEvent Listener ⭐ Complementary

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


Solution 4: Prevent Multiple Simultaneous Recordings 🚫

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)


Solution 5: Room Keeper Per Meeting ⭐ Enhancement

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: ⚠️ OVERKILL for this issue


Recommended Solution Stack

Phase 1: Quick Fix (Deploy ASAP)

  1. Solution 1: Per-Meeting Storage Keys (2-4 hours)
  2. Solution 3: StorageEvent Listener (1-2 hours)

Total Effort: 3-6 hours Risk: Low Impact: Completely fixes the issue

Phase 2: Long-term Enhancement (Optional)

  1. ⚠️ Add UI indicator when multiple recordings detected
  2. ⚠️ Add meeting-specific debugging logs
  3. ⚠️ Add analytics to track dual-recording attempts

Testing Plan

Manual Testing Scenarios

  1. 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
  2. Two Tabs - Sequential

    • Open Tab 1, start recording, stop
    • Open Tab 2, start recording
    • Verify: No interference
  3. Page Refresh During Recording

    • Start recording in Tab 1
    • Refresh Tab 1
    • Verify: Session recovery works (correct meeting)
  4. Browser Crash Recovery

    • Start recording
    • Kill browser (not graceful close)
    • Reopen browser, navigate to app
    • Verify: Orphaned recording auto-recovery

Automated Test Cases

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');
  });
});

Monitoring & Alerting

Metrics to Track

  1. Dual Recording Attempts

    analytics.track('dual_recording_detected', {
      meeting_1: oldMeetingId,
      meeting_2: newMeetingId,
      time_between: timeDiff
    });
  2. Session Recovery Failures

    analytics.track('session_recovery_failed', {
      meeting_id: meetingId,
      reason: 'session_mismatch'
    });
  3. StorageEvent Triggers

    analytics.track('storage_conflict_detected', {
      hijacked_meeting: oldMeetingId,
      hijacking_meeting: newMeetingId
    });

Logging Enhancements

// Add meeting ID to all logs
const log = createLogger('LiveKitService', { meetingId });

log.warn('⚠️ [Meeting ${meetingId}] Session overwritten by ${newMeetingId}');

Appendix: Code Locations

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

References


Document Version: 1.0 Created: 2025-11-12 Author: RCA Analysis Tool Status: Ready for Review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment