Notification System
Design in-app, cross-tab and push notification delivery with one unread count that stays correct everywhere, plus grouping, deduplication and a permission flow users accept.
Advertisement
The Problem
Design the notification system for a product: a bell icon with an unread count, a dropdown listing recent notifications, delivery while the app is open, and delivery when it is closed.
The parts that make this hard are not the bell icon. They are one piece of state that must be correct in several places at once - across tabs, across devices, across a live connection and a push service - and a permission you get exactly one chance to ask for. Get the second one wrong and the feature is permanently unavailable to that user.
Requirements
Functional
- Notifications appear in-app in real time while a session is open.
- Unread count accurate and consistent across all open tabs.
- Marking read in one tab reflects immediately in the others.
- System notifications when the app is closed, if the user has granted permission.
- Clicking a notification opens the relevant content, whether or not the app is open.
- High-frequency events grouped rather than listed individually.
Non-functional
- The same logical event is never shown or counted twice.
- One live connection per user, not one per tab.
- Permission requested only in context, and never on load.
- Read state converges on the server as the source of truth.
Delivery Channels
Three channels covering three application states. None of them covers all three, which is why a real system uses all three.
Diagram100%flowchart TB EV["Domain event<br/>(comment created, order shipped)"] subgraph SERVER["Server-side notification service"] DEDUP["assign stable eventId<br/>+ groupKey"] AGG["aggregation window<br/>(collapse 12 likes -> 1)"] PREF["user preferences<br/>+ quiet hours + rate limit"] ROUTE{"does the user have<br/>an active session?"} STORE[("notifications table<br/>read/unread per user")] end EV --> DEDUP --> AGG --> PREF --> STORE PREF --> ROUTE ROUTE -->|"yes"| WS["live connection<br/>(WebSocket / SSE)"] ROUTE -->|"no, and importance is high"| PUSH["Web Push<br/>via OS push service"] ROUTE -->|"no, low importance"| NONE["store only -<br/>seen on next visit"] WS --> LEADER["leader tab"] PUSH --> SW["Service Worker<br/>(runs with no page open)"] LEADER -->|"BroadcastChannel"| TABS["every other tab<br/>updates count + list"] SW -->|"showNotification(tag = groupKey)"| OS["OS notification centre"] SW -->|"BroadcastChannel, if any<br/>client is open"| TABS OS -->|"notificationclick"| SW SW -->|"focus existing client<br/>or open a new window"| APP["app at the deep link"] style SERVER fill:#0f172a,stroke:#334155 style STORE fill:#1e3f2d,stroke:#22c55e style WS fill:#1e3a5f,stroke:#3b82f6 style PUSH fill:#3f2d1e,stroke:#f59e0b style NONE fill:#1e293b,stroke:#475569visualized by
The live connection is the primary channel. Instant, cheap, no permission required, and it can carry the full notification payload so the in-app list updates without a follow-up fetch. The transport tradeoffs - SSE versus WebSocket versus polling, heartbeats, backoff, sequence-based replay - are exactly those in Real-Time Feed, and a notification stream is usually the second consumer of the same connection rather than a new one.
Web Push reaches a closed browser. It costs a permission prompt, routes through an OS push service you do not control, and is subject to rate limits and the notification centre's own rules. It is the wrong tool for the dozen small updates an active session produces and the right tool for the two that matter when the user is away.
Store-only is the third channel and the one people forget: persist it, show it on next visit, interrupt nobody. Most notifications belong here.
The routing decision - live, push, or store-only - is server-side, because only the server knows whether a session is active, what the user's preferences are, and whether it is 3am for them.
The Notification State Model
The bell icon looks like one number. It is derived state over a list, and modelling it as a number is the source of every drift bug.
type Notification = {
/** Server-assigned, identical across every delivery channel. */
id: string;
/** Notifications sharing a groupKey collapse into one row. */
groupKey: string;
type: 'comment' | 'mention' | 'like' | 'follow' | 'system';
actorIds: string[];
subject: { kind: 'post' | 'order'; id: string; title: string };
createdAt: number;
readAt: number | null;
seenAt: number | null;
deepLink: string;
};
type NotificationState = {
items: Notification[];
/** Server's count, used to reconcile rather than to display directly. */
serverUnreadCount: number;
/** Ids marked read locally but not yet confirmed. */
pendingRead: Set<string>;
cursor: string | null;
connection: 'connected' | 'reconnecting' | 'offline';
};
/** Derived, never stored. Storing it is how counts drift. */
function unreadCount(state: NotificationState): number {
const loadedUnread = state.items.filter(
(item) => item.readAt === null && !state.pendingRead.has(item.id),
).length;
// The list is paginated, so items beyond the first page are not loaded.
// Trust the server's total, minus what we have optimistically marked read.
return Math.max(
loadedUnread,
state.serverUnreadCount - state.pendingRead.size,
);
}
Three modelling decisions worth defending.
seenAt and readAt are different. Seen means the dropdown was opened and the notification was displayed - which clears the badge. Read means the user acted on it. Conflating them means either the badge never clears until every item is clicked, or opening the dropdown marks everything read and the user loses track of what they had not dealt with. Most products clear the badge on seen and keep per-item read state.
The count is derived, not stored. The moment you keep a count field, some path increments it without adding an item (or the reverse) and it drifts permanently upward. Deriving it from the list plus the server total makes drift impossible by construction.
Optimistic read is tracked separately. pendingRead lets the count drop instantly on click while remaining reconcilable if the request fails - the same optimistic-with-rollback shape used in Shopping Cart, and the reason it belongs in the shared server-state layer described in State Management Architecture.
Cross-Tab Synchronisation
A user with three tabs open should not get three connections, three copies of every event, and three different counts.
Leader election
One tab owns the connection. The others receive its output.
const CHANNEL_NAME = 'notifications';
const HEARTBEAT_MS = 2_000;
const LEADER_TIMEOUT_MS = 5_000;
type ChannelMessage =
| { kind: 'heartbeat'; tabId: string; at: number }
| { kind: 'claim'; tabId: string }
| { kind: 'notification'; notification: Notification }
| { kind: 'read'; ids: string[] }
| { kind: 'seen'; at: number }
| { kind: 'resync'; serverUnreadCount: number };
class NotificationSync {
private channel = new BroadcastChannel(CHANNEL_NAME);
private tabId = crypto.randomUUID();
private isLeader = false;
private lastLeaderBeat = 0;
constructor(private store: NotificationStore) {
this.channel.onmessage = (event) => this.onMessage(event.data as ChannelMessage);
// Claim leadership if nobody has announced within the timeout.
setInterval(() => this.tick(), HEARTBEAT_MS);
this.channel.postMessage({ kind: 'claim', tabId: this.tabId });
}
private tick() {
const now = Date.now();
if (this.isLeader) {
this.channel.postMessage({ kind: 'heartbeat', tabId: this.tabId, at: now });
return;
}
// The leader has gone quiet - it was closed, backgrounded and frozen, or
// crashed. Take over.
if (now - this.lastLeaderBeat > LEADER_TIMEOUT_MS) this.becomeLeader();
}
private becomeLeader() {
this.isLeader = true;
this.store.openConnection((notification) => {
this.store.add(notification);
// Fan out locally so the other tabs need no network round trip.
this.channel.postMessage({ kind: 'notification', notification });
});
}
private onMessage(message: ChannelMessage) {
switch (message.kind) {
case 'heartbeat':
this.lastLeaderBeat = message.at;
// Deterministic tie-break when two tabs claimed at once: lowest id wins.
if (this.isLeader && message.tabId < this.tabId) this.stepDown();
break;
case 'notification':
this.store.add(message.notification); // deduped by id inside add()
break;
case 'read':
this.store.markReadLocally(message.ids);
break;
case 'seen':
this.store.markSeenLocally(message.at);
break;
case 'resync':
this.store.setServerUnreadCount(message.serverUnreadCount);
break;
}
}
/** Called by whichever tab the user acted in. */
publishRead(ids: string[]) {
this.channel.postMessage({ kind: 'read', ids });
}
}
Four details this handles that a naive version does not:
- Leader failure is detected by silence, not by a close event. A crashed tab, or one frozen by the browser after being backgrounded, sends no
beforeunload. Heartbeat timeout is the only reliable signal. - Simultaneous claims are broken deterministically by comparing tab ids, so the system does not oscillate between two leaders.
- Local fan-out costs no requests. A notification arriving in the leader reaches the other tabs over the channel, not over the network.
- Idempotent application. Every tab runs
add(), which dedupes by id, so a message arriving twice is harmless.
BroadcastChannel is well supported; the localStorage storage event is the fallback and is worth knowing, because it has a real quirk - the event fires in other tabs but not the one that wrote, which is convenient here, though it is a synchronous main-thread API and should carry only small signals.
A SharedWorker is the stronger alternative: the worker itself holds the connection, so there is no election at all and no leader to lose. Support is weaker (notably absent on some mobile browsers), so leader election remains the portable pattern.
The server is still the tiebreaker. A tab that was frozen for an hour reconciles by fetching the authoritative count on visibilitychange, rather than trusting whatever it last heard:
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'visible') return;
// Cheap, authoritative, and it repairs any drift that accumulated while
// this tab was frozen or offline.
void store.reconcileWithServer();
});
Web Push
Push delivery has three distinct phases and each has its own failure modes.
Diagram100%sequenceDiagram participant U as User participant P as Page participant SW as Service Worker participant PS as Push service (OS/browser vendor) participant API as Your API Note over U,P: Phase 1 - registration, at a moment with context P->>SW: navigator.serviceWorker.register('/sw.js') U->>P: follows a thread P->>U: in-page prompt "Get replies to this thread?" U->>P: accepts P->>P: Notification.requestPermission() U->>P: allows (the real OS prompt) P->>PS: pushManager.subscribe({ applicationServerKey }) PS-->>P: subscription {endpoint, p256dh, auth} P->>API: POST /push/subscriptions API->>API: store per user AND per device Note over U,API: Phase 2 - delivery, browser closed API->>API: event; no active session API->>PS: encrypted payload (VAPID-signed, TTL, urgency) PS->>SW: 'push' event (wakes the worker) SW->>SW: decrypt, dedupe by eventId SW->>SW: showNotification(title, {tag: groupKey, data: {deepLink}}) SW->>SW: BroadcastChannel - update any open tab Note over U,API: Phase 3 - click-through U->>SW: clicks the notification SW->>SW: 'notificationclick' - close it SW->>SW: clients.matchAll() - is a window already open? SW->>API: mark seen alt window exists SW->>P: focus it + postMessage(deepLink) else no window SW->>P: clients.openWindow(deepLink) endvisualized by
Permission, requested correctly
This is the highest-stakes UX decision in the whole design, because a denial is effectively permanent. The browser will not ask again, and recovery requires the user to find a settings panel themselves. Almost nobody does.
type PushSetupResult =
| { status: 'subscribed' }
| { status: 'declined-softly' } // our prompt, recoverable
| { status: 'denied' } // browser-level, not recoverable
| { status: 'unsupported' };
async function setUpPush(context: string): Promise<PushSetupResult> {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
return { status: 'unsupported' };
}
if (Notification.permission === 'denied') return { status: 'denied' };
if (Notification.permission === 'default') {
// Our own prompt first. If they say no here, nothing is spent - the
// browser-level permission is untouched and we can ask again later.
const accepted = await showInAppPermissionExplainer(context);
if (!accepted) {
recordSoftDecline(context);
return { status: 'declined-softly' };
}
const permission = await Notification.requestPermission();
if (permission !== 'granted') return { status: 'denied' };
}
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
// Chrome requires this: no silent pushes without a visible notification.
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
await api.savePushSubscription(subscription.toJSON());
return { status: 'subscribed' };
}
The rules, in order of importance:
- Never prompt on load. No context, high denial rate, one shot spent.
- Prompt at a moment of demonstrated intent - after following a thread, placing an order, joining a conversation - and say specifically what you will send.
- Use a soft prompt first. A "no" to your own dialog is recoverable; a "no" to the browser's is not.
- Require a user gesture. Browsers increasingly suppress the prompt entirely when it is not tied to an interaction.
- Handle
deniedgracefully. Do not nag. Offer the in-app inbox and email as alternatives.
The Service Worker handler
// sw.js - runs with no page open. Nothing here can assume a DOM.
self.addEventListener('push', (event) => {
event.waitUntil(handlePush(event));
});
async function handlePush(event) {
// A push with no data still has to show something: Chrome revokes the
// subscription if userVisibleOnly pushes do not produce a notification.
const payload = event.data?.json() ?? {
title: 'New activity',
body: 'Open the app to see what happened.',
eventId: `fallback-${Date.now()}`,
groupKey: 'generic',
};
if (await wasAlreadyHandled(payload.eventId)) return;
await recordHandled(payload.eventId);
await self.registration.showNotification(payload.title, {
body: payload.body,
icon: '/icons/notification-192.png',
badge: '/icons/badge-72.png',
// Same tag replaces rather than stacks - this is OS-level grouping.
tag: payload.groupKey,
renotify: payload.importance === 'high',
data: { deepLink: payload.deepLink, eventId: payload.eventId },
actions: payload.actions?.slice(0, 2),
});
// If a tab happens to be open, keep it in step with the OS notification.
const clients = await self.clients.matchAll({ type: 'window' });
const channel = new BroadcastChannel('notifications');
channel.postMessage({ kind: 'notification', notification: payload.notification });
channel.close();
}
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(openDeepLink(event.notification.data.deepLink));
});
async function openDeepLink(deepLink) {
const clients = await self.clients.matchAll({
type: 'window',
includeUncontrolled: true,
});
// Reuse an existing window rather than opening a fifth tab of the same app.
for (const client of clients) {
if (new URL(client.url).origin === self.location.origin) {
await client.focus();
client.postMessage({ kind: 'navigate', to: deepLink });
return;
}
}
await self.clients.openWindow(deepLink);
}
Four platform constraints that shape this code:
userVisibleOnly: trueis mandatory in Chrome, and a push that does not result in a visible notification can get the subscription revoked. Hence the fallback payload.tagis OS-level grouping. Two notifications with the same tag replace each other rather than stacking - the cheapest possible defence against flooding the notification centre.event.waitUntilis required. Without it the worker can be terminated mid-handler, since it is only alive for the duration of the event.- Focus an existing window before opening a new one. Otherwise every notification click opens another tab of your app.
Subscriptions expire and rotate. Handle pushsubscriptionchange, re-subscribe and re-register, and treat a 410 Gone from the push service as a signal to delete the stored subscription. Subscriptions are also per device, not per user, so one user has several - each needs its own row, and a read on one device should ideally clear the notification on the others via a follow-up push.
The Service Worker lifecycle, registration and update semantics behind all of this are covered in Offline and PWA Architecture; push is the capability that makes a Service Worker worth having beyond caching.
Deduplication and Grouping
Two distinct problems that are easy to conflate.
Deduplication is correctness: the same logical event must not be counted twice. Duplicates are not hypothetical - the same event can arrive over the live connection and as a push if the server was unsure whether a session was active, be replayed from a sequence buffer after reconnect, or be delivered twice because a retry succeeded after its acknowledgement was lost.
class SeenEventIds {
private ids = new Set<string>();
private order: string[] = [];
constructor(private capacity = 500) {}
/** Returns true the first time an id is seen, false thereafter. */
claim(id: string): boolean {
if (this.ids.has(id)) return false;
this.ids.add(id);
this.order.push(id);
if (this.order.length > this.capacity) {
this.ids.delete(this.order.shift() as string);
}
return true;
}
}
The server must assign the id, and it must be identical across channels. A client-generated id, or a per-channel id, cannot dedupe across channels - which is the case that actually produces duplicates. For push, the check must live in the Service Worker with the record in IndexedDB, because the worker has no page and no memory between events.
Grouping is presentation: twelve likes on one post is one notification, not twelve.
function groupNotifications(items: Notification[]): GroupedNotification[] {
const byKey = new Map<string, Notification[]>();
for (const item of items) {
const existing = byKey.get(item.groupKey);
if (existing) existing.push(item);
else byKey.set(item.groupKey, [item]);
}
return Array.from(byKey.values()).map((group) => {
const actors = Array.from(new Set(group.flatMap((item) => item.actorIds)));
return {
groupKey: group[0].groupKey,
count: group.length,
actors,
// "Ana and 11 others liked your post"
title: summarise(actors, group[0].type, group[0].subject),
// The group is unread if any member is.
isUnread: group.some((item) => item.readAt === null),
latestAt: Math.max(...group.map((item) => item.createdAt)),
memberIds: group.map((item) => item.id),
};
});
}
Grouping belongs on both sides. Server-side aggregation with a short window means one delivery instead of twelve, which saves bandwidth, push quota and the user's attention. Client-side grouping handles the items that arrived separately anyway - across a reconnect, or from different channels.
A rate limit is the backstop: a runaway producer should degrade to a summary ("47 new notifications") rather than flooding the notification centre, because a user who is flooded once tends to revoke permission, and you cannot get that back.
The Bell and the Dropdown
<button
type='button'
aria-label={
count > 0 ? `Notifications, ${count} unread` : 'Notifications'
}
aria-expanded={isOpen}
aria-haspopup='true'
onClick={onToggle}>
<Bell aria-hidden className='h-5 w-5' />
{count > 0 && (
// Hidden from assistive tech: the count is already in the button's label,
// so exposing it here would announce the number twice.
<span aria-hidden className='absolute -top-1 -right-1 rounded-full ...'>
{count > 99 ? '99+' : count}
</span>
)}
</button>
{/* Announce arrivals for users who are not watching the icon. */}
<div role='status' aria-live='polite' className='sr-only'>
{latestAnnouncement}
</div>
Three details. The count goes in the aria-label, not only in a visual badge, and the badge itself is aria-hidden so it is not announced twice. Arrivals are announced through a polite live region - assertive would interrupt whatever the user is doing, which is exactly what a notification should not do. And clearing the badge should fire on seen (the dropdown opened) rather than requiring every item to be clicked, per the state model above.
The dropdown itself is a paginated list, so it inherits everything from Infinite Scroll - cursor pagination, and a merge strategy that must not shift content while the user is reading it.
Common Interview Follow-Up Questions
"A user reads a notification on their phone. How does the badge clear on their laptop?"
Read state has to be server-owned and pushed to every device, not kept per client. The phone marks read, the server updates the row and then notifies the user's other sessions - over the live connection for anything open, and optionally as a silent push to other devices' Service Workers so they can call getNotifications() and close the matching displayed notification by tag. A tab that was closed during all of this reconciles on next focus by fetching the authoritative count. The general shape is that every client treats its local read state as optimistic and the server as authoritative, which is the same reconciliation model as Shopping Cart.
"Push notifications are being sent at 3am. What did the design miss?" Server-side scheduling with the user's timezone. Quiet hours have to be enforced where routing decisions are made, because the client is asleep and cannot filter anything. Notifications produced during quiet hours are stored and either batched into a morning digest or delivered silently, with urgency levels providing an override for the genuinely time-critical - a security alert should still interrupt. This is also where per-type preferences belong, since the correct answer for most users is not "all or nothing" but "mentions yes, likes no".
"How do you test this?"
Layer it. The store and its reducers are pure - dedupe by id, count derivation, group collapsing, optimistic read with rollback - and deserve the deepest coverage, including a duplicate arriving from a second channel. Cross-tab sync is testable in a real browser with two pages and a fake connection, asserting leader election, failover after the leader is closed, and read propagation. Push is the hardest and needs its own harness: a Service Worker test that dispatches a synthetic push event and asserts one showNotification call with the right tag, plus a duplicate eventId producing none. Permission flows need mocking of Notification.permission, since a real prompt cannot be automated. The layering rationale is in Testing Strategy.
"What do you instrument, and why is server-side data not enough?" Because the server knows what it sent, not what arrived. Push delivery is best-effort and silent failures are common, so the client must report: notifications received per channel, duplicates suppressed, click-through rate by type, permission prompt outcomes (accepted, softly declined, denied) segmented by the context in which they were shown, subscription expiry rate, and reconnect frequency for the live channel. The permission funnel is the highest-value metric in this list because it tells you whether your prompt timing is working, and it exists nowhere except the client - the argument for client-side telemetry made in Observability.
"The notification payload contains a private message. Any concerns?" Several. Push payloads are encrypted end to end and the push service cannot read them, but the displayed notification appears on a lock screen where anyone holding the device can read it - so sensitive content should be elided ("New message from Ana") with the detail fetched only after the app is opened and authenticated. Payload size is also capped around 4KB, which pushes you toward sending an identifier and fetching the content anyway. And the Service Worker's IndexedDB dedupe store persists on the device, so it should hold ids and timestamps rather than message bodies. The reasoning about where sensitive data may and may not be stored client-side is in Security Architecture.
Tradeoffs Table
| Option | Pros | Cons | When to Use |
|---|---|---|---|
| Live connection only | Instant, no permission, full payload, cheap per event | Silent the moment the app closes | Active-session updates; always the primary channel |
| Web Push | Reaches a closed browser, OS-level presence, survives reboots | Permission is one-shot and denial is permanent; rate limits; payload cap; per-device subscriptions | Events worth interrupting an absent user for |
| Store-only, seen on next visit | Zero interruption, no permission, no delivery infrastructure | No timeliness at all | The majority of low-importance notifications |
| BroadcastChannel leader election | Widely supported, one connection per user, instant local fan-out | Election logic, failover timing, must reconcile with the server | Default for multi-tab consistency |
| SharedWorker holding the connection | No election at all, single owner by construction | Not supported everywhere, notably some mobile browsers | When you can constrain the browser matrix |
| Prompt for permission on load | One line of code, maximum reach attempted | Very high denial rate, and denial cannot be undone | Never |
| Two-step contextual prompt | Much higher acceptance, soft decline is recoverable | Needs a real UI and a judgement about when to ask | Default for any permission request |
Where This Applies
A notification system is where three foundations intersect. Push and the Service Worker lifecycle are the capability described in Offline and PWA Architecture - the reason to have a Service Worker beyond caching. The unread count is a textbook case for State Management Architecture: one piece of server-owned state, optimistically mutated, mirrored across tabs and devices, and derived rather than stored. And the permission funnel is data that exists only on the client, which is the argument for the instrumentation in Observability.
Within this track, the live channel is the transport built in Real-Time Feed, reused rather than rebuilt. The dropdown is a paginated list with the merge concerns of Infinite Scroll. Optimistic read-marking is the same rollback pattern as Shopping Cart. And the cross-tab machinery here is what a multi-panel dashboard uses to avoid one connection per widget.
Advertisement
Why do you need both a live connection and Web Push rather than just one?
They cover different application states and neither covers both. A live connection - a WebSocket or SSE stream - only exists while a page is open, so it delivers instantly and cheaply to an active user but goes silent the moment the tab closes. Web Push goes through an operating-system push service, so it reaches the user when the browser is closed entirely, but it costs a permission prompt, is subject to the OS notification centre and its rate limits, and is far too heavy for the dozen small updates an active session generates. The right architecture uses the live connection as the primary channel for anyone with the app open, push only when the user has no active session or when the event is important enough to interrupt them, and the same server-side event feeding both so that whichever arrives first, the client converges on the same state.
How do you keep the unread count consistent across several open tabs?
By treating one tab as the owner of the connection and broadcasting state to the others, rather than letting every tab maintain its own count. A BroadcastChannel gives same-origin tabs a message bus, so when one tab receives a notification or marks something read it publishes that change and every other tab applies it immediately without a network request. Two supporting pieces make it reliable. A leader election, usually via a heartbeat over the same channel, ensures only one tab holds the live connection - otherwise each tab opens its own and the server fans out N copies of every event. And the server remains the tiebreaker, so a tab that was asleep or newly opened reconciles by fetching the authoritative count rather than trusting whatever it last heard.
What is the right way to ask for notification permission?
Not on page load. A prompt with no context is denied by most users, and a denial is close to permanent - the browser will not ask again, and recovering requires the user to find the setting themselves, which almost nobody does. The pattern that works is a two-step request. First show your own in-page explanation at a moment when the value is obvious, such as immediately after the user follows a thread or completes an order, saying specifically what you will send. Only if they accept that do you call the browser API, so the real prompt appears with the user already primed to accept. If they decline your own prompt, nothing is spent and you can ask again later. Browsers increasingly punish sites that prompt without a user gesture by suppressing the prompt entirely, so the two-step flow is becoming a requirement rather than a nicety.
Why do notifications need deduplication, and where does the duplicate come from?
Because the same logical event can reach the client through more than one path. A notification may arrive over the live connection and again as a push if the server was unsure whether the session was active, or be replayed after a reconnect from a sequence buffer, or be delivered twice because a retry succeeded after its acknowledgement was lost. The fix is a server-assigned stable event id that is identical across every channel, plus a client-side set of recently seen ids checked before anything is rendered or counted. The same id is used as the tag on a displayed system notification, so the operating system replaces rather than stacks it. Without this the user sees the same message twice and the unread count drifts upward permanently, which is the kind of bug that erodes trust in the whole feature.
How do you stop a busy event source from producing dozens of separate notifications?
By grouping on the server and collapsing on the client. Server-side, a short aggregation window means twelve likes on the same post within a minute become one notification saying twelve people liked it, which is both kinder to the user and cheaper to deliver. Client-side, notifications that share a subject are rendered as one row with a count rather than twelve rows, and a displayed system notification reuses the group's tag so the OS replaces the previous one instead of stacking. There is also a rate limit worth enforcing regardless of grouping, because a runaway producer should degrade to a summary rather than flooding the notification centre - and a user who has been flooded once tends to revoke permission, which you cannot get back.