Frontend / Frontend system design / 08_notification_center.md

Design: Notification Center

Updated 6 interview angles 7 min read source
On this page15
  1. Requirements to clarify
  2. API contract
  3. Client data model
  4. Real-time channel
  5. Grouping (“Alice and 5 others”)
  6. Channels & preferences
  7. Delivery
  8. Browser Push API
  9. Read-state sync
  10. Failure modes
  11. Accessibility
  12. Telemetry
  13. Related
  14. Further reading
  15. Interview angle

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

text
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 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:

  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):

text
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:

  1. Service worker registered.
  2. User grants Notification permission.
  3. Client subscribes to push:
    ts
    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:
    js
    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:

tsx
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-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.

Further reading

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.