Design: Notification Center
TL;DR
A bell icon with a badge showing unread count, a panel listing recent notifications, real-time push of new ones, and the cross-device read-state sync that makes “I read it on my phone” reflect immediately on the desktop. The senior topics: delivery channels (in-app, browser push, email, mobile push), read-state synchronization, deduplication / grouping (“Alice and 5 others liked your post”), muting and preferences, delivery guarantees, and scale (millions of notifications per day, fan-out, retention).
Requirements to clarify
- Notification types. Mentions, system messages, marketing? Each may have different delivery channels.
- Channels. In-app only, or also email/push/SMS? Per-type opt-out?
- Read state. Per-device or per-user? (Almost always per-user.)
- Grouping. “5 likes on your post” rolls up vs flat list?
- History retention. Keep forever or 90 days?
- Cross-device sync. Mark read on phone → desktop updates within seconds?
- Quiet hours / do-not-disturb.
- Localization, timezones.
API contract
GET /api/notifications?after=<cursor>&limit=50
→ {
"items": [
{
"id": "n_abc",
"type": "mention",
"subject": { "userId": "u_bob", "name": "Bob" },
"verb": "mentioned you in",
"object": { "kind": "comment", "id": "c_xyz", "href": "/doc/123#c_xyz" },
"createdAt": "...",
"readAt": null,
"groupKey": "comment:c_xyz"
}
],
"nextCursor": "...",
"unreadCount": 12
}
POST /api/notifications/:id/read → mark one read
POST /api/notifications/mark-all-read → bulk
GET /api/notifications/preferences → per-type channel preferences
PUT /api/notifications/preferences → update
WS / SSE:
→ { "type": "notification", "item": {...}, "unreadCount": 13 }
→ { "type": "read_state", "ids": ["n_abc"], "unreadCount": 12 } // from another device
unreadCount ships with every relevant event so the badge stays in sync without a separate fetch.
Client data model
- List in TanStack Query keyed by
["notifications"](paginated withuseInfiniteQueryif history is large). unreadCountderived from the server’s push or polled separately to keep the badge fresh when the panel isn’t open.- Read-state changes: optimistic in the cache, server reconciles.
Real-time channel
- SSE is usually right (see ../11_apis_data_fetching/08_server_sent_events.md) — server-to-client only, auto-reconnect, plain HTTP.
- WebSocket if you also need bidirectional (e.g., the user “marks as read” is a write — but you can use HTTP POST for that, then SSE for the broadcast).
- Long polling if you have an environment that blocks the others (rare).
The bell badge updates without the panel being open — the SSE/WS subscription is alive on the layout/root.
Grouping (“Alice and 5 others”)
Three approaches:
- Server-side grouping by
groupKey. The server keeps a notification grouped while it’s recent; new actions on the same key update the existing record ({ count: 6, actors: [...] }) rather than creating new ones. Reads remain individual or roll-up. - Client-side rollup. Server sends per-action notifications; client aggregates in the UI by group. Simpler server, more client logic, more memory.
- Hybrid. Server emits per-action notifications, but tags them with
groupKey; the client rolls them up for display, marks all in a group read on click.
Server-side grouping is cleaner and saves bandwidth at scale (one update vs 50 individual notifications). Most apps do hybrid.
Channels & preferences
Per type × per channel matrix:
| In-app | Browser push | Mobile push | ||
|---|---|---|---|---|
| Mention | on | digest | on | on |
| Reply | on | off | on | on |
| Marketing | off | weekly | off | off |
| System | on | on | on | on |
UI is a table with toggles. Preferences live server-side, fetched on settings page.
Digest = batched into a single email at fixed intervals (e.g., daily). Reduces email fatigue.
Delivery
A backend pipeline (out of scope for frontend, but a senior knows the shape):
event → notification service → for each subscriber:
→ write in-app notification (DB)
→ push to in-app channel (SSE/WS)
→ enqueue email if subscribed (queue, with deduping)
→ enqueue web push (Web Push protocol, VAPID)
→ enqueue mobile push (FCM, APNS)
Each channel has its own retry / failure handling. In-app is the most reliable; email/push are best-effort.
See ../../system_design/07_worked_designs/03_notification_fanout.md for the backend fanout pattern.
Browser Push API
Required for desktop push to a closed tab. Setup:
- Service worker registered.
- User grants
Notificationpermission. - Client subscribes to push:
const reg = await navigator.serviceWorker.ready; const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: VAPID_PUBLIC_KEY, }); await fetch("/api/push/subscribe", { method: "POST", body: JSON.stringify(sub) }); - Server stores the subscription, sends push messages via Web Push protocol (signed with VAPID).
- Service worker
pushevent handler shows the notification:self.addEventListener("push", (e) => { const data = e.data?.json() ?? {}; e.waitUntil(self.registration.showNotification(data.title, { body: data.body, data })); }); self.addEventListener("notificationclick", (e) => { e.notification.close(); e.waitUntil(clients.openWindow(e.notification.data.href)); });
userVisibleOnly: true is required by Chrome — no silent push.
Read-state sync
When the user opens the panel:
function NotificationPanel() {
const { data } = useQuery({ queryKey: ["notifications"], queryFn: load });
const markAllRead = useMutation({
mutationFn: () => fetch("/api/notifications/mark-all-read", { method: "POST" }),
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: ["notifications"] });
queryClient.setQueryData<NotifList>(["notifications"], (old) =>
old ? { ...old, items: old.items.map(i => ({ ...i, readAt: now })), unreadCount: 0 } : old
);
},
onError: revertOptimistic,
onSettled: () => queryClient.invalidateQueries({ queryKey: ["notifications"] }),
});
useEffect(() => { markAllRead.mutate(); }, []); // mark all read on open
return <List items={data?.items} />;
}
The SSE channel pushes { type: "read_state", ids, unreadCount } so other devices update without their own action.
Failure modes
- SSE disconnect → badge stops updating; on reconnect, the
Last-Event-IDmechanism replays missed notifications + server includes currentunreadCountin the resume. - Push notification permission denied → don’t repeatedly prompt; show a banner explaining what they’re missing, with re-enable instructions.
- Email bounces → backend pipeline handles via webhook from email provider; UI shows a banner if delivery is suspended for the user.
- Optimistic mark-read fails → revert; show error.
- Race: notification arrives while the panel is open → live-prepend to the list; don’t show the toast (panel is open).
- Massive backlog (user away for 2 weeks, 10K notifications) → server caps SSE replay (e.g., last 100 unread + count of older); panel shows “and 9,847 more” with pagination.
Accessibility
- Bell + badge has
aria-label="3 unread notifications". - Panel is a popover/dialog with focus management (open → focus first item, escape → close, focus back to bell).
- Live announcements: use an
aria-live="polite"region for newly arrived notifications so screen reader users hear them. - Keyboard nav: Tab through items, Enter activates, mark-read affordance on each.
Telemetry
- Notification delivery latency (event → user-visible).
- Click-through rate per type (signals notification quality).
- Opt-out rate per type.
- Unread-count badge accuracy (catches read-state sync bugs).
- Push subscription success rate.
What a senior is expected to say
- “SSE for the badge/panel push — server-to-client only, auto-reconnect, simpler than WebSocket. The unread count ships with every event so the badge stays in sync without a separate fetch.”
- “Read state is per-user, not per-device — when I mark read on my phone, the server pushes a
read_stateevent to my other connected sessions.” - “Group by
groupKeyserver-side to avoid bombarding the user with 50 ‘liked your post’ notifications. Hybrid is common: server emits per-action with agroupKey, client rolls up.” - “Channels and preferences are a type × channel matrix. Email digesting reduces fatigue; the user can opt out per type per channel.”
- “Browser push needs a service worker + VAPID +
userVisibleOnly: true. Don’t re-prompt on denial.” - “Backend fanout is its own system — write in-app + enqueue email + enqueue web/mobile push. In-app is reliable; the others are best-effort with their own retry pipeline.”
Cross-references
- SSE transport: ../11_apis_data_fetching/08_server_sent_events.md
- Optimistic updates (mark-read): ../11_apis_data_fetching/03_optimistic_updates.md
- Backend notification fan-out: ../../system_design/07_worked_designs/03_notification_fanout.md
Further reading
- W3C Push API: https://www.w3.org/TR/push-api/
- RFC 8030 — Web Push protocol: https://datatracker.ietf.org/doc/html/rfc8030
- VAPID (RFC 8292): https://datatracker.ietf.org/doc/html/rfc8292
- web.dev — Push notifications overview: https://web.dev/articles/push-notifications-overview