<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: aditya emmadishetty</title>
    <description>The latest articles on DEV Community by aditya emmadishetty (@aditya_emmadishetty_70951).</description>
    <link>https://dev.to/aditya_emmadishetty_70951</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4043730%2F23272eb1-417e-4c32-9cfa-8ce40ec7556f.png</url>
      <title>DEV Community: aditya emmadishetty</title>
      <link>https://dev.to/aditya_emmadishetty_70951</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aditya_emmadishetty_70951"/>
    <language>en</language>
    <item>
      <title>Designing a Real-Time Chat System (Slack-style)</title>
      <dc:creator>aditya emmadishetty</dc:creator>
      <pubDate>Tue, 28 Jul 2026 08:43:31 +0000</pubDate>
      <link>https://dev.to/aditya_emmadishetty_70951/designing-a-real-time-chat-system-slack-style-4dg8</link>
      <guid>https://dev.to/aditya_emmadishetty_70951/designing-a-real-time-chat-system-slack-style-4dg8</guid>
      <description>&lt;p&gt;External link &lt;a href="https://adityavarma1234.github.io/blog/2026/07/28/designing-a-real-time-chat-system" rel="noopener noreferrer"&gt;Design real time chat system&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is a write-up of a system design exercise I worked through for real-time&lt;br&gt;
communication between users, modeled loosely on Slack. It covers the&lt;br&gt;
requirements, the evolution from polling to WebSockets, how to scale&lt;br&gt;
WebSocket connections across multiple servers, and the database schema.&lt;/p&gt;

&lt;p&gt;To keep the core design easy to reason about, I've deliberately scoped out&lt;br&gt;
&lt;strong&gt;multi-device support&lt;/strong&gt; and &lt;strong&gt;strict message ordering guarantees&lt;/strong&gt; — these&lt;br&gt;
are called out as improvements at the end rather than folded into the main&lt;br&gt;
design.&lt;/p&gt;
&lt;h2&gt;
  
  
  Requirements
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Multiple users, multiple channels&lt;/li&gt;
&lt;li&gt;Users can DM each other or message in a channel&lt;/li&gt;
&lt;li&gt;Real-time chat — messages should be delivered live, not on refresh&lt;/li&gt;
&lt;li&gt;Historical messages can be scrolled through (pagination)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Architecture at a Glance
&lt;/h2&gt;

&lt;p&gt;Before diving into the individual pieces, here's the end state we're&lt;br&gt;
building toward: clients hold a persistent WebSocket connection to one of&lt;br&gt;
several WS servers, a discovery layer tracks which server each user is on,&lt;br&gt;
and a Chat Service is the single source of truth for persistence.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart LR
    A[User A] -- WebSocket --&amp;gt; WS1[WS Server 1]
    B[User B] -- WebSocket --&amp;gt; WS2[WS Server 2]
    C[User C] -- WebSocket --&amp;gt; WS2

    WS1 &amp;lt;--&amp;gt; DS[Discovery Service&amp;lt;br/&amp;gt;user_id to server mapping]
    WS2 &amp;lt;--&amp;gt; DS

    WS1 &amp;lt;--&amp;gt; MB[(Message Broker&amp;lt;br/&amp;gt;Pub/Sub)]
    WS2 &amp;lt;--&amp;gt; MB

    MB --&amp;gt; CS[Chat Service]
    CS --&amp;gt; DB[(Database)]

    style DS fill:#f9f0d9,stroke:#c9a227
    style MB fill:#dde8f5,stroke:#4a7ab5
    style DB fill:#e2f0e2,stroke:#4a8a4a
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WS Servers&lt;/strong&gt; hold the actual live connections and know nothing about
routing — they just push to whichever local sockets they're told to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Discovery Service&lt;/strong&gt; is the "phone book": given a &lt;code&gt;user_id&lt;/code&gt;, it tells you
which WS server (if any) that user is currently connected to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Message Broker&lt;/strong&gt; is the pub/sub layer that lets a message published on
one WS server's side reach a subscriber connected to a &lt;em&gt;different&lt;/em&gt; WS
server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chat Service + DB&lt;/strong&gt; is the durability layer — every message is persisted
here regardless of whether anyone was online to receive it live.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Data Model
&lt;/h2&gt;

&lt;p&gt;Four core entities:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User
  id
  name

Message
  id
  from
  channel_id

Channel
  id
  name
  type: direct / group

ChannelUserMapping
  id
  channel_id
  user_id
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few useful queries this schema supports directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- messages in a channel&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;channel_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;

&lt;span class="c1"&gt;-- users in a channel&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;channel_user_mapping&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;channel_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Member joins/leaves are handled as separate operations against&lt;br&gt;
&lt;code&gt;ChannelUserMapping&lt;/code&gt;, rather than being baked into the channel or message&lt;br&gt;
tables.&lt;/p&gt;
&lt;h2&gt;
  
  
  Getting to Real-Time: Three Approaches
&lt;/h2&gt;
&lt;h3&gt;
  
  
  1. Short Polling
&lt;/h3&gt;

&lt;p&gt;The simplest approach: the client repeatedly calls an endpoint like&lt;br&gt;
&lt;code&gt;GET /messages?channel_id=?&amp;amp;from=?&lt;/code&gt;, and the server returns new messages if&lt;br&gt;
any exist, or an empty response otherwise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; the client is guessing when to ask. Poll too infrequently and&lt;br&gt;
messages feel delayed; poll too frequently and you're hammering the server&lt;br&gt;
with mostly-empty responses. It's simple but not truly real-time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sequenceDiagram
    participant B as User B
    participant S as Server

    loop every few seconds
        B-&amp;gt;&amp;gt;S: GET /messages?channel_id=X&amp;amp;from=Y
        S--&amp;gt;&amp;gt;B: [] (no new messages)
    end
    Note over B,S: eventually...
    B-&amp;gt;&amp;gt;S: GET /messages?channel_id=X&amp;amp;from=Y
    S--&amp;gt;&amp;gt;B: [new message]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Long Polling
&lt;/h3&gt;

&lt;p&gt;The client keeps the connection open, and the server only responds once a&lt;br&gt;
new message actually shows up (or a timeout elapses, after which the client&lt;br&gt;
reopens the connection).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; this is still fundamentally client-initiated. The server can&lt;br&gt;
only &lt;em&gt;respond&lt;/em&gt; — it can't push new data on a connection unless the client&lt;br&gt;
asked first. That still doesn't give you real bidirectional, low-latency&lt;br&gt;
communication, and the connection &lt;em&gt;churn&lt;/em&gt; (constantly closing and reopening)&lt;br&gt;
is wasteful.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sequenceDiagram
    participant B as User B
    participant S as Server

    B-&amp;gt;&amp;gt;S: GET /messages?channel_id=X&amp;amp;from=Y
    Note over S: connection held open...
    Note over S: new message arrives
    S--&amp;gt;&amp;gt;B: [new message]
    B-&amp;gt;&amp;gt;S: GET /messages?channel_id=X&amp;amp;from=Y (reopen)
    Note over S: connection held open again...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. WebSockets
&lt;/h3&gt;

&lt;p&gt;WebSockets open a persistent, bidirectional connection between the client&lt;br&gt;
and server. Once established, either side can send data at any time. This is&lt;br&gt;
the right building block for real-time chat — it's the actual mechanism&lt;br&gt;
underneath both Slack and most modern chat apps. (Server-Sent Events were&lt;br&gt;
also considered, but ruled out since they're server-to-client only — the&lt;br&gt;
client still needs a separate channel to send messages.)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sequenceDiagram
    participant B as User B
    participant S as Server

    B-&amp;gt;&amp;gt;S: WebSocket handshake (upgrade)
    S--&amp;gt;&amp;gt;B: connection established (persistent)
    Note over B,S: connection stays open
    S-&amp;gt;&amp;gt;B: push: new message (server-initiated!)
    B-&amp;gt;&amp;gt;S: send: new message
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Scaling WebSocket Connections
&lt;/h2&gt;

&lt;p&gt;A single server can only hold so many concurrent open socket connections. If&lt;br&gt;
you have many users, you need multiple servers holding connections, which&lt;br&gt;
introduces a new problem: &lt;strong&gt;if user A is connected to server 1 and sends a&lt;br&gt;
message to user B, who is connected to server 2 — how does server 1 know&lt;br&gt;
where to deliver it?&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Discovery Service
&lt;/h3&gt;

&lt;p&gt;The fix is a &lt;strong&gt;discovery service&lt;/strong&gt; that tracks which server each user's&lt;br&gt;
active WebSocket connection lives on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When a client first loads the app, it registers with the discovery
service, recording which WS server it has an open connection to.&lt;/li&gt;
&lt;li&gt;When user A sends a message, it's routed to a &lt;strong&gt;Chat Service&lt;/strong&gt;, which
persists it to the DB and also asks the discovery service where user B is
connected.&lt;/li&gt;
&lt;li&gt;If B has an active connection, the message is routed to the correct WS
server and pushed down B's socket in real time.&lt;/li&gt;
&lt;li&gt;If B isn't connected anywhere, the message is just persisted — B will see
it next time they load the channel or reconnect.
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sequenceDiagram
    participant A as User A
    participant WS1 as WS Server 1
    participant CS as Chat Service
    participant DS as Discovery Service
    participant WS2 as WS Server 2
    participant B as User B

    A-&amp;gt;&amp;gt;WS1: send message to B
    WS1-&amp;gt;&amp;gt;CS: forward message
    CS-&amp;gt;&amp;gt;CS: persist to DB
    CS-&amp;gt;&amp;gt;DS: where is B connected?
    DS--&amp;gt;&amp;gt;CS: WS Server 2
    CS-&amp;gt;&amp;gt;WS2: deliver message
    WS2-&amp;gt;&amp;gt;B: push message (live)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  Channel / Group Fan-out
&lt;/h3&gt;

&lt;p&gt;For channels (as opposed to 1:1 DMs), the same idea extends via &lt;strong&gt;pub/sub&lt;/strong&gt;:&lt;br&gt;
each WS server subscribes to the channels its connected users are members&lt;br&gt;
of. When a message is published to a channel, it's fanned out to every WS&lt;br&gt;
server that has at least one relevant subscriber, and each of those servers&lt;br&gt;
pushes it to its own connected sockets.&lt;/p&gt;

&lt;p&gt;This is &lt;strong&gt;fan-out-on-write&lt;/strong&gt;: the fan-out work happens at message-send time,&lt;br&gt;
not at read time. It's the standard approach for chat systems at Slack's&lt;br&gt;
scale (as opposed to fan-out-on-read, which is more common for large,&lt;br&gt;
algorithmic feeds).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TB
    A[User A sends to Channel] --&amp;gt; CS[Chat Service]
    CS --&amp;gt;|1. persist| DB[(Database)]
    CS --&amp;gt;|2. publish to channel topic| MB[(Message Broker)]

    MB --&amp;gt;|subscribed| WS1[WS Server 1]
    MB --&amp;gt;|subscribed| WS2[WS Server 2]

    WS1 --&amp;gt; B[User B]
    WS1 --&amp;gt; C[User C]
    WS2 --&amp;gt; D[User D]

    style CS fill:#dde8f5,stroke:#4a7ab5
    style MB fill:#f9f0d9,stroke:#c9a227
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the fan-out is &lt;strong&gt;per WS server that has relevant subscribers&lt;/strong&gt;, not per&lt;br&gt;
individual user — if 50 channel members happen to be connected to the same&lt;br&gt;
WS server, that's a single publish to that server, which then pushes to its&lt;br&gt;
50 local sockets.&lt;/p&gt;
&lt;h3&gt;
  
  
  Avoiding Duplicate Persistence
&lt;/h3&gt;

&lt;p&gt;One subtlety: a message needs to be &lt;strong&gt;persisted&lt;/strong&gt; (Chat Service → DB) and&lt;br&gt;
also &lt;strong&gt;delivered live&lt;/strong&gt; (via the WS/pub-sub path) — these are two different&lt;br&gt;
concerns and should be handled by two different components, not conflated:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Chat Service&lt;/strong&gt; is the single source of truth for persistence — it writes
every message to the DB exactly once.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;WS/pub-sub queue&lt;/strong&gt; is purely for real-time fan-out to connected
clients and should not also be treated as a persistence layer. Keeping
these responsibilities separate avoids double-writes and keeps recovery
logic simple — if delivery fails, the message is still safely in the DB
and will show up on next fetch.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Handling Scale: Sharded Discovery Services
&lt;/h2&gt;

&lt;p&gt;If the number of users is large enough that a single discovery service (or&lt;br&gt;
a single set of WS servers) can't hold all the connection state, you shard&lt;br&gt;
it — e.g., partitioning users across &lt;code&gt;DiscoveryService1&lt;/code&gt;, &lt;code&gt;DiscoveryService2&lt;/code&gt;,&lt;br&gt;
etc. Each shard registers itself as a subscriber to the relevant channels /&lt;br&gt;
topics in a shared message broker (SQS/Redis/Kafka-like), so that a message&lt;br&gt;
originating on one shard can still be routed to a user connected via a&lt;br&gt;
different shard.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart LR
    A[User A] --&amp;gt; DS1[Discovery Service 1]
    B[User B] --&amp;gt; DS1
    C[User C] --&amp;gt; DS2[Discovery Service 2]
    D[User D] --&amp;gt; DS2

    DS1 &amp;lt;--&amp;gt; MB[(Shared Message Broker&amp;lt;br/&amp;gt;topics = channels)]
    DS2 &amp;lt;--&amp;gt; MB

    style MB fill:#f9f0d9,stroke:#c9a227
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each discovery service shard subscribes to the broker on behalf of its own&lt;br&gt;
users' channels, so a message published by a user on &lt;code&gt;DiscoveryService1&lt;/code&gt; can&lt;br&gt;
still reach a subscriber whose connection lives on &lt;code&gt;DiscoveryService2&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting It Together (End-to-End Flow)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Client loads the app → registers its WebSocket connection with the
discovery service.&lt;/li&gt;
&lt;li&gt;User A sends "Hi" to a channel → goes to the discovery service /
message-fan-out layer.&lt;/li&gt;
&lt;li&gt;It checks which channel members have active connections and pushes the
message to the WS servers those connections live on.&lt;/li&gt;
&lt;li&gt;In parallel, the message is sent to a queue → picked up by a worker →
persisted to the DB.&lt;/li&gt;
&lt;li&gt;Any client without an active connection simply reads the message from
the DB the next time they open the channel.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Afterthoughts / Future Improvements
&lt;/h2&gt;

&lt;p&gt;These were intentionally left out of the core design to keep it focused, but&lt;br&gt;
are worth calling out as natural next steps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Multi-device support.&lt;/strong&gt; Users are often connected from more than one&lt;br&gt;
device at once (phone + laptop). The discovery mapping would need to go&lt;br&gt;
from &lt;code&gt;user_id → single connection&lt;/code&gt; to &lt;code&gt;user_id → set of connections&lt;/code&gt;, and&lt;br&gt;
delivery/read-state would need to account for multiple active sessions per&lt;br&gt;
user.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Strict message ordering guarantees.&lt;/strong&gt; With multiple WS servers and a&lt;br&gt;
pub/sub fan-out layer in between, messages sent close together from&lt;br&gt;
different servers aren't guaranteed to arrive or be persisted in send&lt;br&gt;
order. A more complete design would assign each channel's messages a&lt;br&gt;
monotonically increasing sequence number at write time (e.g., by&lt;br&gt;
serializing writes per channel, or using a partitioned log like Kafka&lt;br&gt;
keyed by &lt;code&gt;channel_id&lt;/code&gt;), and have clients render/order by that sequence&lt;br&gt;
number rather than by arrival time.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Offline push notifications&lt;/strong&gt; for users with no active connection at all.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Pagination strategy&lt;/strong&gt; for scrolling through history — cursor-based&lt;br&gt;
pagination on &lt;code&gt;(channel_id, created_at)&lt;/code&gt; rather than naive &lt;code&gt;SELECT *&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Presence and typing indicators&lt;/strong&gt;, which piggyback on the same connection&lt;br&gt;
infrastructure but weren't part of the core requirements here.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>scalability</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>Designing a User Online/Offline Presence System: From Naive Polling to Redis TTLs</title>
      <dc:creator>aditya emmadishetty</dc:creator>
      <pubDate>Thu, 23 Jul 2026 11:32:35 +0000</pubDate>
      <link>https://dev.to/aditya_emmadishetty_70951/designing-a-user-onlineoffline-presence-system-from-naive-polling-to-redis-ttls-1jle</link>
      <guid>https://dev.to/aditya_emmadishetty_70951/designing-a-user-onlineoffline-presence-system-from-naive-polling-to-redis-ttls-1jle</guid>
      <description>&lt;p&gt;External Github &lt;a href="https://github.com/adityavarma1234/system_design/tree/main/presence-service" rel="noopener noreferrer"&gt;link&lt;/a&gt; for diagrams&lt;/p&gt;

&lt;p&gt;&lt;em&gt;How I designed a presence system from scratch — including the mistakes I nearly shipped&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If you've ever wondered "why does this need more than a boolean column in the users table," this is for you.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Naive Approach: A Boolean in the Database
&lt;/h2&gt;

&lt;p&gt;The first instinct is simple: add an &lt;code&gt;is_online&lt;/code&gt; column to your &lt;code&gt;users&lt;/code&gt; table, set it to &lt;code&gt;true&lt;/code&gt; on login, &lt;code&gt;false&lt;/code&gt; on logout.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart LR
    A[User] --&amp;gt;|login| B[Login Service]
    B --&amp;gt;|UPDATE users SET is_online=true| C[(Database)]
    A --&amp;gt;|logout| B
    B --&amp;gt;|UPDATE users SET is_online=false| C
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This immediately falls apart for one obvious reason: &lt;strong&gt;users don't log out.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt Two: Client-Side Heartbeat Polling
&lt;/h2&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sequenceDiagram
    participant Client
    participant Server
    participant DB
    loop every 30 seconds
        Client-&amp;gt;&amp;gt;Server: POST /heartbeat
        Server-&amp;gt;&amp;gt;DB: UPDATE user_activity SET last_active_at = now()
    end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To check if someone is online, you query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;user_activity&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;last_active_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'1 minute'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works — but it introduces a new problem: &lt;strong&gt;you're now writing to your primary database on every heartbeat, from every connected user, every 30 seconds.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  The questions worth asking at this point
&lt;/h3&gt;

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

&lt;p&gt;That last question is the key insight that unlocks the next design.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Insight: Presence Data Doesn't Need to Be Durable
&lt;/h2&gt;

&lt;p&gt;Presence is fundamentally &lt;strong&gt;ephemeral state&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;Enter Redis.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart LR
    A[User] --&amp;gt;|heartbeat| B[Server]
    B --&amp;gt;|SET presence:id EX 30| C[(Redis)]
    B -.-&amp;gt;|no impact| D[(Primary DB)]
    style D stroke-dasharray: 5 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trick isn't just "use a faster database" — it's using a &lt;strong&gt;native TTL (time-to-live) expiry&lt;/strong&gt; instead of storing a timestamp and comparing it yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SET user:123 1 EX 30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now "is this user online" is just:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EXISTS user:123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No timestamp math, no &lt;code&gt;WHERE last_active &amp;lt; NOW() - INTERVAL&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this design survives a Redis outage
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation
&lt;/h2&gt;

&lt;p&gt;Here's the actual service, in Python with FastAPI and &lt;code&gt;redis-py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PresenceService&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;redis_client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Redis&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;redis&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis_client&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ttl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;mark_online&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;presence:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;is_online&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;presence:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;mark_offline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;presence:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Depends&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;presence_service&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PresenceService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;redis_client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/heartbeat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;heartbeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_current_user_id&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="n"&gt;presence_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mark_online&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  The Fan-Out Problem: Checking Many Users at Once
&lt;/h2&gt;

&lt;p&gt;A single &lt;code&gt;EXISTS&lt;/code&gt; 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TB
    A[Client loads friend list] --&amp;gt; B[Friend Service]
    B --&amp;gt; C[(Primary DB&amp;lt;br/&amp;gt;list of friend ids)]
    B --&amp;gt; D[Chat/Presence Service]
    D --&amp;gt;|pipelined EXISTS x N| E[(Redis)]
    E --&amp;gt;|batched result| D
    D --&amp;gt;|green/gray dots| A
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is &lt;strong&gt;pipelining&lt;/strong&gt; — batching all the &lt;code&gt;EXISTS&lt;/code&gt; checks into a single round trip:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;are_online&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;chunk_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_ids&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;chunk_size&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_ids&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;chunk_size&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;pipe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;uid&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;presence:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;exists_flags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exists_flags&lt;/span&gt;&lt;span class="p"&gt;))))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Fix: Stop Polling, Push Instead
&lt;/h2&gt;

&lt;p&gt;Heartbeat polling every 30 seconds works, but it has two structural weaknesses:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It's wasteful.&lt;/strong&gt; Every connected client is sending a request every 30 seconds whether or not anything changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's slow to detect disconnects.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The better design — and where systems like Slack and Discord actually land — is to tie presence directly to a persistent connection, like a WebSocket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sequenceDiagram
    participant Client
    participant WSGateway as WebSocket Gateway
    participant Redis

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

    Client--xWSGateway: disconnect (tab closed / network drop)
    WSGateway-&amp;gt;&amp;gt;Redis: DEL presence:id
    Note over Redis: instant offline detection
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The connection's lifecycle &lt;em&gt;is&lt;/em&gt; 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 &lt;em&gt;safety net&lt;/em&gt; — 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Publishing Presence Changes to Friends
&lt;/h2&gt;

&lt;p&gt;The last piece is getting presence updates to the people who need to see them, without broadcasting every status change to everyone.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart LR
    A[User goes online/offline] --&amp;gt; B[Presence Service]
    B --&amp;gt;|publish event| C[Pub/Sub - Redis or Kafka]
    C --&amp;gt;|only to subscribed viewers| D[Friends currently viewing this user's status]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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).&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture Evolution, Summarized
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Problem&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Boolean column in &lt;code&gt;users&lt;/code&gt; table&lt;/td&gt;
&lt;td&gt;Never goes false on disconnect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Heartbeat + &lt;code&gt;last_active_at&lt;/code&gt; timestamp in DB&lt;/td&gt;
&lt;td&gt;Crushes primary DB with write load&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Heartbeat + Redis with TTL&lt;/td&gt;
&lt;td&gt;Still polling; 30s detection lag&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;WebSocket connection lifecycle + Redis TTL as safety net&lt;/td&gt;
&lt;td&gt;Production-grade&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;+ Pub/sub fan-out to active viewers only&lt;/td&gt;
&lt;td&gt;Scales to large friend graphs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Closing Thoughts
&lt;/h2&gt;

&lt;p&gt;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 &lt;em&gt;why&lt;/em&gt; it breaks — is what makes the Redis TTL design click, rather than just being a pattern you memorized.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>systemdesign</category>
      <category>distributedsystems</category>
      <category>redis</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
