Skip to main content

Real-Time Presence: Online Status and Indicators

Presence is real-time information about who is online, where they are, and what they're doing. Slack shows a green dot next to online users; Figma displays collaborators' cursors. Implementing presence requires tracking user connections on the server and broadcasting status changes to all clients. Presence timeout detection—assuming a user is offline after 30 seconds of inactivity—ensures stale connections don't clutter the UI (Crater Labs, 2026).

Core Presence Architecture

A presence system has three components:

  1. Client: Sends initial presence on connect (username, room ID), heartbeats periodically, and removes presence on disconnect.
  2. Server: Stores active users in memory (or a pub/sub broker like Redis), broadcasts presence changes to all clients in the room, and times out inactive users.
  3. UI: Displays online users, their status (away, do-not-disturb), and updates on presence changes.

Server-Side Presence with Node.js

Here's a minimal presence server using a pub/sub pattern:

import WebSocket from 'ws';
import http from 'http';

const server = http.createServer();
const wss = new WebSocket.Server({ server });

// Store active users: roomId -> Set of user objects
const rooms = new Map();

wss.on('connection', (ws) => {
let userId = null;
let roomId = null;
let heartbeatTimer = null;

ws.on('message', (data) => {
try {
const msg = JSON.parse(data);

if (msg.type === 'presence-join') {
userId = msg.userId;
roomId = msg.roomId;

// Initialize room if needed
if (!rooms.has(roomId)) {
rooms.set(roomId, new Map());
}

const room = rooms.get(roomId);
room.set(userId, {
userId,
username: msg.username,
status: 'online',
joinedAt: Date.now(),
});

// Start heartbeat timer; clear on message
heartbeatTimer = setTimeout(() => {
// User is inactive; mark as away
const user = room.get(userId);
if (user) {
user.status = 'away';
broadcastPresence(roomId);
}
}, 30000); // 30 second timeout

broadcastPresence(roomId);
}

if (msg.type === 'heartbeat') {
// User is still active; reset timer
if (heartbeatTimer) clearTimeout(heartbeatTimer);

const user = room?.get(userId);
if (user && user.status === 'away') {
user.status = 'online';
}

heartbeatTimer = setTimeout(() => {
const u = room?.get(userId);
if (u) u.status = 'away';
broadcastPresence(roomId);
}, 30000);
}
} catch (error) {
console.error('Parse error:', error);
}
});

ws.on('close', () => {
if (heartbeatTimer) clearTimeout(heartbeatTimer);
if (roomId && userId) {
const room = rooms.get(roomId);
if (room) {
room.delete(userId);
broadcastPresence(roomId);
if (room.size === 0) rooms.delete(roomId);
}
}
});
});

function broadcastPresence(roomId) {
const room = rooms.get(roomId);
if (!room) return;

const presenceList = Array.from(room.values());
const message = JSON.stringify({
type: 'presence-update',
users: presenceList,
});

// Broadcast to all clients in the room
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
// Ideally, filter by roomId; simplified here
client.send(message);
}
});
}

server.listen(3000, () => {
console.log('Presence server on ws://localhost:3000');
});

Client-Side Presence Hook

Create a hook that joins a room, sends heartbeats, and listens for presence updates:

import { useEffect, useState, useRef } from 'react';
import { useWebSocket } from './useWebSocket'; // From article 4

export function usePresence(userId, username, roomId) {
const [presentUsers, setPresentUsers] = useState([]);
const heartbeatIntervalRef = useRef(null);
const { connected, send } = useWebSocket('wss://your-server.example.com', {
onMessage: (msg) => {
if (msg.type === 'presence-update') {
setPresentUsers(msg.users);
}
},
onOpen: () => {
// Send initial presence join
send({
type: 'presence-join',
userId,
username,
roomId,
});

// Send heartbeat every 15 seconds to stay "online"
heartbeatIntervalRef.current = setInterval(() => {
send({ type: 'heartbeat', userId, roomId });
}, 15000);
},
onClose: () => {
if (heartbeatIntervalRef.current) {
clearInterval(heartbeatIntervalRef.current);
}
},
});

useEffect(() => {
return () => {
if (heartbeatIntervalRef.current) {
clearInterval(heartbeatIntervalRef.current);
}
};
}, []);

return presentUsers;
}

Presence UI Component

Display online users with status indicators:

import { usePresence } from './usePresence';

export function RoomPresence() {
const presentUsers = usePresence('user-123', 'Alice', 'room-abc');

const onlineCount = presentUsers.filter((u) => u.status === 'online').length;
const awayCount = presentUsers.filter((u) => u.status === 'away').length;

return (
`<div style={{ padding: '10px', borderRadius: '8px', backgroundColor: '#f5f5f5' }}>`
`<h3>`Users in room ({onlineCount + awayCount})`</h3>`
`<div>`
{presentUsers.map((user) => (
`<div key={user.userId} style={{ display: 'flex', gap: '8px', marginBottom: '8px' }}>`
`<span`
style={{
display: 'inline-block',
width: '10px',
height: '10px',
borderRadius: '50%',
backgroundColor: user.status === 'online' ? '#4caf50' : '#9e9e9e',
}}
`/>`
`<span>`{user.username}`</span>`
{user.status === 'away' && `<span style={{ fontSize: '0.9em', color: '#666' }}`(away)`</span>`}
`</div>`
))}
`</div>`
`</div>`
);
}

Advanced: Cursor Tracking and Awareness

For collaborative editing, broadcast cursor position in real-time:

export function CursorAwareness() {
const { send } = useWebSocket('wss://your-server.example.com');
const [remoteCursors, setRemoteCursors] = useState({});

// Send cursor position on mouse move (throttled)
useEffect(() => {
let lastSent = 0;

const handleMouseMove = (e) => {
const now = Date.now();
if (now - lastSent < 100) return; // Throttle to 10 updates/sec

send({
type: 'cursor-move',
userId: 'user-123',
x: e.clientX,
y: e.clientY,
});
lastSent = now;
};

window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
}, [send]);

// Listen for remote cursor updates
useWebSocket('wss://your-server.example.com', {
onMessage: (msg) => {
if (msg.type === 'remote-cursor') {
setRemoteCursors((prev) => ({
...prev,
[msg.userId]: { x: msg.x, y: msg.y, username: msg.username },
}));
}
},
});

return (
`<div>`
{/* Render remote cursors */}
{Object.entries(remoteCursors).map(([userId, { x, y, username }]) => (
`<div`
key={userId}
style={{
position: 'fixed',
left: x,
top: y,
pointerEvents: 'none',
}}
`>`
`<div style={{ color: 'blue', fontSize: '12px' }}>`{username}`</div>`
`<div style={{ width: '8px', height: '8px', backgroundColor: 'blue' }} />`
`</div>`
))}
`</div>`
);
}

Comparison: Presence Timeout Strategies

StrategyTimeoutTrade-offs
No timeoutNeverStale presence; users offline forever stay online
Connection drop onlyOn WebSocket closeWorks but doesn't detect network issues
Server-side heartbeat timeout30s inactivityAccurate but adds server complexity
Client-side keepalive + server timeout15s heartbeat + 45s server timeoutBest: handles network issues and detects death

Key Takeaways

  • Presence tracks who's online and their status (online, away, do-not-disturb).
  • Send heartbeats every 10–20 seconds to prevent timeout; set server timeout at 2–3x heartbeat interval.
  • Broadcast presence changes to all clients in a room; use pub/sub brokers (Redis) for scale.
  • Display presence in the UI with visual indicators (green dot, status label, avatars).
  • Advanced: track cursor position and typing indicators for collaboration.

Frequently Asked Questions

How do I scale presence to millions of users?

Use a pub/sub broker like Redis or Message Queue (RabbitMQ). Each server instance subscribes to a presence channel and broadcasts updates. With Redis, store presence as a Hash and use HGETALL for room snapshots.

Can I persist presence across server restarts?

Presence is usually ephemeral. If a user reconnects, they join as a new session. For persistent state (e.g., "last seen"), store that in a database separately.

How do I detect away vs. offline?

Away = no keyboard/mouse input for 30s (client-side idle detection). Offline = WebSocket disconnected. Combine both: mark away after idle, mark offline on disconnect.

What if a user closes their laptop without disconnecting?

Set a server timeout. If no heartbeat arrives in 45 seconds, assume offline and remove from presence. The client's reconnection logic (from article 4) will re-join when the laptop wakes.

Further Reading