Design: Notification Center
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 deviceunreadCount 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 Streaming an LLM answer into a React UI) — 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 Worked Design — Notification / Fan-out Service 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"] }),
});
// mark all read on open
useEffect(() => { markAllRead.mutate(); }, []);
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.
Related
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
Interview angle 6
- “What transport for the badge and panel?” - SSE. It is server-to-client only, which is exactly the shape here, and it gives automatic reconnection and event resume over plain HTTP. Ship the unread count with every event so the badge stays correct without a separate poll.
- “A user marks something read on their phone. What happens on their laptop?” - read state is per user, not per device, so the server pushes a read-state event to every other connected session. Without that, the badge disagrees across devices and users report it as a bug.
- “Fifty people like a post. What does the user see?” - one grouped entry. The server tags each notification with a group key and the client rolls them up, so the panel says “50 people liked your post” rather than fifty rows. Not grouping is how a notification centre becomes something people mute.
- “How do preferences work?” - as a matrix of notification type against channel, so a user can take mentions by email and likes only in-app. Digesting email is the other lever: batching the low-value types into a daily summary cuts fatigue without removing the information.
- “What does browser push require?” - a service worker, VAPID keys, and
userVisibleOnly: true, which is enforced - you cannot use push silently. And do not re-prompt after a denial: the browser increasingly blocks it permanently, so a badly-timed first prompt costs you the channel for good. - “What does the backend look like?” - a fan-out per event: write the in-app record, enqueue email, enqueue push. In-app is the reliable channel because you own the store; email and push are best-effort with their own retry pipelines, and treating them as guaranteed is a mistake.