How Slack instantly syncs read state across all your devices
How Slack propagates read horizons, notification badges, and message state across mobile, desktop, and web clients using server-sent events and a per-device cursor model.
The Problem Statement
Interviewer: "You are using Slack on your laptop and you get a notification on your iPhone. You tap it, read the message, and put your phone down. Ten seconds later you look back at your laptop and the unread badge is gone. How does that work? Walk me through the full system."
This question tests your understanding of multi-device session management, real-time state propagation, and the subtle difference between per-device state and per-user state. The interviewer wants to know whether you instinctively model "read state" as belonging to a device (wrong) or to a user (right), and whether you understand how that state fans out across an arbitrary number of active sessions.
There is a second layer to this question. The interviewer will usually follow up with edge cases: what happens when you read on two devices simultaneously, how does the badge update without polling, what is the difference between mobile push notifications and desktop WebSocket connections, and how does Slack handle the "intentional unread" feature where you swipe to mark something as unread again. By the end of your answer, you should have touched all of those.
Clarifying the Scenario
You: "Before I dive in, I want to clarify a few things."
You: "When you say the badge disappears, are we talking about the red dot on a specific channel, or the overall app icon badge, or both?"
Interviewer: "Start with the channel-level badge. We can extend to app-level badges after."
You: "Got it. Should I assume both devices have a live connection to Slack, or can the laptop be in low-power mode with no active WebSocket?"
Interviewer: "Assume both devices are actively running Slack with live connections."
You: "And when I read the message on my phone, is that by tapping a push notification that opens the app, or by the app already being open in the foreground?"
Interviewer: "Yes, that is the interesting case."
You: "OK. I will cover the data model for read state, how updates propagate in real time across devices, how unread counts are computed, and how conflicts are resolved when two devices update at the same time."
My Approach
I break this into four parts:
- The read horizon model: How Slack stores "last read" position as a per-user, per-channel cursor
- Cross-device propagation: How updating read state on one device pushes to all other connected devices via WebSocket
- Unread count and badge computation: How the server computes unread counts from the gap between channel head and read horizon
- Conflict resolution and notification suppression: What happens when two devices mark-as-read simultaneously, and how Slack avoids double-notifying
The core insight is that Slack does not store "read" or "unread" per message. It stores a single pointer per user per channel: the ID of the last message the user has seen. Everything after that pointer is unread. Everything before it is read. This is the read horizon, and everything in this system flows from it.
Think of it like a bookmark in a book. You do not stamp every page as "read." You put the bookmark at the page you stopped on. Everything before the bookmark is read. If you pick up the same book on a different device (your Kindle instead of the print copy), the system just needs to sync where the bookmark is, not the state of every individual page.
This model has profound scaling implications. A per-message flag approach requires O(messages x users) storage. The cursor approach requires O(channels x users) storage, which is orders of magnitude smaller since the number of channel memberships is far less than the number of messages.
A concrete scenario
You have Slack on your iPhone and your MacBook. Both are open to the same channel. There are 5 unread messages on both screens.
- You pick up your iPhone and read through all 5 messages. Your eyes reach the bottom of the conversation.
- After 1 second with no scrolling, the iOS client fires a
mark_readevent:{ user: U123, channel: C456, last_read: ts=1700000005 }. - The API server receives the event, writes the new horizon to the read-state store with strong consistency.
- The server side-loads the session registry: user U123 has two active sessions -- iPhone (the origin) and MacBook.
- The server computes the new unread count by querying messages in C456 with timestamp > 1700000005. Result: 0.
- It sends
{ channel: C456, unread_count: 0 }to the MacBook WebSocket connection only (iPhone is the origin; no echo). - Your MacBook badge clears within 500ms of you finishing reading on your phone.
This sequence is exactly the same whether you have 2 devices or 10. The fan-out list just gets longer, and the session registry lookup cost grows proportionally with the number of connected sessions -- typically 2-4 per active user.
The Architecture
Here is the full system for propagating a read event from one device to another.
The flow has six logical steps:
Step 1: Sending the mark_read event. When User A reads channel X on the phone, the Slack mobile client sends a mark_read event to the WebSocket gateway. The event payload is small: the channel ID and the timestamp of the last message the user scrolled past. This is the new read horizon.
Step 2: Writing the read horizon. The Channel Service receives the event and performs a single-row write to the read horizons store. The write uses the user ID and channel ID as the composite key, and the new timestamp as the value. This write requires strong consistency because a stale read could temporarily re-show cleared badges, which is jarring to users.
Step 3: Triggering fan-out. After the write commits, the Channel Service emits a notification to the fan-out service. The fan-out service looks up all active sessions for this user in the session store (a Redis cluster with short TTLs per session).
Step 4: Computing the new unread count. The unread count is computed server-side by comparing the new read horizon against the channel's message log. This is non-trivial: it is not just subtracting, because messages can arrive after the read horizon while you are actively in the channel. The server returns the count as a concrete number, not a delta, so devices cannot diverge even if they miss an intermediate event.
Step 5: Pushing to all other sessions. The fan-out service sends the mark_read event and the new unread count to all sessions except the one that triggered the event (no need to echo back to the originating device). Delivery uses the WebSocket connection the gateway already holds for each session.
Step 6: Re-rendering the badge. The desktop Slack client receives the event, sees channel_X: unread_count = 0, and clears the red badge on that channel in the left sidebar.
Slack maintains persistent WebSocket connections for desktop clients. Mobile clients use a hybrid: WebSocket when the app is in the foreground, and push notifications when the app is backgrounded or closed. This distinction matters a lot for notification suppression, which we cover in Deep Dive 3.
What happens when things go wrong
The happy path above assumes all services are up and the client has an active WebSocket connection. In practice, three failure modes come up constantly in system design interviews:
The read-state store is unavailable. If the store that holds read horizons goes down, mark_read writes fail. The correct behavior is to queue the write and retry -- not to drop it silently. If the write is dropped, the user's read horizon is never updated and their badge will re-appear when they next open the app. Most production implementations have a retry queue with short TTLs for read horizon writes, since a slightly delayed badge clear is acceptable but a permanently wrong badge is not.
The client loses its WebSocket connection mid-scroll. If the connection drops while the user is actively reading, the 1-second debounce timer never fires and no mark_read event is sent. When the client reconnects, it asks the server for its current read horizon and channel states. At this point, the client's in-memory cursor (which tracked how far the user scrolled) is ahead of the server's stored horizon. A well-designed client sends a mark_read event immediately on reconnect, using its in-memory cursor, to bring the server up to date.
Fan-out partially succeeds. The fan-out service successfully pushes to 2 of your 3 active sessions but the third WebSocket times out. The missed session will have a stale badge count. This is corrected at next channel load: when the user opens the channel on the third device, it fetches the canonical unread count from the server, which has the correct value. The transient inconsistency is acceptable because it is self-healing and lasts at most until the next time the user interacts with that device.
Deep Dive 1: The Read Horizon Model
The single most important design decision here is what "read state" means physically. There are two obvious options: store a set of message IDs that have been read, or store a single timestamp cursor per channel. Slack uses the cursor model, and the reasons are worth understanding deeply.
The cursor model has a key property: a channel's unread count is a question you ask at read time, not a counter you maintain. You store the horizon, and the unread count is derived by querying how many messages in that channel have timestamps newer than the horizon. This makes the write path (marking as read) extremely fast: one row update. The read path (displaying the badge) is a range count, which databases can serve efficiently with the right index.
The read horizon model also makes "mark all as read" trivial to implement. Set the horizon to the current timestamp and all messages in the channel become read instantly. With the message-ID model, "mark all as read" requires inserting potentially thousands of rows.
The storage schema behind the cursor
A concrete physical schema helps anchor the discussion if the interviewer asks you to go deeper. The read-state store likely has a structure similar to this:
-- Read horizon: one row per (user, channel) membership
CREATE TABLE read_horizons (
user_id BIGINT NOT NULL,
workspace_id BIGINT NOT NULL,
channel_id BIGINT NOT NULL,
last_read_ts BIGINT NOT NULL, -- Slack message timestamp (microseconds since epoch)
updated_at TIMESTAMP NOT NULL,
PRIMARY KEY (user_id, workspace_id, channel_id)
);
-- Messages: the other side of the range query
-- Needs an index on (channel_id, ts) for efficient unread count computation
CREATE INDEX idx_messages_channel_ts ON messages (channel_id, ts);
The unread count query becomes:
SELECT COUNT(*) FROM messages
WHERE channel_id = :channel_id
AND ts > :last_read_ts
AND deleted_at IS NULL;
This query uses the idx_messages_channel_ts index and runs in a few milliseconds for channels with reasonable activity. For extremely high-traffic channels (think Slack's own engineering channels with thousands of messages per day), this is the query Slack would most likely cache.
Deep Dive 2: Real-Time Badge Propagation Across Sessions
The core challenge is that one user can have many active sessions simultaneously. A typical Slack power user might have the desktop app on their work MacBook, the mobile app on their iPhone, a browser tab open on their second monitor, and another mobile app on their personal Android phone. All four of these are separate WebSocket connections. When any one sends a mark_read event, all three others must receive the updated badge count within a second or two.
The session store is the critical piece of infrastructure here. It maps each user ID to the list of currently active session IDs, where each session ID is tied to a specific WebSocket connection on a specific gateway server. Session entries are created when a client connects and deleted (or expire via TTL) when the client disconnects.
The session store must be fast. Querying it on every mark_read event introduces latency in the fan-out path. The typical implementation uses Redis with a set per user: the key is the user ID and the value is a set of session IDs with associated gateway server addresses. A lookup is a single Redis SMEMBERS command taking under a millisecond.
One important detail: the fan-out deliberately excludes the originating session. The phone that sent the mark_read event knows it just marked the channel as read. Sending the update back to it would be a no-op at best and a confusing double-update at worst. The server tracks the originating session ID and skips it during fan-out.
The session push is also asynchronous from the database write. Once the read horizon write commits, the fan-out happens in a separate goroutine or async task. If one of the N sessions is slow to receive its push (the gateway for that session is briefly overloaded), it does not block the response to the originating device. The originating device gets its ACK as soon as the DB write commits.
What happens when a session is disconnected mid-fan-out? If a client reconnects to a new gateway, the new gateway does not automatically receive read state changes that arrived while the client was disconnected. When the client re-establishes its WebSocket connection, it sends its last known channel cursor positions as part of the handshake. The server reconciles these against the current server-side horizons and pushes any discrepancies in the initial connection response. This "reconnect sync" is the fallback safety net that ensures devices converge even when real-time pushes are missed.
The unread count sent during fan-out is the authoritative server-side count, not a client-derived delta. If a device receives unread_count: 0 for a channel, it sets its local count to 0 regardless of what it previously thought. This absolute-value approach prevents drift even when a device misses intermediate events during a reconnect.
Deep Dive 3: Notification Suppression
Mobile push notifications and WebSocket-based real-time events serve the same purpose from the user's perspective: alerting you to new messages. But they travel over completely different channels and arrive at completely different times. When both channels try to deliver the same information, you get double-notification: a push notification pops up on your locked phone, you unlock it to read, and then when you switch back to the desktop, a banner notification fires there too even though you already read the message. Slack has to suppress the second notification.
The suppression logic works in both directions. If your phone is backgrounded and Slack sends a push notification, it records that the notification was delivered (not read, just delivered) in a short-TTL log. When you pick up your phone, unlock it, and read the message in the Slack app, the app sends a mark_read event as normal. The server fans out to all sessions including the desktop. The desktop updates its badge. So far this is the standard flow.
The suppression case is when Slack needs to decide whether to trigger a desktop banner notification for a message that has already been delivered via push to the mobile. The rule is: if the push notification has already been delivered and the push delivery timestamp is newer than the desktop session's last seen timestamp for that channel, suppress the desktop notification sound and banner. The badge update still happens (the count changes), but the aggressive "ding" and pop-up do not fire again.
Slack's mobile apps send a "foreground event" to the server when the app comes to the foreground. This tells the server to hold any pending push notifications for up to 3 seconds while the WebSocket reconnects and the client fetches the latest state. This prevents a push from firing the instant you unlock your phone to check something unrelated, before Slack even has a chance to sync.
Mobile vs desktop session behavior
It is worth being explicit about how mobile and desktop sessions differ, because the hybrid model is a common interview follow-up.
Desktop Slack uses a single persistent WebSocket connection maintained for as long as the app is running. When the app loses network, it enters a reconnect loop, and on successful reconnect it performs the reconciliation sync described above. The read horizon propagation relies entirely on this WebSocket: no push notifications are involved for desktop.
iOS and Android Slack use WebSocket when the app is in the foreground. When the user switches away from the app or locks the screen, iOS suspends the network connection and the WebSocket closes. Slack then registers with APNS (Apple) or FCM (Google) to receive push notifications. New messages are delivered via push while the app is backgrounded.
When the user opens Slack from a push notification, the app transitions from background to foreground. The sequence is:
- App process resumes.
- App sends a "foreground event" to the Slack gateway.
- Gateway holds any in-flight push notifications for up to 3 seconds.
- App re-establishes WebSocket connection.
- App sends its last known channel cursors in the handshake.
- Server pushes reconciliation updates for any channels where the server horizon is ahead of the client.
- Push hold expires. Gateway resumes normal push delivery for any new messages.
Step 3 (holding pushes during reconnect) is the key trick. Without it, a race exists: the push arrives the instant the app opens, before the WebSocket has had a chance to fetch the latest read state. The push would show a badge for a message the user is about to read, creating a jarring double-alert. The 3-second hold gives the WebSocket time to sync and the app time to mark the channel as read before the push would have fired.
The practical result: if you tap a push notification and read the message before the 3-second hold expires, the push is cancelled server-side and no notification is shown. If you take longer than 3 seconds, the push fires as normal. This is the "foreground event" optimization mentioned in the Callout above.
The Tricky Parts
These are the details that separate a good answer from a great one. Most candidates get the basic fan-out model right but miss at least two of these.
-
Conflict resolution when two devices read simultaneously. If you read channel X on your phone at ts=1000 and read it on your desktop at ts=1005 at nearly the same time, both writes race to the server. The correct resolution is "highest timestamp wins": the server accepts both writes, but the final value stored is ts=1005. Any write that would move the horizon backward is silently discarded. This is equivalent to a last-write-wins policy where "last" means "furthest along in the message history," not "latest wall clock time." Never use wall clock time as the tiebreaker because client clocks can be skewed. Use the message timestamp, which the server controls.
-
Intentional unread (re-marking as unread). Slack supports long-pressing a message to mark it unread. This is not just a UI trick: it moves the read horizon backward in time. The server must allow read horizon updates in both directions for this feature. But this creates a security constraint: a malicious client cannot move another user's horizon forward to make messages appear read. Write access to the read horizon is restricted to the owning user's sessions only.
-
Debouncing rapid scrolling. When a user rapidly scrolls through a channel with 500 unread messages, the client should not send a
mark_readevent for every message boundary it passes. The client debounces: it updates a local "current read position" variable as the user scrolls, and only sends themark_readevent when the user has been stationary in the channel for at least 1 second, or when they navigate away. The debounce window prevents flooding the server with thousands of mark_read events per scroll session.
Here is the client-side debounce state machine that keeps the read horizon accurate without write amplification:
The key detail: the client always flushes its cursor immediately when the user navigates away from the channel, cancelling any pending 1-second timer and sending the mark_read event right away. This guarantees the server-side read horizon is never stale by more than one navigation action. The debounce only applies within a single channel session, not across channel switches.
4. Unread count accuracy vs. latency. The server computes the unread count as a range query against the message store. For a very active channel with tens of thousands of messages, this query can be expensive. Slack almost certainly caches a running message count per channel and tracks the channel's message count at the time of the user's last read, so the unread count is derived from two cached integers rather than a full query. This is an optimization worth mentioning in the interview.
-
Multi-workspace sessions. Slack users who belong to multiple workspaces have independent read states per workspace. The session registry and read horizon store are both scoped to (user_id, workspace_id, channel_id). A user reading a message in workspace A does not affect their read state in workspace B. But all sessions across all workspaces share the same WebSocket connection to the gateway, with workspace multiplexing at the protocol level. This means one fan-out operation can reach sessions across workspaces for the same user without opening multiple connections.
-
The session registry is write-heavy, not just read-heavy. Most candidates think of the session registry as a read-path concern (the fan-out needs to look up sessions). But it also gets written to constantly: every WebSocket connection open, every reconnect, every session heartbeat. At scale, this means the registry needs to support high-throughput writes without becoming a bottleneck. Redis with per-user hash keys is a standard approach: each user has a hash where fields are session IDs and values are gateway addresses. Connection events write to the hash; fan-out reads it once per event. Using a hash means all operations for one user are atomic at the key level.
-
Badge count can be briefly wrong during high-velocity channels. In a very active channel where messages are arriving at 10+ per second, the range query that computes the unread count is racing against new message inserts. The count returned at time T might already be stale by time T+50ms when the fan-out reaches the client. Slack accepts this transient inaccuracy -- the next mark_read event or channel load will correct the count. The system is designed so the badge count is "eventually exactly right" rather than "perfectly right at every instant." Users cannot perceive a 50ms lag in badge accuracy.
What Most People Get Wrong
| Mistake | What they say | Why it is wrong | What to say instead |
|---|---|---|---|
| Per-device read state | "Each device tracks which messages it has seen" | Devices cannot coordinate without a central source of truth; divergence is inevitable | "Read state is per-user, stored server-side. Devices are just renderers of that state." |
| Polling for badge updates | "The desktop polls for unread counts every few seconds" | Generates massive read traffic; badges lag by up to the poll interval | "Server pushes updated counts to all active sessions immediately after any mark_read event." |
| Counter maintenance | "We keep an unread counter and increment or decrement it" | Concurrent updates cause drift; deletes and edits are hard to account for | "We store a read horizon timestamp. Unread count is computed as a range query, not maintained." |
| Ignoring notification suppression | "Push notifications and WebSocket updates are independent" | Users get double-notified and it destroys the experience | "Push delivery is recorded. WebSocket notifications are suppressed if the user already acted on the push." |
| Clock-based conflict resolution | "Latest wall-clock write wins in a concurrent read race" | Client clocks are unreliable and can be up to minutes off | "Message timestamp wins, not wall clock. Higher message timestamp = further into the channel = correct winner." |
The most common mistake -- per-device read state -- is so persistent because it feels intuitive. Of course a device knows what it has shown the user! But this thinking conflates display state with authoritative read state. Display state is local and ephemeral. Authoritative read state must be durable, server-side, and device-independent. A device might crash, be wiped, or never sync with your other devices. The server is the only place that reliably spans all sessions.
The counter-vs-cursor mistake surfaces most often when candidates have worked on simple notification systems where incrementing a badge counter felt natural. The problem appears the moment you start asking: what happens when a message is deleted? What happens when a read races with a new message arriving? What happens when two devices read concurrently? Each of these breaks a counter, but none of them breaks a cursor. The cursor only moves forward; it never needs to be decremented.
Interviewers often probe the counter-vs-cursor distinction explicitly. If you say "unread count," expect a follow-up: "How do you handle message deletes?" The correct answer is that you never stored the count as a number you maintain -- you recompute it on demand from the cursor position and the messages table. Deleting a message just removes it from the range query result.
How I Would Communicate This in an Interview
Here is the structure I follow when answering this question in a live interview. The goal is not to dump everything at once but to build the answer progressively so the interviewer can redirect at any decision point.
Opening framing (30 seconds): "Before I get into the mechanics, the key insight here is that read state belongs to the user, not the device. Slack stores a single read horizon per user per channel server-side, and all devices are just consumers of that single canonical value. That is what makes multi-device sync tractable."
Core flow (2 minutes): Walk through the numbered steps in My Approach above. Draw the architecture diagram if you have a whiteboard. Emphasize: one write, one fan-out, authoritative server-side counts.
Read horizon model (1 minute): "Rather than storing per-message read state, Slack uses a cursor model. One timestamp per user per channel. The unread count is derived by querying how many messages have timestamps newer than that cursor. This is fast to write and correct to read."
Fan-out mechanics (1 minute): "The server keeps a session registry mapping each user ID to all active WebSocket connections. When the read horizon updates, the server pushes the new count to all sessions for that user except the one that triggered the update. The count sent is absolute, not a delta, so sessions that were temporarily disconnected cannot diverge."
Notification suppression (30 seconds): "On mobile, push notifications and WebSocket events can race. Slack suppresses the desktop banner notification if the user already acted on the push by reading the message in the app. The badge still clears, but no redundant audio or visual alert fires."
Close with tradeoffs (30 seconds): "The main tradeoff here is strong consistency vs. availability on the read horizon write. Slack needs strong consistency because a stale horizon causes flickering badges, which is genuinely confusing. That means the write path cannot use an eventually consistent store. You would use something like DynamoDB with strongly-consistent reads or a relational DB with per-channel row locking."
In the interview, pause after the core flow and ask "Does that cover the level of depth you are looking for, or should I go deeper on any particular part?" This shows confidence, keeps the conversation interactive, and prevents you from spending 10 minutes on a detail the interviewer does not care about.
Interview Cheat Sheet
- Read state is per-user, per-channel, stored server-side as a single timestamp cursor called the read horizon. Not per-device, not per-message.
- The read horizon model stores one row per user per channel. Unread count is computed as a range query, not maintained as a live counter.
- When a device sends a
mark_readevent, the server writes the new horizon, then fans out the updated unread count to all other active sessions for that user via their WebSocket connections. - The session registry (typically Redis) maps user IDs to the list of active session IDs and their associated gateway server addresses. Fan-out is a single Redis
SMEMBERSlookup followed by N push operations. - The unread count pushed during fan-out is an absolute server-computed value, not a delta. This prevents drift even when sessions miss intermediate events.
- Conflict resolution for simultaneous reads on two devices uses the message timestamp (not wall clock): whichever device's read horizon is further along in the channel's message history wins.
- Mobile clients use a hybrid: WebSocket when foregrounded, push notifications (APNS or FCM) when backgrounded. Push delivery is logged so the desktop can suppress redundant banner notifications after the user reads via push.
- "Intentional unread" moves the read horizon backward. The server allows backward updates but only from the owning user's sessions, preventing any other party from manipulating read state.
- Client-side debouncing prevents flooding the server during rapid scrolling. The client updates a local cursor as messages scroll past and sends a single
mark_readevent after 1 second of scroll inactivity. - Strong consistency is required on the read horizon write. An eventually consistent store risks showing a cleared badge briefly and then re-showing it, which users find more confusing than a 200ms delay in clearing.
Common follow-up questions
After you give the core answer, interviewers typically probe in one of these four directions. Expect at least one:
"How would you handle message deletes?" Answer: message deletes do not require any change to the read horizon. When a message is deleted, it is removed from the messages table (or soft-deleted with a deleted_at timestamp). The unread count range query automatically excludes deleted messages because it queries the live messages table. The cursor stays where it is. Nothing needs to be backfilled.
"What if the user has 50 active sessions?" Answer: the fan-out still works, but you need to ensure the fan-out is non-blocking. Each push is a separate WebSocket write. If you do them serially, the 50th push might be delayed by 50 * round-trip time. Use async parallel dispatch: dispatch all 50 pushes concurrently, with a timeout for any that take more than ~100ms. The occasional slow connection should not delay the fast ones.
"How does this work for Slack's shared channels (channels that span two workspaces)?" Answer: shared channels introduce a cross-workspace fan-out problem. The read horizon is likely scoped to (workspace_id, user_id, channel_id), but the same channel exists in two workspace graphs. If the user reads the channel in workspace A, workspace B also needs to reflect the updated count. This almost certainly requires a cross-workspace fan-out layer that is aware of the channel's mirrored membership. This is a hard problem and worth flagging as "out of scope for the MVP design" in the interview.
"How would you test this system?" Answer: the key invariant to test is "no session divergence after any sequence of mark_read events." Property-based tests that generate random sequences of concurrent reads from N sessions and assert that all sessions eventually converge to the same badge count are far more valuable than individual unit tests. Load tests should cover reconnect storms (10k clients reconnecting within 5 seconds) and high-velocity channels (100 messages per second in a single channel with 10k members).
"How would you design this differently for an email-like interface vs. a real-time chat interface?" Answer: email clients (like Gmail) can tolerate much higher latency for badge updates because users have different expectations -- an email badge lagging by ~5 seconds is acceptable, whereas a Slack badge lagging 5 seconds feels broken. This means you could use an eventually consistent store and polling instead of WebSockets for an email product. For real-time chat, the sub-500ms round-trip expectation drives the need for push-based fan-out, strong consistency, and always-on WebSocket connections. The cursor model works for both, but the delivery and consistency requirements differ significantly.
Test Your Understanding
Quick Recap
The key insight: read state in Slack belongs to the user, not the device. One timestamp cursor per user per channel lives on the server and is the single source of truth. Every device is a renderer of that state.
When you read a channel on one device, the client sends a mark_read event to the server. The server writes the new read horizon with strong consistency, then looks up all active sessions for that user in a Redis-backed session store, computes the new unread count server-side, and pushes the absolute count to every session except the originating one. The whole flow takes under 500ms end-to-end.
The three subtleties that make this hard:
- Conflict resolution: highest message timestamp wins when two devices race. Never use wall clock time.
- Notification suppression: push delivery is logged so desktop banners do not fire redundantly after the user already read via push.
- Debouncing: clients batch scroll events into a single write after a pause, preventing write amplification during rapid message browsing.
The complete read cycle in one place
It can help to see the full flow laid out end-to-end with approximate latencies:
| Step | What happens | Typical latency |
|---|---|---|
| User reads last message in channel | Client updates in-memory cursor (no network yet) | < 1ms local |
| User pauses scrolling for 1 second | Debounce timer fires; client sends mark_read event via WebSocket | 1s wait + ~10ms WS send |
| Server writes new read horizon | Single strongly-consistent row write to read-state store | ~5-20ms |
| Server looks up active sessions | Redis hash lookup: user ID -> list of (session_id, gateway_address) pairs | ~1ms |
| Server computes new unread count | Range query on messages table: count rows where ts > new_horizon | ~5-10ms |
| Server fans out to other sessions | One WebSocket push per active session on other devices | ~10-30ms each |
| Other device receives update | Client overwrites local unread count with server value; badge re-renders | < 1ms local |
Total round-trip from "user finishes reading" to "other device badge clears": roughly 30-70ms of server-side work, plus the 1-second client debounce. The user perceives this as "instant."
Thread reads use the same cursor model as channel reads, but with a separate read horizon per thread root. A channel can have unread = 0 while still showing an indicator that a thread you participated in has new replies. The two counters are maintained independently and fan out independently.
Scaling Considerations
At Slack-scale -- tens of millions of users, each in dozens of channels, across multiple devices -- the naive version of this architecture falls apart in at least three places. Here is where the load concentrates and how production systems address it.
Session registry hotspots: The fan-out service looks up every user's active sessions on every mark_read event. At peak, a single popular user might generate thousands of read events per hour across all their channels. If the session registry is a single Redis node partitioned by user ID, users with many connections (Slack bot accounts, enterprise accounts with custom integrations) can create hot partitions. The fix is to bucket the session registry by a hash of (user_id + shard_key) so lookups are distributed and no single shard bears disproportionate traffic.
Read horizon storage at scale: The read-state store holds one row per user per channel membership. A large enterprise with 100k users each in 50 channels means 5 million rows -- manageable. But Slack has millions of users in public community servers. The storage tier needs range scans on (channel_id, ts) to compute unread counts, which means you need a composite index on (channel_id, ts) as well as the primary key on (user_id, channel_id). These two access patterns point to different storage optimizations: the first is write-oriented (one row per membership), the second is read-oriented (scan recent messages by channel).
Fan-out amplification during reconnect storms: When a major network outage ends or a Slack deployment rolls over thousands of WebSocket connections simultaneously, large numbers of clients reconnect within a short window. Each reconnected client requests its full state: unread counts for all channels. This hits the read-state store and message store simultaneously. A reconnect storm on 100k clients requesting counts for 50 channels each is 5 million range queries in seconds. Production systems handle this with a reconnect queue that smooths the burst, plus aggressive server-side caching of recently-computed unread counts (since most users reconnect with the same unread state they had before the disconnect).
Cross-workspace boundaries: Enterprise Slack deployments often have users who are members of multiple workspaces. Each workspace has its own channel graph and its own read-state partition. Fan-out is entirely contained within a workspace boundary -- there is no cross-workspace read horizon sync. This is intentional: it dramatically simplifies the fan-out logic and means workspace-level outages cannot cascade across workspace boundaries.
These scaling concerns are the kind of follow-up an interviewer asks after you nail the happy-path design. A strong answer to "how would this scale to 50 million users?" is: partition the session registry by user, cache computed unread counts server-side, and smooth reconnect storms with a queued burst-control mechanism.
Design Alternatives
It is worth briefly discussing the road not taken, since an interviewer might ask "did you consider other approaches?"
Server-Sent Events (SSE) instead of WebSockets. SSE is a unidirectional push channel from server to client. It is simpler to implement and proxies handle it well. For read-horizon fan-out, SSE would work: the server pushes badge updates as events, and the client renders them. The limitation is that mark_read events from the client still need a separate REST call to the server. WebSockets allow full-duplex communication on a single connection, which is important for Slack because the same connection also carries typing indicators, real-time message delivery, presence heartbeats, and calls. Replacing WebSockets with SSE would require a separate HTTP connection for all client-to-server events, doubling the number of connections.
Long polling. Before WebSockets were widely supported, long polling was the standard approach: the client opens an HTTP request, the server holds it open until there is a state update to push, then the server responds and the client immediately opens a new request. This works for low-frequency updates like email badge counts, but for Slack-frequency events (typing indicators change tens of times per second, messages arrive continuously), long polling generates too much HTTP overhead and too much connection churn.
Firebase Realtime Database or similar. Using a managed real-time sync product would abstract away the WebSocket infrastructure. The tradeoff is vendor lock-in, cost at scale, and reduced ability to customize conflict resolution. For a startup prototyping a Slack-like feature, Firebase is a reasonable choice. For Slack itself at scale, the infrastructure investment in custom WebSocket gateways and session registries pays off in control and cost.
A common signal of a junior answer is immediately reaching for a specific technology (WebSockets, Firebase, Redis) without first establishing what properties the system needs. Senior engineers name requirements first: sub-500ms latency, server-authoritative state, absolute not delta updates. Then they choose technologies that satisfy those requirements and explain why the alternatives fall short.
The right choice depends on the scale and product context. In an interview, naming these alternatives and explaining the tradeoffs (not just "I would use WebSockets") is what earns a senior-level score. The interviewer wants to see that you understand that the choice is contextual, not obvious.
Related Concepts
- WebSocket connection management: Slack's gateway maintains millions of persistent WebSocket connections. The session registry and fan-out infrastructure described here also serves message delivery, presence updates, and real-time collaboration features.
- Push notification delivery: Understanding APNS (Apple Push Notification Service) and FCM (Firebase Cloud Messaging) is necessary to reason about the mobile-to-desktop notification suppression flow.
- Optimistic updates: Slack clients update their local UI immediately when the user performs an action, before the server ACK arrives. The server's authoritative response either confirms or corrects the local state. This is why badges clear instantly when you read a channel.
- Last-write-wins vs. multi-version concurrency: The read horizon uses a restricted form of LWW where "last" means furthest in message history. Full MVCC would be overkill here: there is no need to track all intermediate states of the read cursor.
- Session affinity and connection servers: The gateway architecture assumes that each session is pinned to one gateway server for its lifetime. Cross-gateway fan-out requires the session registry to include the gateway address, not just the session ID, so the fan-out service knows which server to send each push to.
- CRDT-based conflict resolution: The restricted LWW model used for the read horizon is conceptually simpler than a full Conflict-free Replicated Data Type, but if you needed to support collaborative editing of read state across fully disconnected clients, CRDTs would be the right tool. Understanding why Slack can avoid full CRDTs here (because the read horizon only ever needs to move forward under normal operation) is a useful contrast to draw when explaining the design.
- Event sourcing and the outbox pattern: The fan-out from the read horizon write to all active sessions is an event-driven side effect. In a system that uses event sourcing, the
mark_readevent would be written to an append-only event log first, and consumers (the fan-out service) would process it asynchronously. This adds durability to the fan-out: if the fan-out service crashes mid-delivery, it can resume from the last processed event in the log rather than losing the push entirely. The tradeoff is added latency and infrastructure complexity. - Presence and activity indicators: The session registry built for read-horizon fan-out is also the backbone of Slack's presence system (the green dot that shows whether a user is online). Presence events use the same session-lookup mechanism: when a user's status changes, the server fans out the presence update to all members of all shared channels, using the same session registry infrastructure. The read-horizon and presence systems share session registry reads but differ in fan-out fanout factor -- presence fans out to all shared-channel members, not just the reading user's own sessions.
- Session affinity and connection servers: The gateway architecture assumes that each session is pinned to one gateway server for its lifetime. Cross-gateway fan-out requires the session registry to include the gateway address, not just the session ID, so the fan-out service knows which server to send each push to.