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