Unread Counts and Read Receipts over SSE Permalink to this section

Part of Notification & Activity Feeds, under Real-Time Application Patterns.

Unread counts look like a trivial integer and behave like a distributed system. The same number is displayed in several tabs, on a phone and in a desktop app, changed by the user in any of them, and increased by other people at any moment. Read receipts add a second audience: other users who need to know that a message has been seen. This guide makes both consistent by giving the server sole ownership of the numbers and streaming versioned state over Server-Sent Events.

Symptom & Developer Intent Permalink to this section

  • A conversation shows “2 unread” on the phone after it was read on the laptop an hour ago.
  • The count flickers: it drops to 0 when the user opens a conversation, jumps back to 1, then settles at 0.
  • Counts drift upward over days until the user clicks “mark all read”.
  • Read receipts (“Seen by Ana”) appear for a message that arrived after Ana closed the conversation.
  • Two tabs show different counts for the same inbox.

The intent is one unread count per conversation and in total, identical on every device within about a second, never flickering and never drifting, plus read receipts that are only shown for messages the reader actually had on screen.

Root Cause Analysis Permalink to this section

Counts drift when more than one party writes them. A client that decrements locally on click, while the server also sends updated counts, applies each change twice or in the wrong order. Flicker is the visible form of that race: the optimistic local decrement, then a stale server value that was computed before the read landed, then the fresh one.

How an optimistic decrement causes flicker Sequence diagram in which a tab decrements the count locally, receives a stale unread event computed before its read was stored, and then a fresh one. How an optimistic decrement causes flicker Tab Server Other writer new message → recount (1) local decrement to 0 POST read unread 1 (computed before the read) unread 0 (after the read) the badge shows 1, 0, 1, 0 in under a second
The stale event was correct when the server computed it. Without a version, the client cannot tell that it predates its own write.

Read receipts have a different root cause: “read” is often recorded as “the user opened the conversation”, with no bound on which messages were on screen. A message that arrives after the conversation was opened but before a receipt is written gets marked as seen.

Both problems are solved with the same two ideas: a read position instead of read flags, and a version on every state event.

Step-by-Step Resolution Permalink to this section

Step 1 — Store a read position per user and conversation Permalink to this section

Instead of a read flag on every message, store the highest message sequence each member has read. The unread count is then a subtraction, and “mark read” is a single monotonic update.

CREATE TABLE read_positions (
  conversation_id bigint NOT NULL,
  user_id         bigint NOT NULL,
  last_read_seq   bigint NOT NULL DEFAULT 0,
  version         bigint NOT NULL DEFAULT 0,
  PRIMARY KEY (conversation_id, user_id)
);

-- Monotonic: a late or duplicated request can never move the position backwards.
UPDATE read_positions
   SET last_read_seq = GREATEST(last_read_seq, $3), version = version + 1
 WHERE conversation_id = $1 AND user_id = $2
RETURNING last_read_seq, version;

The unread count for a conversation is latest_seq - last_read_seq, which is exact and cheap. GREATEST makes the update idempotent and immune to requests arriving out of order.

Step 2 — Mark read with the sequence that was on screen Permalink to this section

// Client: report the newest message the user could actually see.
function reportRead(conversationId, visibleSeq) {
  navigator.sendBeacon?.(
    `/api/conversations/${conversationId}/read`,
    new Blob([JSON.stringify({ seq: visibleSeq })], { type: 'application/json' }),
  ) || fetch(`/api/conversations/${conversationId}/read`, {
    method: 'POST', keepalive: true, credentials: 'include',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ seq: visibleSeq }),
  });
}

Use an IntersectionObserver on message rows to determine visibleSeq, and only report while the document is visible. A message below the fold, or in a background tab, is not read.

Step 3 — Publish versioned state to every interested stream Permalink to this section

After the update commits, publish two things: the reader’s new unread state to their own channel, and a receipt to the conversation’s channel.

app.post('/api/conversations/:id/read', requireSession, async (req, res) => {
  const pos = await markRead(req.params.id, req.user.id, Number(req.body.seq));
  const unread = await unreadFor(req.user.id);                       // { total, byConversation }

  await bus.publish(`user:${req.user.id}:control`, JSON.stringify({
    type: 'unread', version: pos.version, ...unread,
  }));
  await bus.publish(`conversation:${req.params.id}`, JSON.stringify({
    type: 'receipt', user: req.user.id, seq: pos.last_read_seq,
  }));
  res.status(204).end();
});

On the wire, the unread event carries its version so clients can discard stale ones:

event: unread
data: {"version":57,"total":4,"byConversation":{"812":0,"815":3,"901":1}}

event: receipt
data: {"conversation":812,"user":17,"seq":4410}

For the per-user version to be meaningful across conversations, keep a single counter per user (for example a user_unread_versions row incremented in the same transaction) rather than the per-conversation version shown above.

Step 4 — Apply state events only if they are newer Permalink to this section

let unreadState = { version: -1, total: 0, byConversation: {} };

es.addEventListener('unread', (e) => {
  const next = JSON.parse(e.data);
  if (next.version <= unreadState.version) return;   // stale: computed before a later write
  unreadState = next;
  renderBadges(unreadState);
});

With versions in place the client never needs an optimistic decrement. The server’s response to the read arrives within a round trip, and the stream’s event is the single update to the badge.

Accepting or rejecting an unread event State diagram of the client's unread state with incoming events accepted when their version is higher and ignored when it is equal or lower. Accepting or rejecting an unread event HOLDING v57 badge 4 EVENT v56 stale EVENT v58 newer HOLDING v58 badge 3 arrives applied ignored
Versions turn out-of-order delivery into a no-op. The only transition that changes the badge is a strictly newer version.

Step 5 — Render receipts from positions, not from events Permalink to this section

A receipt says “user 17 has read up to 4410”. To show “Seen by Ana” under a message, compare the message’s sequence with each member’s position; the latest message at or below a member’s position gets their avatar.

function seenBy(messageSeq, positions) {
  return Object.entries(positions)
    .filter(([, seq]) => seq >= messageSeq)
    .map(([userId]) => userId);
}

Because receipts are positions, a missed receipt event is repaired by the next one, and a reconnecting client can fetch the current positions in the stream’s opening snapshot.

Step 6 — Open every stream with the current state Permalink to this section

Unread counts and receipts are state, so the reconnect rule is the dashboard rule: send a snapshot first. When the conversation stream opens, write the member positions and the user’s unread state before any live events.

app.get('/api/conversations/:id/stream', requireMember, async (req, res) => {
  openStream(res);
  const [positions, unread] = await Promise.all([
    db.any('SELECT user_id, last_read_seq FROM read_positions WHERE conversation_id = $1', [req.params.id]),
    unreadFor(req.user.id),
  ]);
  res.write(`event: positions\ndata: ${JSON.stringify(Object.fromEntries(positions.map((p) => [p.user_id, p.last_read_seq])))}\n\n`);
  res.write(`event: unread\ndata: ${JSON.stringify(unread)}\n\n`);
  // …then messages after the cursor, then live receipts and messages.
});

A client that was offline while three people read the conversation receives their positions in one frame instead of waiting for each of them to read something else. Because the snapshot’s unread version is the current one, any older unread event still in flight is discarded by the version check in step 4.

Validation & Monitoring Permalink to this section

# Out-of-order reads must not move the position backwards.
curl -s -b s.txt -X POST -d '{"seq":4410}' -H 'Content-Type: application/json' \
  https://app.example.com/api/conversations/812/read
curl -s -b s.txt -X POST -d '{"seq":4390}' -H 'Content-Type: application/json' \
  https://app.example.com/api/conversations/812/read
curl -s -b s.txt https://app.example.com/api/conversations/812/position
# {"last_read_seq":4410}

For the client, open two tabs and a phone session, read a conversation on the phone, and time how long the laptop badges take to update. Record every applied unread event’s version in the console during the test; versions must be strictly increasing in each tab.

Time for a read on one device to clear badges elsewhere Bar chart comparing badge convergence time across three designs: periodic polling, client-side decrement with unversioned events, and versioned server state over SSE. Time for a read on one device to clear badges elsewhere Poll counts every 30 s ~15 s, no flicker Local decrement + events 0.4 s, flickers Versioned state over SSE 0.3 s, stable median seconds until every device shows the same count
Versioned server state is both the fastest to converge and the only design without flicker or drift.

In production, emit a metric when a client discards a stale unread event. A small rate is expected; a high rate suggests a slow path is publishing state computed long before it is sent.

Production Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

Why use a read position instead of a read flag per message?

A position turns counting into subtraction and marking read into one row update, and it is naturally monotonic. Per-message flags require touching every row and make out-of-order updates able to un-read messages.

Is the optimistic decrement never worth it?

It saves one round trip of perceived latency, typically under 300 ms, and costs flicker and drift. If you keep it, the server's versioned event must still be authoritative, and the local value must be discarded when it arrives.

How do I avoid receipts for messages the user never saw?

Report the highest sequence that was visible in the viewport while the document was visible, rather than marking the conversation read on open. Messages that arrive while the tab is hidden stay unread.

Do read receipts need their own SSE connection?

No. Publish them on the conversation channel and let the existing stream multiplex them as a named event type alongside messages.