How to Track Online Users in Redis
Knowing who is currently online powers features like "5 friends are active," green status dots in chat applications, and "3 people are viewing this document." The challenge is that "online" is fuzzy. Users do not explicitly log out. They close browser tabs, lose network connections, or just walk away. A presence system must infer status from activity and handle users who silently disappear.
Redis sorted sets solve this elegantly. Store user IDs as members with their last activity timestamp as the score. Active users update their timestamp on each action. To find who is online, query for users with timestamps within the last N minutes. Stale entries age out naturally without explicit cleanup. This guide covers three approaches: site-wide presence tracking for "who's online" lists, room or document presence for tracking users in specific contexts, and rich status information for displaying more than just online/offline.
Which Redis data types we will use
Sorted Set is used in two ways in this implementation:
- For global presence tracking where each user ID is a member and their last activity timestamp is the score. When a user does something, update their score with
ZADD. To find online users, query withZRANGEBYSCOREfor all users with timestamps in the last N minutes. The timestamp-as-score pattern means you do not need to delete users when they go offline—just query for recent timestamps. - For scoped presence (per room, document, or lobby). Instead of one global set, create a set per scope. Each scope is independent, so you can answer "who is in this room" without scanning all online users.
Hash stores additional presence metadata when you need more than online/offline. Status messages ("In a meeting"), device type ("mobile"), custom emojis, or other fields all fit in a hash per user. The sorted set handles the time-based online check, and the hash stores the details.
The combination of sorted sets for timestamps and hashes for metadata gives you both efficient queries and rich status information.
Site-wide presence tracking: who's online now
The simplest presence system tracks all active users in a single sorted set. Each user's ID is a member, and the score is their last activity Unix timestamp. When a user does something, update their score with ZADD. To get online users, use ZRANGEBYSCORE to find everyone with a timestamp in the last few minutes.
This approach scales well because sorted sets are efficient even with millions of members. ZADD is O(log N), and range queries return results in sorted order. The sorted set also serves as a natural cleanup mechanism: you do not need to delete users who go offline. Just query for recent timestamps, and inactive users are excluded automatically.
The tradeoff is memory. Every user who has ever been active stays in the set until you explicitly remove them. For applications with many users, periodically run ZREMRANGEBYSCORE to prune entries older than your offline threshold. A background job running every few minutes keeps memory bounded without affecting query performance.
- Redis
- Python
- TypeScript
- Go
# User performs an action, update their last-seen timestamp
ZADD presence:global 1699900060 user123
> 1 (added) or 0 (updated existing)
# Another user is active
ZADD presence:global 1699900055 user456
> 1
# Get all users active in the last 5 minutes (300 seconds)
# Current time is 1699900060, so cutoff is 1699899760
ZRANGEBYSCORE presence:global 1699899760 +inf
> ["user456", "user123"]
# Count online users without fetching them
ZCOUNT presence:global 1699899760 +inf
> 2
# Remove users inactive for more than 1 hour (cleanup job)
ZREMRANGEBYSCORE presence:global -inf 1699896460
> 15 (removed 15 stale entries)
import time
ONLINE_THRESHOLD = 300 # 5 minutes
def update_presence(client, user_id: str):
"""Mark a user as active now."""
client.zadd('presence:global', {user_id: time.time()})
def get_online_users(client) -> list[str]:
"""Get all users active in the last 5 minutes."""
cutoff = time.time() - ONLINE_THRESHOLD
return client.zrangebyscore('presence:global', cutoff, '+inf')
def count_online_users(client) -> int:
"""Count online users without fetching the list."""
cutoff = time.time() - ONLINE_THRESHOLD
return client.zcount('presence:global', cutoff, '+inf')
def cleanup_stale_presence(client, max_age: int = 3600):
"""Remove users inactive for longer than max_age seconds."""
cutoff = time.time() - max_age
client.zremrangebyscore('presence:global', '-inf', cutoff)
const ONLINE_THRESHOLD = 300; // 5 minutes
async function updatePresence(client: Redis, userId: string) {
await client.zadd('presence:global', Date.now() / 1000, userId);
}
async function getOnlineUsers(client: Redis): Promise<string[]> {
const cutoff = Date.now() / 1000 - ONLINE_THRESHOLD;
return await client.zrangebyscore('presence:global', cutoff, '+inf');
}
async function countOnlineUsers(client: Redis): Promise<number> {
const cutoff = Date.now() / 1000 - ONLINE_THRESHOLD;
return await client.zcount('presence:global', cutoff, '+inf');
}
async function cleanupStalePresence(client: Redis, maxAge: number = 3600) {
const cutoff = Date.now() / 1000 - maxAge;
await client.zremrangebyscore('presence:global', '-inf', cutoff);
}
const onlineThreshold = 300 // 5 minutes
func updatePresence(ctx context.Context, client *redis.Client, userID string) {
client.ZAdd(ctx, "presence:global", redis.Z{
Score: float64(time.Now().Unix()),
Member: userID,
})
}
func getOnlineUsers(ctx context.Context, client *redis.Client) ([]string, error) {
cutoff := float64(time.Now().Unix() - onlineThreshold)
return client.ZRangeByScore(ctx, "presence:global", &redis.ZRangeBy{
Min: fmt.Sprintf("%f", cutoff),
Max: "+inf",
}).Result()
}
func countOnlineUsers(ctx context.Context, client *redis.Client) (int64, error) {
cutoff := float64(time.Now().Unix() - onlineThreshold)
return client.ZCount(ctx, "presence:global", fmt.Sprintf("%f", cutoff), "+inf").Result()
}
func cleanupStalePresence(ctx context.Context, client *redis.Client, maxAge int64) {
cutoff := float64(time.Now().Unix() - maxAge)
client.ZRemRangeByScore(ctx, "presence:global", "-inf", fmt.Sprintf("%f", cutoff))
}
Room and document presence: who's here with me
Many applications need presence within a specific context: users in a chat room, viewers of a document, or players in a game lobby. Instead of one global set, create a sorted set per scope. The key includes the scope identifier, and queries return only users active in that specific context.
Scoped presence lets you answer questions like "who else is viewing this document right now" without scanning all online users. Each scope is independent, so a user can be present in multiple rooms simultaneously. The same user appears in multiple sorted sets with potentially different timestamps reflecting when they last interacted with each scope.
The complexity is managing scope lifecycle. When a chat room is deleted or a document is archived, you need to clean up the corresponding presence key. Users can also be in many scopes, so your heartbeat logic needs to update all relevant sets. For applications where users are in few scopes at a time, this is manageable. For applications where users might be in hundreds of scopes, consider a different data model.
- Redis
- Python
- TypeScript
- Go
# User joins a document, record their presence
ZADD presence:doc:doc789 1699900060 user123
> 1
# Another user views the same document
ZADD presence:doc:doc789 1699900055 user456
> 1
# User is also in a chat room
ZADD presence:room:general 1699900060 user123
> 1
# Who is viewing this document? (active in last 2 minutes)
ZRANGEBYSCORE presence:doc:doc789 1699899940 +inf
> ["user456", "user123"]
# How many people are in the chat room?
ZCOUNT presence:room:general 1699899940 +inf
> 1
# User leaves the document explicitly
ZREM presence:doc:doc789 user123
> 1
import time
SCOPE_THRESHOLD = 120 # 2 minutes for scoped presence
def join_scope(client, scope_type: str, scope_id: str, user_id: str):
"""Mark user as present in a specific scope."""
key = f'presence:{scope_type}:{scope_id}'
client.zadd(key, {user_id: time.time()})
def leave_scope(client, scope_type: str, scope_id: str, user_id: str):
"""Explicitly remove user from a scope."""
key = f'presence:{scope_type}:{scope_id}'
client.zrem(key, user_id)
def get_scope_users(client, scope_type: str, scope_id: str) -> list[str]:
"""Get active users in a scope."""
key = f'presence:{scope_type}:{scope_id}'
cutoff = time.time() - SCOPE_THRESHOLD
return client.zrangebyscore(key, cutoff, '+inf')
def heartbeat_scopes(client, user_id: str, scopes: list[tuple[str, str]]):
"""Update presence in multiple scopes at once."""
now = time.time()
pipe = client.pipeline()
for scope_type, scope_id in scopes:
key = f'presence:{scope_type}:{scope_id}'
pipe.zadd(key, {user_id: now})
pipe.execute()
const SCOPE_THRESHOLD = 120; // 2 minutes
async function joinScope(client: Redis, scopeType: string, scopeId: string, userId: string) {
const key = `presence:${scopeType}:${scopeId}`;
await client.zadd(key, Date.now() / 1000, userId);
}
async function leaveScope(client: Redis, scopeType: string, scopeId: string, userId: string) {
const key = `presence:${scopeType}:${scopeId}`;
await client.zrem(key, userId);
}
async function getScopeUsers(client: Redis, scopeType: string, scopeId: string): Promise<string[]> {
const key = `presence:${scopeType}:${scopeId}`;
const cutoff = Date.now() / 1000 - SCOPE_THRESHOLD;
return await client.zrangebyscore(key, cutoff, '+inf');
}
async function heartbeatScopes(client: Redis, userId: string, scopes: [string, string][]) {
const now = Date.now() / 1000;
const pipeline = client.pipeline();
for (const [scopeType, scopeId] of scopes) {
pipeline.zadd(`presence:${scopeType}:${scopeId}`, now, userId);
}
await pipeline.exec();
}
const scopeThreshold = 120 // 2 minutes
func joinScope(ctx context.Context, client *redis.Client, scopeType, scopeID, userID string) {
key := fmt.Sprintf("presence:%s:%s", scopeType, scopeID)
client.ZAdd(ctx, key, redis.Z{Score: float64(time.Now().Unix()), Member: userID})
}
func leaveScope(ctx context.Context, client *redis.Client, scopeType, scopeID, userID string) {
key := fmt.Sprintf("presence:%s:%s", scopeType, scopeID)
client.ZRem(ctx, key, userID)
}
func getScopeUsers(ctx context.Context, client *redis.Client, scopeType, scopeID string) ([]string, error) {
key := fmt.Sprintf("presence:%s:%s", scopeType, scopeID)
cutoff := float64(time.Now().Unix() - scopeThreshold)
return client.ZRangeByScore(ctx, key, &redis.ZRangeBy{Min: fmt.Sprintf("%f", cutoff), Max: "+inf"}).Result()
}
func heartbeatScopes(ctx context.Context, client *redis.Client, userID string, scopes [][2]string) {
now := float64(time.Now().Unix())
pipe := client.Pipeline()
for _, scope := range scopes {
key := fmt.Sprintf("presence:%s:%s", scope[0], scope[1])
pipe.ZAdd(ctx, key, redis.Z{Score: now, Member: userID})
}
pipe.Exec(ctx)
}
Adding status messages and device info
Sometimes you need more than just "online" or "offline." Users might be away, busy, or in do-not-disturb mode. They might have a status message or be active on a specific device. For richer presence data, combine a sorted set for timestamps with a hash for metadata. The sorted set handles the time-based queries, and the hash stores additional fields per user.
This two-structure approach keeps queries fast. Checking who is online still uses the sorted set. Fetching status details for those users is a separate hash lookup. You can pipeline both operations to get online users and their metadata in two round trips, or even one if you fetch a known list of users.
The challenge is keeping both structures in sync. When a user goes offline, you might want to preserve their last status in the hash for display purposes, or you might want to clear it. Decide based on your UX: does showing "last seen 3 hours ago, status: In a meeting" make sense, or should offline users show no status? Use EXPIRE on hash keys if you want metadata to auto-clear after inactivity.
- Redis
- Python
- TypeScript
- Go
# Update presence timestamp
ZADD presence:global 1699900060 user123
> 0 (updated)
# Set user's status metadata
HSET presence:meta:user123 status "In a meeting" device "mobile" status_emoji "📅"
> 3
# Get online users
ZRANGEBYSCORE presence:global 1699899760 +inf
> ["user123", "user456"]
# Get metadata for a specific user
HGETALL presence:meta:user123
> {"status": "In a meeting", "device": "mobile", "status_emoji": "📅"}
# Clear status when user explicitly sets to available
HDEL presence:meta:user123 status status_emoji
> 2
# Set metadata to expire after 1 hour of inactivity
EXPIRE presence:meta:user123 3600
> 1
import time
def update_presence_with_status(client, user_id: str, status: str = None, device: str = None):
"""Update presence and optionally set status metadata."""
pipe = client.pipeline()
pipe.zadd('presence:global', {user_id: time.time()})
meta_key = f'presence:meta:{user_id}'
if status or device:
meta = {}
if status:
meta['status'] = status
if device:
meta['device'] = device
pipe.hset(meta_key, mapping=meta)
pipe.expire(meta_key, 3600) # Expire metadata after 1 hour
pipe.execute()
def get_online_users_with_metadata(client) -> list[dict]:
"""Get online users with their status metadata."""
cutoff = time.time() - 300
user_ids = client.zrangebyscore('presence:global', cutoff, '+inf')
if not user_ids:
return []
pipe = client.pipeline()
for user_id in user_ids:
pipe.hgetall(f'presence:meta:{user_id}')
metadata = pipe.execute()
return [{'user_id': uid, **meta} for uid, meta in zip(user_ids, metadata)]
def clear_status(client, user_id: str):
"""Clear a user's status message."""
client.hdel(f'presence:meta:{user_id}', 'status', 'status_emoji')
async function updatePresenceWithStatus(
client: Redis, userId: string, status?: string, device?: string
) {
const pipeline = client.pipeline();
pipeline.zadd('presence:global', Date.now() / 1000, userId);
const metaKey = `presence:meta:${userId}`;
if (status || device) {
const meta: Record<string, string> = {};
if (status) meta.status = status;
if (device) meta.device = device;
pipeline.hset(metaKey, meta);
pipeline.expire(metaKey, 3600);
}
await pipeline.exec();
}
async function getOnlineUsersWithMetadata(client: Redis): Promise<Array<Record<string, string>>> {
const cutoff = Date.now() / 1000 - 300;
const userIds = await client.zrangebyscore('presence:global', cutoff, '+inf');
if (userIds.length === 0) return [];
const pipeline = client.pipeline();
for (const userId of userIds) {
pipeline.hgetall(`presence:meta:${userId}`);
}
const results = await pipeline.exec();
return userIds.map((userId, i) => ({ userId, ...(results[i][1] as Record<string, string>) }));
}
async function clearStatus(client: Redis, userId: string) {
await client.hdel(`presence:meta:${userId}`, 'status', 'status_emoji');
}
func updatePresenceWithStatus(ctx context.Context, client *redis.Client, userID, status, device string) {
pipe := client.Pipeline()
pipe.ZAdd(ctx, "presence:global", redis.Z{Score: float64(time.Now().Unix()), Member: userID})
metaKey := "presence:meta:" + userID
if status != "" || device != "" {
meta := map[string]interface{}{}
if status != "" {
meta["status"] = status
}
if device != "" {
meta["device"] = device
}
pipe.HSet(ctx, metaKey, meta)
pipe.Expire(ctx, metaKey, time.Hour)
}
pipe.Exec(ctx)
}
func getOnlineUsersWithMetadata(ctx context.Context, client *redis.Client) ([]map[string]string, error) {
cutoff := float64(time.Now().Unix() - 300)
userIDs, _ := client.ZRangeByScore(ctx, "presence:global", &redis.ZRangeBy{
Min: fmt.Sprintf("%f", cutoff), Max: "+inf",
}).Result()
if len(userIDs) == 0 {
return nil, nil
}
pipe := client.Pipeline()
cmds := make([]*redis.MapStringStringCmd, len(userIDs))
for i, userID := range userIDs {
cmds[i] = pipe.HGetAll(ctx, "presence:meta:"+userID)
}
pipe.Exec(ctx)
results := make([]map[string]string, len(userIDs))
for i, userID := range userIDs {
meta, _ := cmds[i].Result()
meta["user_id"] = userID
results[i] = meta
}
return results, nil
}
func clearStatus(ctx context.Context, client *redis.Client, userID string) {
client.HDel(ctx, "presence:meta:"+userID, "status", "status_emoji")
}
Choosing an approach
Use global presence when you need a simple site-wide view of active users. It works well for showing total online counts or listing all active users when the count is reasonable to display.
Use scoped presence when users are present in distinct contexts like rooms, documents, or game lobbies. Each scope gets its own sorted set, making queries fast and relevant to the context.
Use presence with metadata when you need richer status information beyond online/offline. The sorted set handles time-based queries while hashes store status messages, devices, or custom fields. Keep both structures in sync and consider expiration policies for stale metadata.
All three approaches use sorted sets with timestamps as scores, making them efficient at scale and naturally self-cleaning. The right choice depends on whether you need global visibility, scoped context, or rich status data.