Skip to content

Session Management

BoardAPI provides robust session management features including automatic session tracking, real-time auto-save, session recovery, and offline support. This guide covers client-side integration best practices.

Overview

Session management enables:

  • Session Tracking - Monitor active participants and their activity
  • Auto-Save - Automatic periodic snapshots of board state (every 30 seconds)
  • Session Recovery - Restore unsaved changes after unexpected disconnects
  • Offline Support - Queue operations when offline and sync when reconnected
  • Reconnection Logic - Automatic reconnection with exponential backoff

Key Benefits

FeatureBenefit
Auto-SavePrevents data loss from browser crashes or network issues
Session RecoveryRestores work after accidental tab closure
Activity TrackingShows who's actively working on the board
Offline QueueEnables work continuation during network outages
Smart ReconnectionAutomatic recovery from temporary disconnects

How Sessions Work

Session Lifecycle

┌─────────────────────────────────────────────────┐
│           SESSION LIFECYCLE                     │
├─────────────────────────────────────────────────┤
│                                                  │
│  1. Client connects to WebSocket                │
│     ↓                                            │
│  2. Emit 'board:join' with board UUID           │
│     ↓                                            │
│  3. Server creates session record               │
│     ↓                                            │
│  4. Auto-save starts (every 30s)                │
│     ↓                                            │
│  5. Activity tracked on every operation         │
│     ↓                                            │
│  6. Client disconnects                          │
│     ↓                                            │
│  7. Final snapshot created                      │
│     ↓                                            │
│  8. Session marked as ended                     │
│     ↓                                            │
│  9. Auto-save stops if room empty               │
│                                                  │
└─────────────────────────────────────────────────┘

Session Properties

When you join a board, a session is created with:

typescript
interface Session {
  id: string;               // Unique session ID
  board_id: string;         // Board UUID
  user_id: string;          // Your user ID
  started_at: Date;         // When session began
  last_activity: Date;      // Last action timestamp
  ended_at: Date | null;    // Session end (null if active)
  status: 'active' | 'ended' | 'timeout';
  client_info: {
    userAgent: string;      // Browser info
    ip: string;             // IP address
  };
}

Session Timeout: Sessions automatically timeout after 30 minutes of inactivity and are cleaned up by a background cron job (runs every 5 minutes).

Joining a Board (Creating a Session)

WebSocket Connection

First, establish a WebSocket connection:

javascript
import io from 'socket.io-client';

const socket = io('https://api.boardapi.io/whiteboard', {
  auth: {
    token: 'your-jwt-token'  // Or API key
  },
  transports: ['websocket'],
  reconnection: true,
  reconnectionDelay: 1000,
  reconnectionDelayMax: 5000,
  reconnectionAttempts: 5
});

socket.on('connect', () => {
  console.log('WebSocket connected');
});

socket.on('connect_error', (error) => {
  console.error('Connection failed:', error);
});

Join Board Event

Emit board:join to create a session:

javascript
const joinBoard = (boardUuid) => {
  return new Promise((resolve, reject) => {
    socket.emit('board:join', { boardUuid }, (response) => {
      if (response.success) {
        console.log('Session created:', response.sessionId);
        resolve(response);
      } else {
        reject(new Error(response.error));
      }
    });
  });
};

// Usage
try {
  const session = await joinBoard('a1b2c3d4-...');
  console.log('Joined board, session ID:', session.sessionId);

  // Session includes:
  // - sessionId: unique identifier
  // - participants: list of active users
  // - boardState: current board snapshot
} catch (error) {
  console.error('Failed to join board:', error);
}

Auto-Save Integration

How Auto-Save Works

When you join a board, the server automatically:

  1. Starts a timer (every 30 seconds)
  2. Creates snapshots of the current board state
  3. Stores to database with metadata (version, timestamp, created_by)
  4. Continues until all participants leave

Snapshot Structure:

typescript
interface BoardSnapshot {
  id: string;               // Snapshot UUID
  board_id: string;         // Board UUID
  snapshot_data: {
    version: number;        // Board version number
    objects: Object[];      // All board objects
    // Complete board state in JSONB format
  };
  version: number;          // Board version at snapshot time
  created_at: Date;         // Snapshot timestamp
  created_by: 'auto-save' | 'manual' | 'on-disconnect';
}

Listening for Auto-Save Events

The server doesn't emit events for every auto-save (performance), but you can track saves via activity:

javascript
socket.on('board:snapshot', (data) => {
  console.log('Auto-save completed:', {
    snapshotId: data.id,
    version: data.version,
    timestamp: data.created_at
  });

  // Update UI to show "Last saved" timestamp
  updateLastSavedIndicator(data.created_at);
});

Manual Snapshot Trigger

You can also trigger manual snapshots:

javascript
const createManualSnapshot = () => {
  return new Promise((resolve, reject) => {
    socket.emit('board:create-snapshot',
      {
        boardUuid: 'your-board-uuid',
        createdBy: 'manual'
      },
      (response) => {
        if (response.success) {
          console.log('Manual snapshot created:', response.snapshot);
          resolve(response.snapshot);
        } else {
          reject(new Error(response.error));
        }
      }
    );
  });
};

// Usage: Save button in UI
document.getElementById('save-btn').addEventListener('click', async () => {
  try {
    const snapshot = await createManualSnapshot();
    showNotification(`Saved at ${new Date(snapshot.created_at).toLocaleTimeString()}`);
  } catch (error) {
    showError('Save failed: ' + error.message);
  }
});

Session Recovery

Client-Side Recovery Implementation

Use localStorage to store board state before page unload:

javascript
// composables/useSessionRecovery.js
export function useSessionRecovery(boardUuid, options = {}) {
  const STORAGE_KEY = `board_${boardUuid}_recovery`;
  const RECOVERY_TIMEOUT = 5 * 60 * 1000; // 5 minutes

  const hasUnsavedChanges = ref(false);
  const recoveryData = ref(null);
  const showRecoveryModal = ref(false);

  // Check for recovery data on mount
  onMounted(() => {
    const stored = localStorage.getItem(STORAGE_KEY);
    if (!stored) return;

    const data = JSON.parse(stored);
    const age = Date.now() - data.timestamp;

    if (age < RECOVERY_TIMEOUT) {
      recoveryData.value = data;
      showRecoveryModal.value = true;
      hasUnsavedChanges.value = true;
    } else {
      // Stale data, discard
      localStorage.removeItem(STORAGE_KEY);
    }
  });

  // Save to localStorage on beforeunload
  const saveToLocalStorage = (state) => {
    const data = {
      state,
      timestamp: Date.now(),
      version: state.version || 0
    };
    localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
  };

  // Restore from recovery data
  const restore = () => {
    if (!recoveryData.value) return;

    const state = recoveryData.value.state;

    // Callback to apply state to canvas
    if (options.onRestore) {
      options.onRestore(state);
    }

    // Clear recovery data
    localStorage.removeItem(STORAGE_KEY);
    recoveryData.value = null;
    showRecoveryModal.value = false;
    hasUnsavedChanges.value = false;
  };

  // Discard recovery data
  const discard = () => {
    localStorage.removeItem(STORAGE_KEY);
    recoveryData.value = null;
    showRecoveryModal.value = false;
    hasUnsavedChanges.value = false;

    if (options.onDiscard) {
      options.onDiscard();
    }
  };

  // View diff between current and recovered state
  const viewDiff = () => {
    if (options.onViewDiff) {
      options.onViewDiff(recoveryData.value);
    }
  };

  // Setup beforeunload handler
  onMounted(() => {
    window.addEventListener('beforeunload', (e) => {
      // Save current state
      const currentState = getCurrentBoardState(); // Your implementation
      saveToLocalStorage(currentState);
    });
  });

  return {
    hasUnsavedChanges,
    recoveryData,
    showRecoveryModal,
    saveToLocalStorage,
    restore,
    discard,
    viewDiff
  };
}

Using Recovery in Components

vue
<template>
  <div class="board-view">
    <!-- Recovery Modal -->
    <RecoveryModal
      v-if="showRecoveryModal"
      :data="recoveryData"
      :auto-discard-time="300"
      @restore="handleRestore"
      @discard="handleDiscard"
      @view-diff="handleViewDiff"
    />

    <!-- Board Canvas -->
    <canvas ref="canvas"></canvas>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import { useSessionRecovery } from '@/composables/useSessionRecovery';

const boardUuid = 'your-board-uuid';

const {
  hasUnsavedChanges,
  recoveryData,
  showRecoveryModal,
  restore,
  discard,
  viewDiff
} = useSessionRecovery(boardUuid, {
  onRestore: (state) => {
    console.log('Restoring state:', state);
    // Apply state to Excalidraw or your canvas
    excalidrawAPI.updateScene({
      elements: state.objects,
      appState: state.appState
    });
  },
  onDiscard: () => {
    console.log('Recovery data discarded');
  },
  onViewDiff: (data) => {
    console.log('View diff:', data);
    // Show diff modal comparing current vs recovered
  }
});

const handleRestore = () => {
  restore();
  showNotification('Session restored successfully');
};

const handleDiscard = () => {
  discard();
  showNotification('Recovery data discarded');
};

const handleViewDiff = () => {
  viewDiff();
};
</script>

Recovery Modal Component

vue
<template>
  <div class="recovery-modal-overlay">
    <div class="recovery-modal">
      <h2>Unsaved Changes Found</h2>

      <p>
        You have unsaved changes from
        <strong>{{ formattedTime }}</strong> ago.
      </p>

      <div class="countdown" v-if="countdown > 0">
        Auto-discard in {{ countdown }} seconds
      </div>

      <div class="actions">
        <button @click="$emit('restore')" class="btn-primary">
          Restore Changes
        </button>
        <button @click="$emit('view-diff')" class="btn-secondary">
          View Diff
        </button>
        <button @click="$emit('discard')" class="btn-danger">
          Discard
        </button>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';

const props = defineProps({
  data: Object,
  autoDiscardTime: {
    type: Number,
    default: 300 // 5 minutes
  }
});

const emit = defineEmits(['restore', 'discard', 'view-diff']);

const countdown = ref(props.autoDiscardTime);
let intervalId = null;

const formattedTime = computed(() => {
  const seconds = Math.floor((Date.now() - props.data.timestamp) / 1000);
  if (seconds < 60) return `${seconds} seconds`;
  const minutes = Math.floor(seconds / 60);
  return `${minutes} minute${minutes > 1 ? 's' : ''}`;
});

onMounted(() => {
  intervalId = setInterval(() => {
    countdown.value--;
    if (countdown.value <= 0) {
      emit('discard');
    }
  }, 1000);
});

onUnmounted(() => {
  if (intervalId) clearInterval(intervalId);
});
</script>

<style scoped>
.recovery-modal-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.7);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 9999;
}

.recovery-modal {
  background: white;
  border-radius: 8px;
  padding: 24px;
  max-width: 500px;
  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2);
}

.countdown {
  margin: 16px 0;
  padding: 8px;
  background: #fff3cd;
  border-radius: 4px;
  text-align: center;
  color: #856404;
}

.actions {
  display: flex;
  gap: 12px;
  margin-top: 20px;
}
</style>

Offline Support

Offline Detection and Queue

javascript
// composables/useOfflineQueue.js
export function useOfflineQueue() {
  const isOnline = ref(navigator.onLine);
  const queue = ref([]);
  const QUEUE_KEY = 'boardapi_offline_queue';

  // Load queue from localStorage on mount
  onMounted(() => {
    const stored = localStorage.getItem(QUEUE_KEY);
    if (stored) {
      queue.value = JSON.parse(stored);
    }

    // Listen for online/offline events
    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);
  });

  const handleOnline = () => {
    isOnline.value = true;
    console.log('Back online, syncing queue...');
    syncQueue();
  };

  const handleOffline = () => {
    isOnline.value = false;
    console.log('Offline mode activated');
  };

  // Queue an operation
  const queueOperation = (operation) => {
    const op = {
      id: generateId(),
      type: operation.type,
      objectId: operation.objectId,
      data: operation.data,
      timestamp: Date.now(),
      retryCount: 0
    };

    queue.value.push(op);
    persistQueue();
  };

  // Persist queue to localStorage
  const persistQueue = () => {
    localStorage.setItem(QUEUE_KEY, JSON.stringify(queue.value));
  };

  // Sync queue when back online
  const syncQueue = async () => {
    if (queue.value.length === 0) return;

    console.log(`Syncing ${queue.value.length} queued operations...`);

    const operations = [...queue.value];
    queue.value = [];
    persistQueue();

    for (const op of operations) {
      try {
        await executeOperation(op);
        console.log(`Synced operation ${op.id}`);
      } catch (error) {
        console.error(`Failed to sync operation ${op.id}:`, error);

        // Re-queue if retry count < 3
        if (op.retryCount < 3) {
          op.retryCount++;
          queue.value.push(op);
        }
      }
    }

    persistQueue();
  };

  // Execute a queued operation
  const executeOperation = async (operation) => {
    switch (operation.type) {
      case 'create':
        return socket.emit('object:create', operation.data);
      case 'update':
        return socket.emit('object:update', operation.data);
      case 'delete':
        return socket.emit('object:delete', { objectId: operation.objectId });
      default:
        throw new Error(`Unknown operation type: ${operation.type}`);
    }
  };

  onUnmounted(() => {
    window.removeEventListener('online', handleOnline);
    window.removeEventListener('offline', handleOffline);
  });

  return {
    isOnline,
    queue,
    queueOperation,
    syncQueue
  };
}

Using Offline Queue

javascript
import { useOfflineQueue } from '@/composables/useOfflineQueue';

const { isOnline, queue, queueOperation } = useOfflineQueue();

// Create object (with offline support)
const createObject = (objectData) => {
  if (!isOnline.value) {
    console.log('Offline: queueing operation');
    queueOperation({
      type: 'create',
      objectId: objectData.id,
      data: objectData
    });

    // Show local optimistic update
    addObjectToLocalState(objectData);
    return;
  }

  // Online: execute immediately
  socket.emit('object:create', objectData);
};

Reconnection Logic

Automatic Reconnection with Exponential Backoff

javascript
class WebSocketService {
  constructor() {
    this.socket = null;
    this.reconnectAttempts = 0;
    this.maxReconnectAttempts = 5;
    this.reconnectTimeouts = [1000, 2000, 4000, 8000, 16000]; // Exponential backoff
    this.isReconnecting = false;
  }

  connect(token) {
    this.socket = io('https://api.boardapi.io/whiteboard', {
      auth: { token },
      transports: ['websocket'],
      reconnection: false // We handle reconnection manually
    });

    this.setupEventHandlers();
  }

  setupEventHandlers() {
    this.socket.on('connect', () => {
      console.log('WebSocket connected');
      this.reconnectAttempts = 0;
      this.isReconnecting = false;
      this.emit('status', { connected: true });
    });

    this.socket.on('disconnect', (reason) => {
      console.log('WebSocket disconnected:', reason);
      this.emit('status', { connected: false, reason });

      if (reason === 'io server disconnect') {
        // Server initiated disconnect, don't reconnect
        return;
      }

      // Attempt reconnection
      this.attemptReconnection();
    });

    this.socket.on('connect_error', (error) => {
      console.error('Connection error:', error);
      this.attemptReconnection();
    });
  }

  attemptReconnection() {
    if (this.reconnectAttempts >= this.maxReconnectAttempts) {
      console.error('Max reconnection attempts reached');
      this.emit('status', {
        connected: false,
        error: 'Max reconnection attempts reached'
      });
      return;
    }

    if (this.isReconnecting) return;
    this.isReconnecting = true;

    const delay = this.reconnectTimeouts[
      Math.min(this.reconnectAttempts, this.reconnectTimeouts.length - 1)
    ];

    console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1}/${this.maxReconnectAttempts})`);

    this.emit('status', {
      reconnecting: true,
      attempt: this.reconnectAttempts + 1,
      maxAttempts: this.maxReconnectAttempts,
      delay
    });

    setTimeout(() => {
      this.reconnectAttempts++;
      this.socket.connect();
      this.isReconnecting = false;
    }, delay);
  }

  getReconnectionStatus() {
    return {
      isReconnecting: this.isReconnecting,
      attempts: this.reconnectAttempts,
      maxAttempts: this.maxReconnectAttempts
    };
  }

  getConnectionUptime() {
    return this.socket?.connected
      ? Date.now() - this.connectionStartTime
      : 0;
  }
}

Displaying Reconnection Status

vue
<template>
  <div v-if="!isConnected" class="reconnection-banner">
    <div v-if="isReconnecting">
      Reconnecting... (attempt {{ reconnectStatus.attempts }}/{{ reconnectStatus.maxAttempts }})
    </div>
    <div v-else class="error">
      Connection lost.
      <button @click="manualReconnect">Reconnect</button>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';

const isConnected = ref(true);
const isReconnecting = ref(false);
const reconnectStatus = ref({ attempts: 0, maxAttempts: 5 });

const websocketService = new WebSocketService();

onMounted(() => {
  websocketService.on('status', (status) => {
    isConnected.value = status.connected || false;
    isReconnecting.value = status.reconnecting || false;
    if (status.attempt) {
      reconnectStatus.value = {
        attempts: status.attempt,
        maxAttempts: status.maxAttempts
      };
    }
  });
});

const manualReconnect = () => {
  websocketService.connect(yourToken);
};
</script>

Activity Tracking

Tracked Events

All write operations update session activity:

javascript
// The server automatically tracks activity for these events:
socket.emit('object:create', data);    // Tracked
socket.emit('object:update', data);    // Tracked
socket.emit('object:delete', data);    // Tracked
socket.emit('cursor:move', data);      // Tracked (non-blocking)

Session Timeout Handling

If a user is inactive for 30 minutes, their session times out:

javascript
socket.on('session:timeout', (data) => {
  console.log('Session timed out after 30 minutes of inactivity');

  // Show notification
  showNotification('Your session has expired due to inactivity. Please rejoin.');

  // Optionally auto-rejoin
  setTimeout(() => {
    rejoinBoard(data.boardUuid);
  }, 2000);
});

Best Practices

Client-Side

  1. Session Recovery

    • Save state to localStorage on beforeunload
    • Check for recovery data on mount
    • Clear recovery data after successful restore/discard
  2. Offline Support

    • Queue operations when offline
    • Persist queue to localStorage (production: IndexedDB)
    • Sync queue when back online
    • Handle conflicts gracefully
  3. Reconnection

    • Use exponential backoff (1s, 2s, 4s, 8s, 16s)
    • Show reconnection status to user
    • Provide manual reconnect button
    • Max 5 reconnection attempts
  4. Performance

    • Throttle cursor move events (non-blocking)
    • Avoid awaiting activity tracking (fire-and-forget)
    • Use debouncing for auto-save triggers

Error Handling

javascript
// Graceful error handling
socket.on('error', (error) => {
  console.error('WebSocket error:', error);

  if (error.code === 'SESSION_EXPIRED') {
    // Session expired, rejoin
    rejoinBoard(boardUuid);
  } else if (error.code === 'BOARD_NOT_FOUND') {
    // Board doesn't exist
    showError('Board not found');
    redirectToDashboard();
  } else {
    // Generic error
    showError('An error occurred: ' + error.message);
  }
});

Troubleshooting

Sessions Not Being Created

Symptoms: No session ID returned from board:join

Solutions:

  1. Verify authentication token is valid
  2. Check board UUID is correct
  3. Ensure WebSocket connection is established
  4. Review server logs for errors

Auto-Save Not Working

Symptoms: No snapshots in database after 30 seconds

Solutions:

  1. Verify session was created successfully
  2. Check if room has active participants
  3. Confirm auto-save interval configuration
  4. Review server logs for snapshot creation errors

Recovery Data Not Showing

Symptoms: Recovery modal doesn't appear after refresh

Solutions:

  1. Check localStorage for recovery data (board_{uuid}_recovery)
  2. Verify data is not stale (>5 minutes old)
  3. Check browser localStorage quota (not exceeded)
  4. Confirm beforeunload handler is saving state

Reconnection Failing

Symptoms: Max attempts reached, no reconnection

Solutions:

  1. Verify WebSocket server is running
  2. Check authentication token is still valid
  3. Review network connectivity
  4. Confirm firewall/proxy allows WebSocket connections

Performance Considerations

Database Optimization

  • Indexes on board_id, user_id, status for fast session lookups
  • GIN indexes on snapshot JSONB for fast queries
  • Partitioning (optional) for time-based snapshot storage

Memory Management

  • Auto-save intervals stored in memory, cleared on stop
  • Session cache (optional) for active sessions
  • Snapshot pruning to limit storage (keep last 50 snapshots)

Network Optimization

  • Exponential backoff prevents server overload during reconnection
  • Cursor throttling (fire-and-forget) reduces bandwidth
  • WebSocket compression enabled by default

Support

Need help with session management?


Last Updated: 2025-11-28 Status: Production-Ready