Presence
User presence system design for tracking online/offline status, typing indicators, and activity in real-time apps
You are an expert in designing user presence systems for real-time applications. ## Key Points - **Online** — the user has an active connection - **Idle / Away** — the user is connected but inactive (no input events for N minutes) - **Do Not Disturb** — user-set status that suppresses notifications - **Offline** — no active connection and grace period has expired - **Use grace periods before marking offline** — connections drop and reconnect frequently. A 5-15 second grace period avoids flickering status. - **Throttle presence broadcasts** — batch presence changes and send updates at most every few seconds to avoid overwhelming clients. - **Use subscription-based fanout** — only notify users who are viewing a contact list or channel with the changed user, not every connected client. - **Store presence in Redis with TTLs** — this handles server crashes gracefully. If a server dies, its users' presence keys expire automatically. - **Debounce typing indicators** — send `typing-start` on the first keystroke, then suppress further signals until a pause. Auto-expire on the receiver side. - **Provide a bulk presence query endpoint** — when a user opens a contact list, fetch all statuses in one call rather than N individual queries. - **Treating connection = online** — a socket being open does not mean the user is actively present. Combine connection status with activity signals. - **Not handling multi-device** — a user may be connected from phone and laptop. Going offline on one device should not mark them offline globally. Track per-device and aggregate.
skilldb get websocket-skills/PresenceFull skill: 277 linesPresence Systems — WebSockets & Real-Time
You are an expert in designing user presence systems for real-time applications.
Overview
A presence system tracks and broadcasts the real-time status of users — online, offline, idle, typing, in a call, etc. Presence is a foundational feature in chat apps, collaboration tools, multiplayer games, and any product where users need awareness of each other. Building presence well requires handling unreliable connections, scale, and the inherent ambiguity of "online" status.
Core Concepts
Presence States
A typical presence model includes:
- Online — the user has an active connection
- Idle / Away — the user is connected but inactive (no input events for N minutes)
- Do Not Disturb — user-set status that suppresses notifications
- Offline — no active connection and grace period has expired
Heartbeats and TTLs
Connections can die without a clean close (network failure, laptop lid close). Presence systems use heartbeats — periodic signals from the client — combined with server-side TTLs to detect stale connections.
Presence Fanout
When a user's status changes, every user who cares must be notified. In a naive implementation, this is O(N * M) where N is the number of status changes and M is the number of interested subscribers. Efficient fanout is the primary scaling challenge.
Implementation Patterns
Basic Presence Tracking
// Server: track presence with heartbeats
const presenceMap = new Map(); // userId -> { status, lastSeen, socketId }
const HEARTBEAT_INTERVAL = 15000; // Client sends every 15s
const PRESENCE_TTL = 45000; // Consider offline after 45s
io.on('connection', (socket) => {
const userId = socket.data.user.id;
// Mark online
setPresence(userId, 'online', socket.id);
broadcastPresence(userId, 'online');
// Heartbeat
socket.on('heartbeat', () => {
setPresence(userId, 'online', socket.id);
});
// Client reports idle
socket.on('status', (status) => {
if (['online', 'idle', 'dnd'].includes(status)) {
setPresence(userId, status, socket.id);
broadcastPresence(userId, status);
}
});
socket.on('disconnect', () => {
// Use a grace period before marking offline
setTimeout(() => {
const current = presenceMap.get(userId);
if (current && current.socketId === socket.id) {
setPresence(userId, 'offline', null);
broadcastPresence(userId, 'offline');
}
}, 5000); // 5s grace for reconnection
});
});
function setPresence(userId, status, socketId) {
presenceMap.set(userId, { status, lastSeen: Date.now(), socketId });
}
function broadcastPresence(userId, status) {
io.emit('presence', { userId, status, timestamp: Date.now() });
}
// Sweep stale entries
setInterval(() => {
const now = Date.now();
for (const [userId, entry] of presenceMap) {
if (entry.status !== 'offline' && now - entry.lastSeen > PRESENCE_TTL) {
setPresence(userId, 'offline', null);
broadcastPresence(userId, 'offline');
}
}
}, 10000);
Client-Side Idle Detection
// Client: detect idle/active state
let idleTimer;
const IDLE_TIMEOUT = 120000; // 2 minutes
function resetIdleTimer() {
clearTimeout(idleTimer);
if (currentStatus === 'idle') {
socket.emit('status', 'online');
}
idleTimer = setTimeout(() => {
socket.emit('status', 'idle');
}, IDLE_TIMEOUT);
}
['mousemove', 'keydown', 'mousedown', 'touchstart', 'scroll'].forEach((event) => {
document.addEventListener(event, resetIdleTimer, { passive: true });
});
// Heartbeat
setInterval(() => socket.emit('heartbeat'), 15000);
// Visibility change
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
socket.emit('status', 'idle');
} else {
socket.emit('status', 'online');
resetIdleTimer();
}
});
Redis-Backed Presence at Scale
import Redis from 'ioredis';
const redis = new Redis();
const pub = new Redis();
const sub = new Redis();
const PRESENCE_KEY_PREFIX = 'presence:';
const PRESENCE_TTL_SECONDS = 45;
async function setPresence(userId, status, serverId) {
const key = `${PRESENCE_KEY_PREFIX}${userId}`;
await redis.hset(key, { status, serverId, updatedAt: Date.now().toString() });
await redis.expire(key, PRESENCE_TTL_SECONDS);
// Publish change to all server instances
await pub.publish('presence-updates', JSON.stringify({ userId, status }));
}
async function getPresence(userId) {
const data = await redis.hgetall(`${PRESENCE_KEY_PREFIX}${userId}`);
if (!data || !data.status) return { status: 'offline' };
return data;
}
async function getBulkPresence(userIds) {
const pipeline = redis.pipeline();
userIds.forEach((id) => pipeline.hgetall(`${PRESENCE_KEY_PREFIX}${id}`));
const results = await pipeline.exec();
return userIds.map((id, i) => ({
userId: id,
status: results[i][1]?.status || 'offline',
}));
}
// Refresh TTL on heartbeat
async function heartbeat(userId, serverId) {
const key = `${PRESENCE_KEY_PREFIX}${userId}`;
await redis.hset(key, { updatedAt: Date.now().toString() });
await redis.expire(key, PRESENCE_TTL_SECONDS);
}
// Subscribe to presence changes across servers
sub.subscribe('presence-updates');
sub.on('message', (channel, message) => {
const { userId, status } = JSON.parse(message);
// Broadcast to locally connected clients who care about this user
notifyLocalSubscribers(userId, status);
});
Typing Indicators
// Server
socket.on('typing-start', ({ channelId }) => {
socket.to(channelId).emit('typing', {
userId: socket.data.user.id,
name: socket.data.user.name,
channelId,
});
});
socket.on('typing-stop', ({ channelId }) => {
socket.to(channelId).emit('typing-stopped', {
userId: socket.data.user.id,
channelId,
});
});
// Client: debounced typing indicator
let typingTimeout;
const TYPING_DEBOUNCE = 2000;
inputElement.addEventListener('input', () => {
if (!typingTimeout) {
socket.emit('typing-start', { channelId: currentChannel });
}
clearTimeout(typingTimeout);
typingTimeout = setTimeout(() => {
socket.emit('typing-stop', { channelId: currentChannel });
typingTimeout = null;
}, TYPING_DEBOUNCE);
});
// Client: render typing indicators with auto-expiry
const typingUsers = new Map();
socket.on('typing', ({ userId, name, channelId }) => {
if (channelId !== currentChannel) return;
typingUsers.set(userId, { name, expires: Date.now() + 3000 });
renderTypingIndicator();
});
socket.on('typing-stopped', ({ userId }) => {
typingUsers.delete(userId);
renderTypingIndicator();
});
// Expire stale typing indicators
setInterval(() => {
const now = Date.now();
for (const [userId, data] of typingUsers) {
if (now > data.expires) typingUsers.delete(userId);
}
renderTypingIndicator();
}, 1000);
Best Practices
- Use grace periods before marking offline — connections drop and reconnect frequently. A 5-15 second grace period avoids flickering status.
- Throttle presence broadcasts — batch presence changes and send updates at most every few seconds to avoid overwhelming clients.
- Use subscription-based fanout — only notify users who are viewing a contact list or channel with the changed user, not every connected client.
- Store presence in Redis with TTLs — this handles server crashes gracefully. If a server dies, its users' presence keys expire automatically.
- Debounce typing indicators — send
typing-starton the first keystroke, then suppress further signals until a pause. Auto-expire on the receiver side. - Provide a bulk presence query endpoint — when a user opens a contact list, fetch all statuses in one call rather than N individual queries.
Common Pitfalls
- Treating connection = online — a socket being open does not mean the user is actively present. Combine connection status with activity signals.
- Not handling multi-device — a user may be connected from phone and laptop. Going offline on one device should not mark them offline globally. Track per-device and aggregate.
- Presence storms at scale — if 10,000 users are online and each tracks 100 contacts, a single status change can trigger massive fanout. Use interest-based subscription rather than global broadcast.
- Stale state on reconnect — after reconnecting, the client's view of other users' presence is outdated. Always fetch fresh presence state on reconnect.
- Over-precise timestamps — showing "last seen 37 seconds ago" that updates every second is noisy. Bucket into "just now", "a few minutes ago", "1 hour ago".
Core Philosophy
Presence is the bridge between the digital and physical worlds in your application. It answers the question "is this person here right now?" — a question that seems simple but is surprisingly difficult to answer accurately. A connected socket does not mean a present user; an active heartbeat does not mean an engaged user; and a disconnected socket does not immediately mean the user is gone. Layer multiple signals (connection state, heartbeat freshness, input activity, tab visibility) to build an accurate, nuanced picture of user presence.
Grace periods are essential for a calm user experience. Network connections drop and reconnect constantly — WiFi handoffs, cellular dead zones, server deployments. Without a grace period, a user who loses connection for 2 seconds appears to go offline and come back online, creating a flickering status that annoys everyone watching. A 5-15 second grace period smooths over these transient disconnections.
Presence at scale is primarily a fanout problem. When a user's status changes, every subscriber must be notified. In a naive implementation, this is O(N * M) where N is status changes and M is subscribers. Efficient presence systems use subscription-based fanout (only notify users who are viewing a relevant contact list or channel), Redis pub/sub for cross-server coordination, and throttled batch updates to prevent overwhelming clients.
Anti-Patterns
-
Equating connection status with user presence — a socket being open does not mean the user is actively present; combine connection state with heartbeats, input activity, and tab visibility for an accurate status.
-
Broadcasting every presence change globally — sending a status update to all connected clients when one user goes idle creates O(N) messages for every status change; use interest-based subscription so only relevant users are notified.
-
Not handling multi-device presence — a user connected from phone and laptop should show as online if either device is active; tracking only per-socket presence causes incorrect global status when one device disconnects.
-
Sending typing indicators without debouncing — emitting a typing event on every keystroke floods the network; send
typing-starton the first keystroke, then suppress until a pause, and auto-expire on the receiver side. -
Not fetching fresh presence state on reconnect — after a disconnection, the client's view of other users' presence is stale; always request a bulk presence update when the connection is re-established.
Install this skill directly: skilldb add websocket-skills
Related Skills
Chat Rooms
Chat room architecture covering message routing, history, moderation, and scalable room-based real-time systems
Collaborative Editing
Real-time collaborative editing with CRDTs and Operational Transform for conflict-free concurrent document editing
Reconnection
Reconnection and offline resilience patterns for WebSocket apps including retry strategies and state synchronization
Scaling Websockets
Scaling WebSocket applications with Redis pub/sub, sticky sessions, horizontal scaling, and load balancing strategies
Server Sent Events
Server-Sent Events (SSE) patterns for efficient unidirectional real-time streaming from server to client
Socket Io
Socket.IO patterns for event-driven real-time communication with automatic reconnection and room management