Distributed presence tracking using Memcached
The online/offline status system is a real-time feature that tells users which contacts are currently available. This article explains how presence is tracked across a distributed, multi-server architecture using Memcached as the central session store.
Instead of storing user status in the database (which would require constant updates and queries), we use Memcached, a fast, in-memory cache system, to track active user sessions. Each time a user connects, we create an entry with a TTL of 60 seconds. When they disconnect, the TTL expires and the entry gets removed.
| Approach | Pros | Cons |
|---|---|---|
| Database | Persistent, queryable | Slow, frequent updates, storage overhead |
| Memcached | Ultra-fast, perfect for transient data | Data lost on server restart (acceptable for online status) |
| Redis | Fast + persistence options | Slightly heavier than Memcached for this use case |
For presence status, Memcached is ideal because:
Presence System Architecture
Format: user:{userId}:online
Example: user:123:online
Value: { serverId: 'server-1', connectedAt: 1234567890 }
TTL: 60 seconds (with heartbeat refresh)
// When user connects via WebSocket
socket.on('connect', async () => {
const userId = socket.data.userId // From JWT
const serverId = process.env.SERVER_ID // e.g., 'server-1'
// Create entry in Memcached
const sessionKey = `user:${userId}:online`
await memcached.set(
sessionKey,
{
serverId,
connectedAt: Date.now(),
socketId: socket.id,
},
60 // 60 second expiration
)
})
To keep sessions alive, we use a heartbeat. Since we are using Socket.IO, we can utilize the inbuilt Socket.IO ping events as heartbeats to refresh presence status in Memcached. If we refresh the status on Memcached on every ping, then Memcached will be overwhelmed with queries. So we accumulate all pings from all users for 5 seconds and then perform a batch refresh.
const pingTrackingSet = new Set<number>()
function setupPingTracking(socket: Socket, userId: number) {
// Monitor socket.io ping packets
socket.conn.on('packet', (packet: { type: string }) => {
if (packet.type === 'pong') {
pingTrackingSet.add(userId)
}
})
}
function getAndClearPingTrackingSet(): number[] {
const userIds = Array.from(pingTrackingSet)
pingTrackingSet.clear()
return userIds
}
// Start periodic flush of ping tracking to memcached
setInterval(async () => {
try {
const userIds = getAndClearPingTrackingSet()
if (userIds.length > 0) {
await memcachedService.setBatchOnline(userIds, ONLINE_STATUS_TTL)
}
} catch (error) {
console.error('Error flushing ping tracking to memcached:', error)
}
}, PING_FLUSH_INTERVAL_MS) // 5 seconds
When a user disconnects, we simply ignore. Since the user has disconnected, no heartbeats will arrive from the user, and the Memcached entry won't be refreshed. Hence the presence entry will expire after the TTL, that is, 60 seconds.
The client "pulls" the presence status of the visible users periodically. Also if the document is not visible on screen, for example if the user has minimized the screen, then we pause the "pulls". Document visibility is detected with the Page Visibility API.
Online/Offline Status Flow