Building a Notification Bell with SSE Permalink to this section

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

The notification bell is three pieces of interface — a badge with a count, a dropdown list and transient toasts — driven by one stream. Each piece has a different correctness rule, and most bell bugs come from treating them the same. This guide builds all three on a single Server-Sent Events connection, in plain JavaScript that drops into any framework.

Symptom & Developer Intent Permalink to this section

The requirements sound simple and the failures are familiar:

  • The badge says 3 but the dropdown shows 5 unread items, or the badge goes negative after marking items read.
  • Reloading the page produces a burst of toasts for notifications the user has already seen.
  • A notification that arrived while the laptop was asleep never appears until the next full reload.
  • Opening the dropdown in one tab does not clear the badge in another tab.
  • The dropdown shows the same item twice after a flaky connection.

The intent is a bell where the badge always equals the number of unread items the server knows about, the list never duplicates or loses an item, and toasts appear exactly once, only for items that are genuinely new.

Root Cause Analysis Permalink to this section

The three pieces of interface have different data shapes:

Three pieces of one bell, three different correctness rules Matrix comparing the badge, the dropdown list and toasts by data shape, source of truth and behaviour on reconnect. Three pieces of one bell, three different correctness rules Piece Data shape Source of truth On reconnect Badge current count server count replace value Dropdown list append-only log server rows merge by id Toast side effect client memory must not repeat
The badge is state, the list is an append-only log, and toasts are one-shot side effects. Each needs its own handling of the same stream.

When the client derives the badge by counting unread items in its local list, the badge is only as correct as the list — and the list is a window on the server’s data, not all of it. When the client increments the badge on each notification event and decrements on each click, every missed or duplicated event corrupts the count permanently. And when toasts fire on every notification event, the replay after a reconnect, or the first page load, looks like a flood of new activity.

The fix is to let each piece follow its own shape: the server sends the count as authoritative state, the list is merged by id, and toasts are gated on “new to this client and newer than page load”.

Step-by-Step Resolution Permalink to this section

Step 1 — Send the unread count as its own event Permalink to this section

The server computes the count and sends it after the replay and after every change that affects it. The event has no id, so it never moves the browser’s replay cursor.

// Server: after replay, and after any notify() or markRead() for this user.
async function sendUnread(res, userId) {
  const { count } = await db.one(
    'SELECT count(*)::int AS count FROM notifications WHERE user_id = $1 AND read_at IS NULL',
    [userId]);
  res.write(`event: unread\ndata: {"count":${count}}\n\n`);
}

With a partial index — CREATE INDEX ON notifications (user_id) WHERE read_at IS NULL — this count stays cheap even for users with years of history.

Step 2 — Build a store that merges by id Permalink to this section

// bell-store.js
export function createBellStore(url) {
  const state = { items: new Map(), unread: 0, status: 'connecting' };
  const subs = new Set();
  const publish = () => subs.forEach((fn) => fn(state));
  const pageLoadedAt = Date.now();
  const toasted = new Set();

  const es = new EventSource(url, { withCredentials: true });
  es.onopen = () => { state.status = 'live'; publish(); };
  es.onerror = () => { state.status = es.readyState === 2 ? 'closed' : 'reconnecting'; publish(); };

  es.addEventListener('notification', (e) => {
    const n = JSON.parse(e.data);
    const known = state.items.has(n.id);
    state.items.set(n.id, n);                     // idempotent: duplicates overwrite
    if (!known && !toasted.has(n.id) && Date.parse(n.ts) > pageLoadedAt) {
      toasted.add(n.id);
      toast(n);                                   // only genuinely new items
    }
    publish();
  });
  es.addEventListener('unread', (e) => { state.unread = JSON.parse(e.data).count; publish(); });

  return { subscribe: (fn) => (subs.add(fn), fn(state), () => subs.delete(fn)), close: () => es.close() };
}

The pageLoadedAt check prevents the initial replay from toasting; the toasted set prevents a reconnect replay from toasting the same item twice within the page’s life.

Step 3 — Render the badge from the server count only Permalink to this section

function renderBadge(el, { unread }) {
  el.hidden = unread === 0;
  el.textContent = unread > 99 ? '99+' : String(unread);
  el.setAttribute('aria-label', `${unread} unread notifications`);
}

Never compute the badge from items. The list may hold only the most recent page, while the count covers everything.

Step 4 — Mark read through an ordinary request and let the stream confirm it Permalink to this section

async function markAllRead(store) {
  const newest = Math.max(0, ...store.state.items.keys());
  // Mark up to a specific id so items that arrive meanwhile stay unread.
  await fetch('/api/notifications/read', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ upTo: newest }),
    credentials: 'include',
  });
  // No local decrement: the server publishes a new `unread` event to every tab and device.
}

The upTo bound is the important detail. “Mark all read” without it would also mark a notification that arrived between the user opening the dropdown and the request reaching the server — an item the user never saw.

Marking read in one tab updates every tab Sequence diagram in which tab A posts a mark-read request, the server updates the database and publishes a new unread count that the SSE stream delivers to both tab A and tab B. Marking read in one tab updates every tab Tab A API + SSE Tab B POST /read upTo: 8813 UPDATE read_at, recount event: unread count 0 event: unread count 0
No tab edits the count locally. The server is the only writer of the badge, so every tab and device converges on the same number.

Step 5 — Load older items over HTTP, not through the stream Permalink to this section

The dropdown’s “load more” is a paginated GET. Merge the results into the same items map; ids make the merge safe regardless of order.

async function loadOlder(store, beforeId) {
  const res = await fetch(`/api/notifications?before=${beforeId}&limit=20`, { credentials: 'include' });
  for (const n of await res.json()) store.state.items.set(n.id, n);
}

Step 6 — Share one stream between tabs Permalink to this section

Ten open tabs mean ten streams. On HTTP/1.1 that quickly exhausts the six connections per origin and blocks other requests. Run the store in one tab and broadcast state to the others, as shown in leader election with BroadcastChannel. The bell code does not change; only where the store lives does.

Validation & Monitoring Permalink to this section

Four checks that catch almost every bell bug Flow of four manual checks run in order: reload, offline gap, two tabs, and mark-all-read with a concurrent arrival. Four checks that catch almost every bell bug Reload page no toast burst Offline 30 s item appears once Two tabs badges agree Mark all + arrival new item unread
Run them in this order after any change to the bell. Each one targets a different piece of the interface.
  1. Reload with unread items: the list and badge populate, and no toasts appear.
  2. Offline gap: set DevTools to Offline, trigger a notification from another session, restore the network. The item appears once, with one toast.
  3. Two tabs: mark read in one; the other’s badge updates within a second.
  4. Race: open the dropdown, trigger a notification, then click “mark all read”. The new item must remain unread.
# Server-side assertion for check 4: the count after mark-read should include the late item.
curl -s -b session.txt -X POST -H 'Content-Type: application/json' \
  -d '{"upTo":8813}' https://app.example.com/api/notifications/read
curl -s -b session.txt https://app.example.com/api/notifications/unread
# {"count":1}  ← the notification created after 8813 is still unread

In production, log a client-side counter when the badge value from the server differs from the count of unread items the client can see in a fully loaded list. It should be zero; a non-zero rate means some write path forgot to publish an unread event.

Production Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

Why not increment the badge on each notification event?

Because the client cannot know whether it saw every event exactly once. A missed event makes the badge low forever and a replayed one makes it high. Letting the server send the count removes the arithmetic from the client entirely.

How do I avoid toasts for old notifications on page load?

Compare the notification's creation time with the time the page loaded, and keep a set of ids already toasted. Replayed and initial items are older than the page, so they populate the list silently.

Should the dropdown open with a separate HTTP fetch?

The stream's replay already delivers the most recent items, so the dropdown can render from the store immediately. Use HTTP only for older pages, where streaming would be the wrong tool.

What should the bell show before the stream connects?

Render the count embedded in the initial HTML or fetched with the page, then let the stream's first unread event replace it. Showing nothing until the stream opens makes the badge pop in late and shifts the layout.

How do I show the bell's connection state?

Expose the store's status and dim the bell or show a small indicator while reconnecting. Users rarely need detail, but a bell that silently stopped updating is worse than one that admits it.