ESC

Type to search the knowledge base.

Design a Notification System UI

Frontend system design for notifications — inbox, toasts, push permission, realtime, badges, and preferences.

advanced4 min read
  • system-design
  • interview
  • architecture
  • notifications

Scope the problem

In scope (frontend):

  • In-app notification inbox
  • Unread badge
  • Toast/snackbar for ephemeral events
  • Preference center (channels/categories)
  • Web Push opt-in UX (not full push infra)
  • Realtime delivery of new items

Out of scope: email/SMS provider internals, APNs/FCM scaling deep dive (interfaces only).

Requirements

Type Examples
Functional list, mark read, mark all, deep link targets
Non-functional low-latency badge; don’t drop events when offline briefly
Product CTR to target; opt-out rates; notification fatigue

Surfaces

┌──────────────┐  ┌─────────────┐  ┌──────────────┐
│ Toast        │  │ Bell inbox  │  │ System push  │
│ ephemeral    │  │ persistent  │  │ OS surface   │
└──────────────┘  └─────────────┘  └──────────────┘
         overlapping but different retention

Rule of thumb:

  • Toast: immediate feedback on user action or high-urgency in-session event
  • Inbox: durable history + audit
  • Push: user away from tab; requires permission

High-level architecture

┌─────────────────────────────────────────────────────┐
│ NotificationProvider                                │
│  - unreadCount                                      │
│  - items cache (infinite query)                     │
│  - realtime subscription                            │
├──────────────┬──────────────────┬───────────────────┤
│ Bell UI      │ Toast bridge     │ Preferences page  │
└──────────────┴──────────────────┴───────────────────┘
            │
            ▼
   REST/GraphQL + WebSocket/SSE

Data model (client)

type NotificationItem = {
  id: string;
  category: "social" | "billing" | "system" | "marketing";
  title: string;
  body: string;
  createdAt: string;
  readAt: string | null;
  href: string; // deep link
  actor?: { name: string; avatarUrl: string };
};

type NotificationsPage = {
  items: NotificationItem[];
  nextCursor: string | null;
  unreadCount: number;
};

API sketch:

  • GET /notifications?cursor=
  • POST /notifications/:id/read
  • POST /notifications/read-all
  • GET/PUT /notification-preferences
  • Realtime: notification.created event

State & caching

Concern Approach
List infinite query, newest first
Unread badge server count + optimistic decrement
Mark read optimistic; reconcile on error
Realtime insert prepend if at top; else bump badge only
Dedupe by notification id
// on websocket message
queryClient.setQueryData(["notifications"], (old) => prependDedupe(old, item));
queryClient.setQueryData(["unread"], (c) => (c ?? 0) + 1);

When inbox open and item visible, auto-mark-read (debounced) is a product choice — call it out.

Realtime

  • WebSocket or SSE for badge + inbox
  • Fallback polling every N minutes when socket down
  • Resume: fetch since=lastEventId on reconnect to fill gaps

Push permission UX

  1. Never cold-prompt on first paint
  2. Contextual prime (“Get alerted when build finishes”) → then browser prompt
  3. Store permission state; guide to browser settings if blocked
  4. Service worker shows notification; click focuses client route

Preferences

type Prefs = {
  channels: { inApp: boolean; push: boolean; email: boolean };
  categories: Record<string, { inApp: boolean; push: boolean; email: boolean }>;
};

UI: matrix of category × channel. Marketing defaults off in many jurisdictions — product/legal input.

Accessibility & UX

  • Badge has accessible text (aria-label="3 unread notifications")
  • Inbox list semantics; mark-read buttons named
  • Toasts: polite vs assertive by severity (Toast system)
  • Don’t flood toasts for every inbox item — collapse (“3 new notifications”)

Performance

  • Virtualize long inbox
  • Avatars lazy
  • Socket messages coalesce in rAF if bursty
  • Code-split preferences page

Tradeoffs

  1. Single store for toast+inbox vs separate — shared event bus, different retention
  2. Auto read-on-view vs explicit — accuracy vs unread utility
  3. Push vs email for critical billing — redundancy
  4. Client filter vs server fanout of preferences

Failure modes

  • Clock skew on ordering
  • Duplicate delivery — idempotent ids
  • Permission denied — degrade gracefully
  • Deep link to deleted resource — targeted 404 state

Interview close

Separate surfaces (toast/inbox/push) → data model + optimistic read → realtime with catch-up → preference matrix → permission priming. Mention fatigue and a11y badge labeling.

Further reading