This article details the architectural evolution of a user online/offline presence system, moving from naive database polling to a highly scalable solution leveraging Redis's time-to-live (TTL) functionality. It highlights critical trade-offs in data durability and write amplification when dealing with ephemeral state at scale, ultimately demonstrating how an in-memory store like Redis, combined with native expiry, addresses these challenges effectively.
Read original on Dev.to #systemdesignBuilding a system to track user online/offline presence, a seemingly simple feature, quickly exposes significant scaling challenges. Initial, naive approaches, such as adding a boolean column to a relational database, fail when users don't explicitly log out. This leads to stale data and an inaccurate representation of user status.
flowchart LR
A[User] -->|login| B[Login Service]
B -->|UPDATE users SET is_online=true| C[(Database)]
A -->|logout| B
B -->|UPDATE users SET is_online=false| CThis approach quickly becomes problematic because users rarely log out cleanly. Browser closures, network drops, or device power-offs leave the `is_online` flag set to `true` indefinitely, leading to incorrect presence information.
A common improvement is to implement client-side heartbeats, where the client periodically pings the server to indicate activity. The server then updates a `last_active_at` timestamp in the database. Users are considered online if their `last_active_at` is within a recent window (e.g., the last minute).
sequenceDiagram
participant Client
participant Server
participant DB
loop every 30 seconds
Client->>Server: POST /heartbeat
Server->>DB: UPDATE user_activity SET last_active_at = now()
endScaling Challenge: Write Amplification
While solving the staleness issue, this heartbeat mechanism introduces a severe write amplification problem. For 10,000 concurrent users sending heartbeats every 30 seconds, this translates to over 300 writes per second directly to the primary transactional database. This can quickly overwhelm the database and impact its performance for core functionalities.
The critical realization for a scalable presence system is that presence data is ephemeral. Losing this state during a system restart is acceptable, as users will send new heartbeats and self-heal the system. This means presence data does not require the durability guarantees of a primary transactional database.
An in-memory data store like Redis is ideal for this ephemeral state. Instead of storing timestamps and querying with `WHERE` clauses, Redis's native Time-To-Live (TTL) functionality can be used. Each heartbeat updates a key in Redis with an expiry time. If the key exists, the user is online; if it expires, they are offline. Redis handles the expiry automatically and efficiently.
flowchart LR
A[User] -->|heartbeat| B[Server]
B -->|SET presence:id EX 30| C[(Redis)]
B -.->|no impact| D[(Primary DB)]
style D stroke-dasharray: 5 5Resilience to Redis Outages
Because presence data is disposable, a Redis outage does not result in data loss requiring complex recovery. A new Redis instance can be spun up, and the system will self-heal as clients send subsequent heartbeats, repopulating the presence state.
While checking a single user's status with `EXISTS` is fast, fetching the status for many users (e.g., 200 friends in a chat list) could still incur multiple network round trips. This is resolved using Redis pipelining, which batches multiple commands into a single round trip, significantly reducing latency and improving efficiency for bulk lookups.