The challenge of tracking who's actually online
We've all seen that green dot next to a username and wondered: is that person really there, or did their app crash? Building a presence system — patterns, pitfalls and fixes — requires more than just watching connection events. In production, you'll face network flakiness, multiple devices per user, and the cost of broadcasting updates to thousands of team members. Getting it wrong means false confidence about availability, wasted notifications, and frustrated users.
This article walks through a battle-tested approach to presence tracking used in production chat, collaboration, and SaaS platforms. We'll cover connection-based presence, multi-device logic, automatic expiration with heartbeats, and selective broadcasting to keep your system accurate and performant.
Connection-based presence: the foundation
The starting point for any presence system is simple: a user is online if they have an active WebSocket or other persistent connection to your backend. Rather than relying on database updates or polling, you track connections in Redis with minimal latency and maximum scalability.
Here's the core pattern. When a client connects, you store a record keyed by user ID and connection ID:
// On WebSocket connect
const connectionId = generateId();
const userId = getUserIdFromToken(socket.handshake.auth.token);
// Store in Redis with user ID and connection identifier
await redis.hset(
`presence:${userId}`,
connectionId,
JSON.stringify({
connectedAt: Date.now(),
deviceType: socket.handshake.query.deviceType || 'web',
userAgent: socket.handshake.headers['user-agent'],
})
);
// Broadcast presence change to interested parties
await broadcastPresenceChange(userId, 'online');
When a connection closes, you remove it immediately:
// On disconnect
socket.on('disconnect', async () => {
await redis.hdel(`presence:${userId}`, connectionId);
// Only broadcast offline if user has NO remaining connections
const connections = await redis.hgetall(`presence:${userId}`);
if (Object.keys(connections).length === 0) {
await broadcastPresenceChange(userId, 'offline');
}
});
This approach is fast — Redis operations complete in microseconds — and scales horizontally. Each server only needs to track its own connected users in memory or in Redis, then aggregate on demand. No database load, no race conditions between servers.
Multiple devices: one user, many connections
A modern user might be online via web, mobile, and desktop simultaneously. Your presence system must reflect this: a user is online if any device is connected. Broadcasting separate "online" and "offline" events for each device creates noise and confusion.
The Redis hash approach above already handles this cleanly. Since you key by both user ID and connection ID, you can have multiple entries under one user. The logic then becomes:
async function getUserPresence(userId: string) {
const connections = await redis.hgetall(`presence:${userId}`);
return {
isOnline: Object.keys(connections).length > 0,
connectionCount: Object.keys(connections).length,
devices: Object.values(connections).map(conn =>
JSON.parse(conn).deviceType
),
lastSeen: Object.values(connections)
.map(conn => JSON.parse(conn).connectedAt)
.sort((a, b) => b - a)[0] || null,
};
}
When broadcasting presence changes to your team or chat, you only emit the high-level "online" or "offline" state once, regardless of device count:
async function broadcastPresenceChange(
userId: string,
state: 'online' | 'offline'
) {
// Find all relevant teams or chat channels for this user
const subscriptions = await redis.smembers(`user:${userId}:subscriptions`);
for (const channelId of subscriptions) {
// Broadcast to team members only
io.to(`team:${channelId}`).emit('presence:changed', {
userId,
state,
timestamp: Date.now(),
});
}
}
This avoids spamming "device1 online, device2 online, device3 online" and instead sends a single, definitive update: "Alice is online." Clients then update their UI once rather than flickering through multiple transitions.
Expire automatically: heartbeats over disconnect events
Here's a hard-won lesson: don't rely on disconnect events to clean up presence. Network failures, container restarts, and abrupt client crashes mean you often won't see a proper close. Your presence system will claim users are online long after they've disappeared.
Instead, use Redis TTLs (time-to-live) refreshed by client heartbeats. Each connection record expires automatically unless the client proves it's still alive:
// On connect, set initial TTL
const TTL_SECONDS = 60;
await redis.hset(
`presence:${userId}`,
connectionId,
JSON.stringify({
connectedAt: Date.now(),
deviceType: socket.handshake.query.deviceType,
}),
'EX',
TTL_SECONDS
);
// Also set a key that expires to trigger offline notifications
await redis.setex(
`presence:check:${userId}`,
TTL_SECONDS,
'1'
);
Client sends a heartbeat every 30 seconds (half the TTL):
// Client-side: send heartbeat
setInterval(() => {
socket.emit('heartbeat', { deviceType: 'web' });
}, 30000);
// Server-side: refresh TTL
socket.on('heartbeat', async (payload) => {
await redis.expire(`presence:${userId}`, TTL_SECONDS);
await redis.expire(`presence:check:${userId}`, TTL_SECONDS);
});
Use Redis keyspace notifications or a background job to detect when a user's presence expires:
// Background job that runs every 10 seconds
setInterval(async () => {
// Check which users' presence keys are about to expire
// This is a simplified approach; use keyspace notifications in production
const allPresenceKeys = await redis.keys('presence:*');
for (const key of allPresenceKeys) {
const ttl = await redis.ttl(key);
// If TTL is very low or expired, user is offline
if (ttl <= 0) {
const userId = key.split(':')[1];
await broadcastPresenceChange(userId, 'offline');
}
}
}, 10000);
This pattern handles network volatility gracefully. A brief disconnection (network hiccup, WiFi switch) doesn't immediately mark the user offline. They have a grace period to reconnect and refresh their TTL. Only after sustained absence do they truly go offline.
Broadcast changes carefully: only notify those who care
Broadcasting every presence change to every user creates a scalability wall. In a Slack-like system with thousands of members, sending "user X went online" to everyone burns through bandwidth and processing. Instead, only notify users who actually care: team members, chat participants, shared project members.
Build a subscription model using Redis sets:
// When user joins a channel or team
async function subscribeUserToTeam(userId: string, teamId: string) {
// Track that this user cares about team presence
await redis.sadd(`team:${teamId}:subscribers`, userId);
// Track that this user subscribes to this team
await redis.sadd(`user:${userId}:subscriptions`, teamId);
}
// When broadcasting presence changes
async function broadcastPresenceChange(
userId: string,
state: 'online' | 'offline'
) {
// Find all teams where this user is a member
const subscribedTeams = await redis.smembers(
`user:${userId}:subscriptions`
);
for (const teamId of subscribedTeams) {
// Notify only subscribers of that team
const subscribers = await redis.smembers(`team:${teamId}:subscribers`);
for (const subscriberId of subscribers) {
// Send to that subscriber's connected sockets
const subscriberSockets = await redis.hgetall(
`presence:${subscriberId}`
);
io.to(Array.from(Object.keys(subscriberSockets))).emit(
'presence:changed',
{
userId,
state,
teamId,
timestamp: Date.now(),
}
);
}
}
}
This is more efficient than Redis Pub/Sub for presence because you're targeting specific users, not broadcasting to a topic. You trade memory (subscription tracking) for bandwidth savings (fewer irrelevant messages).
Common mistakes
- Trusting disconnect events. Don't rely on clean socket closure to mark users offline. Use TTLs and heartbeats.
- Broadcasting to everyone. Sending presence updates to all connected clients creates exponential message growth. Subscribe only the relevant users.
- Storing presence in the database. Database writes and queries are too slow for real-time presence. Use Redis or in-memory stores.
- Single device per user. Build multi-device support from the start. It's common and adds only a hash instead of a simple key.
- No jitter on heartbeats. If all clients heartbeat at the same second, your server gets hammered. Add randomness: 30 + random(0, 10) seconds.
- Infinite TTL or no expiration. Without TTLs, presence keys accumulate forever and consume memory. Always set an explicit expiration.
Key takeaways
- Track connections in Redis with user ID + connection ID pairs in a hash. This scales horizontally and avoids database load.
- A user is online if they have any active connection. Broadcast a single "online" state regardless of device count.
- Use Redis TTLs refreshed by client heartbeats instead of relying on disconnect events. Network flakiness is inevitable.
- Maintain subscription lists so presence updates reach only users who care (team members, chat participants). Broadcast selectively, not to everyone.
- In production, monitor heartbeat latency and presence accuracy. Alert if TTL expiration exceeds your grace period, indicating a systemic issue.






Comments
Loading comments…