Disconnect Timeout & Participant Rejoin System
This document explains the architecture and implementation of HyperStudy's disconnect timeout and participant rejoin system, which allows participants to return to their experiment after a connection loss while maintaining experiment integrity.
Overview
The disconnect timeout system provides two key features:
- Participant Rejoin: Participants who refresh or lose connection can return to their current experiment state without restarting
- Automatic Pause: Multi-person experiments automatically pause when a participant disconnects, with a configurable timeout before ending the experiment
Architecture
System Components
┌─────────────────┐
│ Participant │
│ Refreshes │
└────────┬────────┘
│
▼
┌─────────────────────────────────────────┐
│ Frontend: Detect Rejoin │
│ - Check session status = 'active' │
│ - Check deviceSetupCompleted flag │
│ - Skip device setup if already done │
└────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Backend: Handle Rejoin │
│ - Detect participant already in room │
│ - Clear disconnect timeout if paused │
│ - Resume state timer with remaining │
│ - Send current state to participant │
└─────────────────────────────────────────┘
┌─────────────────┐
│ Participant │
│ Disconnects │
└────────┬────────┘
│
▼
┌─────────────────────────────────────────┐
│ Backend: Pause Experiment │
│ - Clear state timer │
│ - Set pause state │
│ - Start disconnect timeout (60s) │
│ - Notify all participants │
└────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Frontend: Show Pause Overlay │
│ - Display countdown timer │
│ - Show participant status │
│ - Block user interaction │
└─────────────────────────────────────────┘
│
┌────┴────┐
│ │
▼ ▼
Rejoin Timeout
Resume Complete
Implementation Details
Frontend Components
1. Session Service (participantSessionService.js)
Tracks device setup completion to enable smart rejoin:
// Session schema includes:
deviceSetupCompleted: false,
deviceSetupTimestamp: null,
// Method to mark completion
async setDeviceSetupComplete(userId) {
await this.updateSession(userId, {
deviceSetupCompleted: true,
deviceSetupTimestamp: serverTimestamp()
});
}
2. Experiment Route (experiment.svelte)
Detects rejoin scenario and prevents unnecessary redirects:
// Detect rejoin: active session with roomId
if (session.status === 'active' && session.roomId) {
isRejoin = true;
// Continue to experiment, don't redirect to lobby/waiting
}
3. ExperimentRunner (ExperimentRunner.svelte)
Skips device setup for returning participants:
// Check if device setup already completed
let shouldSkipDeviceSetupForRejoin = false;
if (isRejoin && auth.currentUser) {
const session = await sessionService.getSession(auth.currentUser.uid);
shouldSkipDeviceSetupForRejoin = session?.deviceSetupCompleted === true;
}
const needsDeviceSetup = !skipDeviceSetup &&
!shouldSkipDeviceSetupForRejoin &&
shouldShowDeviceSetupCheck(experimentData);
Listens for pause/resume events:
experimentSocket.on('experiment-paused', (data) => {
isPaused = true;
pauseInfo = data;
});
experimentSocket.on('experiment-resumed', (data) => {
isPaused = false;
pauseInfo = null;
});
4. Pause Overlay (ExperimentPauseOverlay.svelte)
Displays full-screen overlay during pause:
- Circular countdown timer: Updates every 100ms
- Participant status: Shows who's connected/disconnected
- Blocks interaction: Full-screen z-index 9999
- Auto-cleanup: Clears interval on component destroy
Backend Components
1. Disconnect Detection (experimentStateServer.js - handleDisconnect)
Triggers pause for multi-person experiments:
// Guard clauses:
if (room.isStarted && // Experiment running
room.expectedCount > 1 && // Multi-person
room.experimentData?.disconnectTimeout?.enabled === true && // Feature enabled
!room.isPausedForDisconnect) { // Not already paused
// Pause state timer
clearTimeout(room.timerRef);
clearInterval(room.timerUpdateInterval);
// Set pause state
room.isPausedForDisconnect = true;
room.pausedAt = Date.now();
room.disconnectedParticipant = participantId;
// Start disconnect timeout
const timeoutDuration = room.experimentData.disconnectTimeout.durationMs || 60000;
room.disconnectTimeoutRef = setTimeout(async () => {
await this.endExperiment(roomId, 'DISCONNECT_TIMEOUT');
}, timeoutDuration);
// Notify participants
this.io.of('/experiment').to(`room:${roomId}`).emit('experiment-paused', {
reason: 'participant_disconnect',
participantId,
timeoutDurationMs: timeoutDuration,
timeoutEndTime: Date.now() + timeoutDuration,
message: `A participant disconnected. Waiting ${timeoutDuration/1000}s for reconnection...`
});
}
Why multi-person only?
- Single-participant experiments don't need coordination
- Participant can rejoin at their own pace
- No other participants waiting
2. Rejoin Detection (experimentStateServer.js - handleJoinExperiment)
Resumes experiment when participant returns:
// Detect rejoin: participant already exists in room
if (room.participants.has(normalizedParticipantId)) {
// Add socket to existing participant
room.participants.get(normalizedParticipantId).sockets.add(socket.id);
room.participants.get(normalizedParticipantId).isActive = true;
// Check if paused for this participant
if (room.isPausedForDisconnect &&
room.disconnectedParticipant === normalizedParticipantId) {
// Clear timeout
clearTimeout(room.disconnectTimeoutRef);
// Resume state timer with remaining time
if (room.timerDetails && room.pausedAt) {
const elapsedBeforePause = room.pausedAt - room.timerDetails.startTime;
const remainingDuration = room.timerDetails.duration - elapsedBeforePause;
if (remainingDuration > 0) {
this.startStateTimer(roomId, room.currentState.id, remainingDuration);
}
}
// Notify participants
this.io.of('/experiment').to(`room:${roomId}`).emit('experiment-resumed', {
reason: 'participant_reconnected',
participantId: normalizedParticipantId,
message: 'Participant reconnected. Resuming experiment...'
});
}
// Send current state to rejoining participant
if (room.isStarted && room.currentState) {
socket.emit('state-update', {
state: room.currentState,
stateIndex: room.stateIndex,
totalStates: room.totalStates,
sharedData: room.sharedData
});
}
}
3. Experiment Completion (experimentStateServer.js - endExperiment)
Enhanced to include completion reason:
async endExperiment(roomId, reason = 'SUCCESS') {
// Clear disconnect timeout if exists
if (room.disconnectTimeoutRef) {
clearTimeout(room.disconnectTimeoutRef);
}
room.isPausedForDisconnect = false;
// Emit completion with reason
this.io.of('/experiment').to(`room:${roomId}`).emit('experiment-complete', {
experimentId: room.experimentId,
totalStates: room.totalStates,
reason: reason, // 'SUCCESS', 'DISCONNECT_TIMEOUT', etc.
completionTimestamp: experimentEndTime
});
}
Completion reasons:
SUCCESS: Normal completion through all statesDISCONNECT_TIMEOUT: Participant didn't rejoin in timeTIMEOUT: Waiting room timeoutTECHNICAL: Technical issueNO_CONSENT: Participant declined consent
Configuration
Experiment Configuration Schema
Add to experiment document in Firebase:
{
"disconnectTimeout": {
"enabled": false, // Enable/disable feature (default: false)
"durationMs": 60000, // Timeout in milliseconds (default: 60s)
"action": "complete" // What to do on timeout (currently only 'complete')
}
}
Default Behavior
- Disabled by default: Opt-in per experiment
- 60-second timeout: Configurable per experiment
- Completes with questionnaires: Follows normal completion flow
State Management
Room State Fields
New fields added to room objects:
{
isPausedForDisconnect: false, // Is experiment paused for disconnect?
pausedAt: null, // Timestamp when paused
disconnectedParticipant: null, // ID of disconnected participant
disconnectTimeoutRef: null, // setTimeout reference
disconnectTimeoutEndTime: null // When timeout expires
}
Session State Fields
New fields added to participant sessions:
{
deviceSetupCompleted: false, // Has device setup been completed?
deviceSetupTimestamp: null, // When was it completed?
completionOutcome: 'SUCCESS' // How did experiment end?
}
Completion outcomes:
SUCCESS: Completed all states normallyDISCONNECT_TIMEOUT: Ended due to participant disconnectTIMEOUT: Waiting room timeoutTECHNICAL: Technical failureNO_CONSENT: Declined consent
Event Flow
Disconnect Event Flow
- Participant socket disconnects
handleDisconnectcalled- Check if pause conditions met (multi-person, enabled, started, not already paused)
- Clear state timers
- Set pause state in room
- Start disconnect timeout (60s)
- Emit
experiment-pausedto all participants - Frontend shows pause overlay
- Either:
- Participant rejoins → Resume flow
- Timeout expires → Complete experiment
Rejoin Event Flow
- Participant navigates to experiment
- Frontend checks session status
- If status = 'active', sets
isRejoin = true handleJoinExperimentcalled with existing participant- Check if experiment paused for this participant
- Clear disconnect timeout
- Calculate remaining time on state timer
- Restart state timer with remaining time
- Emit
experiment-resumedto all participants - Send current state to rejoining participant
- Frontend hides pause overlay
- Experiment continues
Resume Event Flow
Timer Management
State Timer Resume Logic
When resuming, state timer is restarted with remaining time:
// Calculate elapsed time before pause
const elapsedBeforePause = room.pausedAt - room.timerDetails.startTime;
// Calculate remaining duration
const remainingDuration = room.timerDetails.duration - elapsedBeforePause;
// Only restart if time remaining
if (remainingDuration > 0) {
this.startStateTimer(roomId, room.currentState.id, remainingDuration);
}
Safety checks:
- Verify
timerDetailsexists - Verify
pausedAtexists - Verify
startTimeanddurationexist - Check
remainingDuration > 0
Timer Cleanup
All timers properly cleaned up:
| Timer | Cleared On | Location |
|---|---|---|
disconnectTimeoutRef | Rejoin, endExperiment | Multiple |
timerRef | Pause, clearStateTimer | Multiple |
timerUpdateInterval | Pause, clearStateTimer | Multiple |
| Pause overlay interval | Component destroy | Frontend |
Edge Cases Handled
1. Multiple Participants Disconnect
Behavior: Only the first disconnect triggers pause
- Guard clause:
!room.isPausedForDisconnect - Subsequent disconnects don't reset timer
- Any participant can rejoin to resume
2. Participant Reconnects Then Disconnects Again
Behavior: New disconnect triggers new pause
- First rejoin clears pause state
- Second disconnect triggers new pause with fresh timeout
3. Timer Already Expired
Behavior: Don't restart timer, let state advance
if (remainingDuration > 0) {
// Restart
} else {
console.log('[Rejoin] Timer already expired, not restarting');
}
4. All Participants Disconnect
Behavior: Experiment completes after timeout
- Last participant disconnect triggers normal timeout
- No participants to resume
- Experiment ends with
DISCONNECT_TIMEOUTreason
5. Single-Participant Disconnect
Behavior: No pause, participant can rejoin anytime
- Guard clause:
room.expectedCount > 1 - Single participant has unlimited time to rejoin
- Returns to current state
Data Persistence
What's Preserved on Rejoin
✅ Preserved:
- Current state and state index
- All shared variables (randomization maintained)
- Participant role
- Room configuration
- Timer remaining time
- Completed components
❌ Not Preserved:
- Device setup UI state (skipped on rejoin)
- Socket connection state (new socket created)
- Local component state (reinitialized)
Randomization Safety
Question: Does rejoin cause re-randomization?
Answer: No. The backend maintains room.sharedData which contains all randomized variables:
socket.emit('state-update', {
state: room.currentState,
stateIndex: room.stateIndex,
sharedData: room.sharedData // Same randomization as before
});
Rejoining participant receives the exact same shared data, ensuring:
- Consistent experimental conditions
- No duplicate randomization
- Maintained counterbalancing
Performance Considerations
Frontend
- Pause overlay updates: 100ms interval (acceptable for 60s duration)
- Memory cleanup: Interval cleared in
onDestroy - Session checks: Only on page load (not polling)
Backend
- Timer overhead: One
setTimeoutper disconnect (minimal) - Room state: Small additional fields (~100 bytes)
- Event emission: Only to participants in paused room
Network
- Rejoin cost: Same as initial join (state + shared data)
- Pause events: Sent once per disconnect (~200 bytes)
- Resume events: Sent once per rejoin (~200 bytes)
Security Considerations
Participant ID Validation
All participant IDs normalized before use:
const normalizedParticipantId = normalizeParticipantId(participantId);
Prevents:
- ID spoofing
- Invalid participant references
- Race conditions on rejoin
Authorization
Rejoining participants must:
- Have valid Firebase authentication
- Match existing participant in room
- Have active session in database
Timer Safety
Timeouts properly cleaned:
- Cleared on rejoin
- Cleared on experiment completion
- Set to
nullafter clearing - No dangling references
Testing
Test Coverage
Frontend Tests: 21 new tests
- Session service: Device setup tracking (2 tests)
- Pause overlay: Full component coverage (19 tests)
Backend Tests: 15 new tests
- Disconnect pause logic (5 tests)
- Rejoin resume logic (4 tests)
- Timeout expiry (1 test)
- Completion with reason (3 tests)
- Edge cases (2 tests)
Total: 36 new tests, all passing ✅
Key Test Scenarios
- ✅ Multi-person experiment pauses on disconnect
- ✅ Single-person experiment doesn't pause
- ✅ Disabled timeout doesn't pause
- ✅ State timer clears on pause
- ✅ Participant rejoin resumes experiment
- ✅ State timer resumes with remaining time
- ✅ Timeout expiry completes experiment
- ✅ Completion reason sent correctly
- ✅ Multiple disconnects handled
- ✅ Expired timer edge case
Troubleshooting
Participant Can't Rejoin
Check:
- Session status is 'active' in Firebase
- Session has
roomIdfield populated - Room still exists on backend
- Experiment hasn't completed
Debug:
// Frontend console
console.log('[experiment] Session:', session);
console.log('[experiment] isRejoin:', isRejoin);
// Backend logs
[Rejoin] Participant X rejoined, resuming experiment
Pause Overlay Not Showing
Check:
disconnectTimeout.enabled === truein experiment config- Experiment has multiple participants (
expectedCount > 1) - Experiment has started (
room.isStarted === true) - Not already paused (
!room.isPausedForDisconnect)
Timer Not Resuming
Check:
room.timerDetailsexistsroom.pausedAtis set- Remaining duration > 0
- State has a timer configured
Debug:
[Rejoin] Restarting state timer with Xms remaining
// OR
[Rejoin] Timer already expired, not restarting
Experiment Completes Immediately on Disconnect
Check:
- Timeout duration configuration:
disconnectTimeout.durationMs - Verify timeout is 60000 (60s), not 0 or very small
Future Enhancements
Potential Improvements
-
Admin UI for Configuration
- Checkbox to enable disconnect timeout
- Slider for timeout duration
- Preview of participant experience
-
Configurable Actions
pause_indefinitely: Wait for manual interventionskip_questionnaire: Complete without post-exp questionnaireresume_from_state: Jump to specific state on timeout
-
Analytics & Monitoring
- Track rejoin frequency per experiment
- Monitor disconnect patterns
- Alert experimenters on timeout expiry
-
Notifications
- Optional toast when participant reconnects
- Email/Slack notification on timeout expiry
- Admin dashboard real-time status
-
Grace Periods
- Short grace period (5-10s) before showing pause overlay
- Handles brief network hiccups without alarming participants
References
- Implementation PR: [Link to PR]
- Related Documentation:
- Test Files:
frontend/src/lib/services/__tests__/participantSessionService.test.jsfrontend/src/components/experiment/__tests__/ExperimentPauseOverlay.test.jsbackend/src/experiment/__tests__/experimentStateServer.rejoin.test.js
Version History
- v0.4.0 (2025-10-31): Initial implementation
- Participant rejoin functionality
- Disconnect timeout with 60s default
- Pause overlay UI
- Session tracking enhancements