사용자 온라인/오프라인 상태 확인 시스템 설계: 순진한 폴링에서 Redis TTL까지

작성자

카테고리:

← 피드로
DEV Community · aditya emmadishetty · 2026-07-23 개발(SW)

External Github link for diagrams

How I designed a presence system from scratch — including the mistakes I nearly shipped

Every chat app, social network, and collaboration tool has that little green dot next to a user’s name. It looks trivial. It is not. In this post I’ll walk through how I designed a presence (online/offline) system for a web app — starting from the naive approach, working through why it breaks at scale, and landing on a design that’s actually used in production systems at places like Slack and Discord.

If you’ve ever wondered “why does this need more than a boolean column in the users table,” this is for you.

The Naive Approach: A Boolean in the Database

The first instinct is simple: add an is_online column to your users table, set it to true on login, false on logout.

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| C

Enter fullscreen mode Exit fullscreen mode

This immediately falls apart for one obvious reason: users don’t log out. They close the tab, their laptop dies, their WiFi drops. Your database now has a permanent record of a user being “online” who logged off three days ago.

Attempt Two: Client-Side Heartbeat Polling

The fix is a heartbeat: a script on the client pings the server every N seconds. If the server has heard from the user recently, they’re online.

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()
    end

Enter fullscreen mode Exit fullscreen mode

To check if someone is online, you query:

SELECT * FROM user_activity
WHERE last_active_at > NOW() - INTERVAL '1 minute'

Enter fullscreen mode Exit fullscreen mode

This works — but it introduces a new problem: you’re now writing to your primary database on every heartbeat, from every connected user, every 30 seconds.

For a modest 10,000 concurrent users, that’s over 300 writes per second hitting your primary DB, forever, just to track a boolean-ish state. This is the kind of load that starts showing up in your slow query logs and your DBA’s Slack messages.

The questions worth asking at this point

  • What happens if the database goes down? Does presence tracking take core traffic down with it?
  • How many concurrent connections/requests can the database realistically absorb?
  • Do we actually need this data to be durable at all?

That last question is the key insight that unlocks the next design.

The Insight: Presence Data Doesn’t Need to Be Durable

Presence is fundamentally ephemeral state. Nobody cares if you lose a user’s online status during a server restart — they’ll just send another heartbeat in 30 seconds and it’ll self-heal. This means presence doesn’t belong in your primary transactional database at all. It belongs in an in-memory store.

Enter Redis.

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 5

Enter fullscreen mode Exit fullscreen mode

The trick isn’t just “use a faster database” — it’s using a native TTL (time-to-live) expiry instead of storing a timestamp and comparing it yourself:

SET user:123 1 EX 30

Enter fullscreen mode Exit fullscreen mode

Now “is this user online” is just:

EXISTS user:123

Enter fullscreen mode Exit fullscreen mode

No timestamp math, no WHERE last_active < NOW() - INTERVAL. If the key exists, they’re online. If Redis silently expired it because 30 seconds passed without a heartbeat, they’re offline. Redis does the work for you.

Why this design survives a Redis outage

Because presence data is disposable, if Redis goes down, you just spin up a fresh instance. There’s no backup to restore, no data loss to worry about — the system self-heals as soon as clients send their next heartbeat.

Implementation

Here’s the actual service, in Python with FastAPI and redis-py:

import redis
from datetime import timedelta

class PresenceService:
    def __init__(self, redis_client: redis.Redis):
        self.redis = redis_client
        self.ttl = timedelta(seconds=30)

    def mark_online(self, user_id: str) -> None:
        self.redis.set(f"presence:{user_id}", "1", ex=self.ttl)

    def is_online(self, user_id: str) -> bool:
        return self.redis.exists(f"presence:{user_id}") == 1

    def mark_offline(self, user_id: str) -> None:
        self.redis.delete(f"presence:{user_id}")

Enter fullscreen mode Exit fullscreen mode

from fastapi import FastAPI, Depends

app = FastAPI()
presence_service = PresenceService(redis_client)

@app.post("/api/heartbeat")
def heartbeat(user_id: str = Depends(get_current_user_id)):
    presence_service.mark_online(user_id)
    return {"status": "ok"}

Enter fullscreen mode Exit fullscreen mode

Note the get_current_user_id dependency — this pulls the user ID from the authenticated session, not from a request parameter. Otherwise anyone can call this endpoint and mark any user online, which is a spoofing vulnerability, not just a bug.

The Fan-Out Problem: Checking Many Users at Once

A single EXISTS call is fine for checking one user. But what happens when you load a chat app and need to show online status for 200 friends at once? Looping and calling Redis 200 times means 200 network round trips.

flowchart TB
    A[Client loads friend list] --> B[Friend Service]
    B --> C[(Primary DB<br/>list of friend ids)]
    B --> D[Chat/Presence Service]
    D -->|pipelined EXISTS x N| E[(Redis)]
    E -->|batched result| D
    D -->|green/gray dots| A

Enter fullscreen mode Exit fullscreen mode

The fix is pipelining — batching all the EXISTS checks into a single round trip:

def are_online(self, user_ids: list[str], chunk_size: int = 500) -> dict[str, bool]:
    result = {}
    for i in range(0, len(user_ids), chunk_size):
        chunk = user_ids[i:i + chunk_size]
        pipe = self.redis.pipeline()
        for uid in chunk:
            pipe.exists(f"presence:{uid}")
        exists_flags = pipe.execute()
        result.update(dict(zip(chunk, map(bool, exists_flags))))
    return result

Enter fullscreen mode Exit fullscreen mode

Chunking (e.g. 500 keys per pipeline) matters even though Redis has no hard cap on pipeline size — an unbounded pipeline for a user with tens of thousands of connections is a real memory and latency risk, both client-side and server-side.

The Real Fix: Stop Polling, Push Instead

Heartbeat polling every 30 seconds works, but it has two structural weaknesses:

  1. It’s wasteful. Every connected client is sending a request every 30 seconds whether or not anything changed.
  2. It’s slow to detect disconnects. If a user closes their laptop, you won’t know they’re offline until their TTL expires — up to 30 seconds of showing a stale “online” status.

The better design — and where systems like Slack and Discord actually land — is to tie presence directly to a persistent connection, like a WebSocket:

sequenceDiagram
    participant Client
    participant WSGateway as WebSocket Gateway
    participant Redis

    Client->>WSGateway: connect
    WSGateway->>Redis: SET presence:id (no expiry needed)
    Note over Client,WSGateway: connection stays open

    Client--xWSGateway: disconnect (tab closed / network drop)
    WSGateway->>Redis: DEL presence:id
    Note over Redis: instant offline detection

Enter fullscreen mode Exit fullscreen mode

The connection’s lifecycle is the presence signal. Connect → online. Disconnect → offline. No polling loop, no 30-second detection lag. The TTL-based Redis key still has value here as a safety net — if a disconnect event is ever missed (e.g. gateway crash), the TTL ensures the key eventually expires instead of leaving a permanently stale “online” status.

Publishing Presence Changes to Friends

The last piece is getting presence updates to the people who need to see them, without broadcasting every status change to everyone.

flowchart LR
    A[User goes online/offline] --> B[Presence Service]
    B -->|publish event| C[Pub/Sub - Redis or Kafka]
    C -->|only to subscribed viewers| D[Friends currently viewing this user's status]

Enter fullscreen mode Exit fullscreen mode

The naive approach — re-fetching every friend’s status on a timer, or broadcasting every change to every friend regardless of whether they’re looking — doesn’t scale once friend lists get large. The scalable version: publish a change event, and only push it to clients that are actively subscribed to that specific user’s presence (i.e., have them visible on screen right now).

Architecture Evolution, Summarized

Stage Approach Problem 1 Boolean column in users table Never goes false on disconnect 2 Heartbeat + last_active_at timestamp in DB Crushes primary DB with write load 3 Heartbeat + Redis with TTL Still polling; 30s detection lag 4 WebSocket connection lifecycle + Redis TTL as safety net Production-grade 5 + Pub/sub fan-out to active viewers only Scales to large friend graphs

Closing Thoughts

What looks like “just a green dot” touches almost every hard problem in distributed systems: ephemeral vs. durable state, polling vs. push, fan-out at scale, and graceful degradation. Working through the naive version first — and feeling why it breaks — is what makes the Redis TTL design click, rather than just being a pattern you memorized.

This is the first in a series where I’m documenting real system design problems as I work through them — from scratch notes to working code. If you found this useful, I’m exploring turning this into a full course on building production-ready systems for backend engineers. Follow along for the next post, where I’ll cover the WebSocket gateway implementation in detail.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다