Front-end Engineering Lab

Presence System

Track and display user online status, last seen, and activity in real-time

Show who's online, when they were last seen, and what they're doing. This guide covers production-ready presence patterns.

🎯 Goals

What we need:
✅ Online/Offline status
✅ Last seen timestamp
✅ Activity status ("typing", "viewing", etc)
✅ Efficient updates
✅ Handle reconnections
✅ Privacy controls

🟢 Pattern 1: Basic Presence

Simple online/offline tracking.

interface PresenceState {
  userId: string;
  status: 'online' | 'offline' | 'away';
  lastSeen: Date;
  device?: 'web' | 'mobile' | 'desktop';
}

class PresenceManager {
  private ws: WebSocket;
  private presenceMap = new Map<string, PresenceState>();
  private heartbeatInterval: NodeJS.Timeout | null = null;
  
  constructor(private userId: string, ws: WebSocket) {
    this.ws = ws;
    this.startHeartbeat();
    this.trackVisibility();
  }
  
  private startHeartbeat() {
    // Send presence every 30 seconds
    this.heartbeatInterval = setInterval(() => {
      this.sendPresence('online');
    }, 30000);
    
    // Send initial presence
    this.sendPresence('online');
  }
  
  private sendPresence(status: PresenceState['status']) {
    this.ws.send(JSON.stringify({
      type: 'presence',
      userId: this.userId,
      status,
      timestamp: Date.now()
    }));
  }
  
  private trackVisibility() {
    document.addEventListener('visibilitychange', () => {
      if (document.hidden) {
        this.sendPresence('away');
      } else {
        this.sendPresence('online');
      }
    });
    
    // Track window focus
    window.addEventListener('blur', () => {
      this.sendPresence('away');
    });
    
    window.addEventListener('focus', () => {
      this.sendPresence('online');
    });
  }
  
  updatePresence(userId: string, state: PresenceState) {
    this.presenceMap.set(userId, state);
    this.notifyListeners(userId, state);
  }
  
  getPresence(userId: string): PresenceState | undefined {
    return this.presenceMap.get(userId);
  }
  
  isOnline(userId: string): boolean {
    const presence = this.presenceMap.get(userId);
    return presence?.status === 'online';
  }
  
  private notifyListeners(userId: string, state: PresenceState) {
    window.dispatchEvent(new CustomEvent('presence-change', {
      detail: { userId, state }
    }));
  }
  
  cleanup() {
    if (this.heartbeatInterval) {
      clearInterval(this.heartbeatInterval);
    }
    this.sendPresence('offline');
  }
}

React Hook

function usePresence(userId: string) {
  const [presence, setPresence] = useState<PresenceState | null>(null);
  
  useEffect(() => {
    const handler = (event: CustomEvent) => {
      if (event.detail.userId === userId) {
        setPresence(event.detail.state);
      }
    };
    
    window.addEventListener('presence-change', handler as EventListener);
    
    return () => {
      window.removeEventListener('presence-change', handler as EventListener);
    };
  }, [userId]);
  
  return presence;
}

// Usage
function UserAvatar({ userId }: { userId: string }) {
  const presence = usePresence(userId);
  
  return (
    <div className="avatar">
      <img src={`/avatars/${userId}.jpg`} alt="Avatar" />
      <span className={`status-indicator ${presence?.status}`}>
        {presence?.status === 'online' && '🟢'}
        {presence?.status === 'away' && '🟡'}
        {presence?.status === 'offline' && '⚫'}
      </span>
    </div>
  );
}

⏰ Pattern 2: Last Seen

Show when user was last active.

class LastSeenTracker {
  private lastSeenMap = new Map<string, Date>();
  
  updateLastSeen(userId: string, timestamp: Date) {
    this.lastSeenMap.set(userId, timestamp);
  }
  
  getLastSeen(userId: string): Date | null {
    return this.lastSeenMap.get(userId) || null;
  }
  
  getLastSeenText(userId: string): string {
    const lastSeen = this.getLastSeen(userId);
    
    if (!lastSeen) {
      return 'Never';
    }
    
    const now = Date.now();
    const diff = now - lastSeen.getTime();
    
    const seconds = Math.floor(diff / 1000);
    const minutes = Math.floor(seconds / 60);
    const hours = Math.floor(minutes / 60);
    const days = Math.floor(hours / 24);
    
    if (seconds < 60) {
      return 'Just now';
    } else if (minutes < 60) {
      return `${minutes}m ago`;
    } else if (hours < 24) {
      return `${hours}h ago`;
    } else if (days < 7) {
      return `${days}d ago`;
    } else {
      return lastSeen.toLocaleDateString();
    }
  }
}

// React Component
function LastSeenIndicator({ userId }: { userId: string }) {
  const presence = usePresence(userId);
  const [lastSeenText, setLastSeenText] = useState('');
  
  useEffect(() => {
    if (presence?.status === 'offline' && presence?.lastSeen) {
      const tracker = new LastSeenTracker();
      tracker.updateLastSeen(userId, presence.lastSeen);
      
      const updateText = () => {
        setLastSeenText(tracker.getLastSeenText(userId));
      };
      
      updateText();
      
      // Update every minute
      const interval = setInterval(updateText, 60000);
      
      return () => clearInterval(interval);
    } else if (presence?.status === 'online') {
      setLastSeenText('Online');
    } else if (presence?.status === 'away') {
      setLastSeenText('Away');
    }
  }, [userId, presence]);
  
  return <span className="last-seen">{lastSeenText}</span>;
}

💼 Pattern 3: Activity Status

Show what users are doing.

type Activity = 
  | { type: 'viewing'; page: string }
  | { type: 'typing'; conversationId: string }
  | { type: 'idle' }
  | { type: 'busy'; reason: string };

interface UserActivity {
  userId: string;
  activity: Activity;
  timestamp: Date;
}

class ActivityTracker {
  private ws: WebSocket;
  private activities = new Map<string, UserActivity>();
  private currentActivity: Activity = { type: 'idle' };
  private activityTimeout: NodeJS.Timeout | null = null;
  
  constructor(private userId: string, ws: WebSocket) {
    this.ws = ws;
    this.trackActivity();
  }
  
  setActivity(activity: Activity) {
    this.currentActivity = activity;
    this.sendActivity();
    
    // Auto-clear after 3 seconds
    if (this.activityTimeout) {
      clearTimeout(this.activityTimeout);
    }
    
    this.activityTimeout = setTimeout(() => {
      this.setActivity({ type: 'idle' });
    }, 3000);
  }
  
  private sendActivity() {
    this.ws.send(JSON.stringify({
      type: 'activity',
      userId: this.userId,
      activity: this.currentActivity,
      timestamp: Date.now()
    }));
  }
  
  private trackActivity() {
    // Track page views
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.entryType === 'navigation') {
          this.setActivity({
            type: 'viewing',
            page: window.location.pathname
          });
        }
      }
    });
    
    observer.observe({ entryTypes: ['navigation'] });
  }
  
  updateActivity(userId: string, activity: UserActivity) {
    this.activities.set(userId, activity);
  }
  
  getActivity(userId: string): UserActivity | undefined {
    return this.activities.get(userId);
  }
  
  getActivityText(userId: string): string {
    const activity = this.getActivity(userId);
    
    if (!activity) {
      return '';
    }
    
    switch (activity.activity.type) {
      case 'viewing':
        return `Viewing ${activity.activity.page}`;
      case 'typing':
        return 'Typing...';
      case 'busy':
        return activity.activity.reason;
      case 'idle':
      default:
        return '';
    }
  }
}

// React Hook
function useActivity(userId: string) {
  const [activity, setActivity] = useState<string>('');
  
  useEffect(() => {
    const handler = (event: CustomEvent) => {
      if (event.detail.userId === userId) {
        const tracker = new ActivityTracker(userId, event.detail.ws);
        setActivity(tracker.getActivityText(userId));
      }
    };
    
    window.addEventListener('activity-change', handler as EventListener);
    
    return () => {
      window.removeEventListener('activity-change', handler as EventListener);
    };
  }, [userId]);
  
  return activity;
}

📊 Pattern 4: Bulk Presence Updates

Efficiently update presence for many users.

class BulkPresenceManager {
  private ws: WebSocket;
  private presenceCache = new Map<string, PresenceState>();
  private subscribedUsers = new Set<string>();
  
  constructor(ws: WebSocket) {
    this.ws = ws;
  }
  
  subscribe(userIds: string[]) {
    userIds.forEach(id => this.subscribedUsers.add(id));
    
    // Request presence for all users
    this.ws.send(JSON.stringify({
      type: 'presence_subscribe',
      userIds: Array.from(this.subscribedUsers)
    }));
  }
  
  unsubscribe(userIds: string[]) {
    userIds.forEach(id => this.subscribedUsers.delete(id));
    
    this.ws.send(JSON.stringify({
      type: 'presence_unsubscribe',
      userIds
    }));
  }
  
  handleBulkUpdate(updates: PresenceState[]) {
    updates.forEach(update => {
      this.presenceCache.set(update.userId, update);
    });
    
    // Notify all listeners
    window.dispatchEvent(new CustomEvent('bulk-presence-update', {
      detail: { updates }
    }));
  }
  
  getPresence(userId: string): PresenceState | undefined {
    return this.presenceCache.get(userId);
  }
  
  getAllPresence(): Map<string, PresenceState> {
    return new Map(this.presenceCache);
  }
}

// React Hook for multiple users
function useBulkPresence(userIds: string[]) {
  const [presenceMap, setPresenceMap] = useState<Map<string, PresenceState>>(new Map());
  
  useEffect(() => {
    const manager = new BulkPresenceManager(ws);
    manager.subscribe(userIds);
    
    const handler = (event: CustomEvent) => {
      setPresenceMap(new Map(manager.getAllPresence()));
    };
    
    window.addEventListener('bulk-presence-update', handler as EventListener);
    
    return () => {
      manager.unsubscribe(userIds);
      window.removeEventListener('bulk-presence-update', handler as EventListener);
    };
  }, [userIds]);
  
  return presenceMap;
}

// Usage - User List
function UserList({ users }: { users: User[] }) {
  const presenceMap = useBulkPresence(users.map(u => u.id));
  
  return (
    <div>
      {users.map(user => {
        const presence = presenceMap.get(user.id);
        return (
          <div key={user.id} className="user-item">
            <img src={user.avatar} alt={user.name} />
            <span>{user.name}</span>
            <span className={`status ${presence?.status}`}>
              {presence?.status === 'online' ? '🟢' : '⚫'}
            </span>
          </div>
        );
      })}
    </div>
  );
}

🔐 Pattern 5: Privacy Controls

Let users control their presence visibility.

type PresenceVisibility = 'everyone' | 'contacts' | 'nobody';

interface PresenceSettings {
  showOnlineStatus: PresenceVisibility;
  showLastSeen: PresenceVisibility;
  showActivity: PresenceVisibility;
}

class PrivatePresenceManager {
  private settings: PresenceSettings;
  
  constructor(settings: PresenceSettings) {
    this.settings = settings;
  }
  
  shouldShowOnlineStatus(viewerId: string, isContact: boolean): boolean {
    switch (this.settings.showOnlineStatus) {
      case 'everyone':
        return true;
      case 'contacts':
        return isContact;
      case 'nobody':
        return false;
    }
  }
  
  shouldShowLastSeen(viewerId: string, isContact: boolean): boolean {
    switch (this.settings.showLastSeen) {
      case 'everyone':
        return true;
      case 'contacts':
        return isContact;
      case 'nobody':
        return false;
    }
  }
  
  shouldShowActivity(viewerId: string, isContact: boolean): boolean {
    switch (this.settings.showActivity) {
      case 'everyone':
        return true;
      case 'contacts':
        return isContact;
      case 'nobody':
        return false;
    }
  }
  
  getVisiblePresence(
    presence: PresenceState,
    viewerId: string,
    isContact: boolean
  ): Partial<PresenceState> {
    const visible: Partial<PresenceState> = {
      userId: presence.userId
    };
    
    if (this.shouldShowOnlineStatus(viewerId, isContact)) {
      visible.status = presence.status;
    }
    
    if (this.shouldShowLastSeen(viewerId, isContact)) {
      visible.lastSeen = presence.lastSeen;
    }
    
    return visible;
  }
}

// React Component - Privacy Settings
function PresenceSettings() {
  const [settings, setSettings] = useState<PresenceSettings>({
    showOnlineStatus: 'everyone',
    showLastSeen: 'contacts',
    showActivity: 'contacts'
  });
  
  const updateSetting = (key: keyof PresenceSettings, value: PresenceVisibility) => {
    setSettings(prev => ({ ...prev, [key]: value }));
    
    // Save to server
    fetch('/api/settings/presence', {
      method: 'PUT',
      body: JSON.stringify({ ...settings, [key]: value })
    });
  };
  
  return (
    <div className="presence-settings">
      <h3>Privacy Settings</h3>
      
      <div>
        <label>Show online status:</label>
        <select
          value={settings.showOnlineStatus}
          onChange={(e) => updateSetting('showOnlineStatus', e.target.value as PresenceVisibility)}
        >
          <option value="everyone">Everyone</option>
          <option value="contacts">Contacts only</option>
          <option value="nobody">Nobody</option>
        </select>
      </div>
      
      <div>
        <label>Show last seen:</label>
        <select
          value={settings.showLastSeen}
          onChange={(e) => updateSetting('showLastSeen', e.target.value as PresenceVisibility)}
        >
          <option value="everyone">Everyone</option>
          <option value="contacts">Contacts only</option>
          <option value="nobody">Nobody</option>
        </select>
      </div>
      
      <div>
        <label>Show activity:</label>
        <select
          value={settings.showActivity}
          onChange={(e) => updateSetting('showActivity', e.target.value as PresenceVisibility)}
        >
          <option value="everyone">Everyone</option>
          <option value="contacts">Contacts only</option>
          <option value="nobody">Nobody</option>
        </select>
      </div>
    </div>
  );
}

📚 Key Takeaways

  1. Heartbeat every 30s - Keep presence fresh
  2. Track visibility - Go "away" when tab hidden
  3. Last seen - Update on every activity
  4. Bulk updates - Don't subscribe to 1000 users individually
  5. Privacy controls - Let users choose visibility
  6. Activity status - Clear after 3s of inactivity
  7. Test reconnections - Presence should restore

Presence is expected in modern apps. Implement it early, not as an afterthought.

On this page