frontend / frontend system design / 08_notification_center.md

Design: Notification Center

6 min read source

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 with useInfiniteQuery if history is large).
  • unreadCount derived 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:

  1. 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.
  2. Client-side rollup. Server sends per-action notifications; client aggregates in the UI by group. Simpler server, more client logic, more memory.
  3. 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 Email 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:

  1. Service worker registered.
  2. User grants Notification permission.
  3. 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) });
  4. Server stores the subscription, sends push messages via Web Push protocol (signed with VAPID).
  5. Service worker push event 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-ID mechanism replays missed notifications + server includes current unreadCount in 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_state event to my other connected sessions.”
  • “Group by groupKey server-side to avoid bombarding the user with 50 ‘liked your post’ notifications. Hybrid is common: server emits per-action with a groupKey, 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

Further reading