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:
- Client: Sends initial presence on connect (username, room ID), heartbeats periodically, and removes presence on disconnect.
- 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.
- 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
| Strategy | Timeout | Trade-offs |
|---|---|---|
| No timeout | Never | Stale presence; users offline forever stay online |
| Connection drop only | On WebSocket close | Works but doesn't detect network issues |
| Server-side heartbeat timeout | 30s inactivity | Accurate but adds server complexity |
| Client-side keepalive + server timeout | 15s heartbeat + 45s server timeout | Best: 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.