Hey folks, Rahul here ๐
Every app has notifications. Most implement them badly โ a polling loop that hammers the server, an unread count that's always wrong, and grouping logic that shows "John and 47 others liked your post" as 48 separate items. Sound familiar?
Let's design a notification system that handles real-time delivery, intelligent grouping, cross-tab synchronization, and the deceptively complex read/unread state machine.
R โ Requirements
Functional Requirements
- Display in-app notification bell with unread count badge
- Notification dropdown/panel with grouped notifications
- Mark individual or all notifications as read
- Real-time delivery โ new notifications appear without refresh
- Push notifications (browser) with user opt-in
- Notification preferences (per-category enable/disable)
- Click-to-navigate: each notification links to relevant content
Non-Functional Requirements
- Real-time: <2s delivery from event to UI
- Consistency: Unread count must be accurate across tabs
- Performance: Support users with 10K+ notifications
- Battery: Mobile-friendly โ no aggressive polling
- Graceful degradation: Works without WebSocket (falls back to polling)
A โ Architecture
Delivery Strategies
| Strategy | Latency | Battery | Complexity | Use When |
|---|---|---|---|---|
| Polling | High (interval) | Poor | Low | Fallback only |
| Long Polling | Medium | OK | Medium | SSE unavailable |
| SSE (Server-Sent Events) | Low | Good | Medium | Read-only stream โ |
| WebSocket | Lowest | Good | High | Already have WS for other features |
Recommendation: SSE for notifications specifically. It's simpler than WebSocket, works over HTTP/2, auto-reconnects, and notifications are inherently server โ client (no bidirectional need).
Component Architecture
NotificationProvider (Context)
โโโ NotificationBell // Bell icon + unread badge
โโโ NotificationPanel // Dropdown/slideover
โ โโโ NotificationFilters // "All" | "Unread" | by category
โ โโโ NotificationGroup // "John and 3 others liked your post"
โ โ โโโ NotificationItem // Individual notification
โ โโโ LoadMoreTrigger // Infinite scroll
โโโ NotificationToast // Ephemeral popup for new notifications
โโโ PushPermissionBanner // "Enable push notifications?"Notification Flow
Event (comment, like, etc.)
โ
Backend creates notification row
โ
Pushes via SSE/WebSocket to connected clients
โ
NotificationProvider receives
โ
โโโ Update unread count (badge)
โโโ Show toast (if panel is closed)
โโโ Prepend to notification list (if panel is open)
โโโ Send browser push (if tab is background + user opted in)D โ Data Model
Notification Types and Grouping
interface Notification {
id: string;
type: NotificationType;
recipient_id: string;
actor_id: string; // Who triggered this
actor: { name: string; avatar: string };
target_type: 'post' | 'comment' | 'article';
target_id: string;
target_preview: string; // "Your post about React hooks..."
group_key: string; // For grouping: "like:post:123"
is_read: boolean;
created_at: string;
action_url: string; // Where to navigate on click
}
type NotificationType =
| 'like'
| 'comment'
| 'reply'
| 'mention'
| 'follow'
| 'system';
// Grouped notification for display
interface NotificationGroup {
group_key: string;
type: NotificationType;
actors: { id: string; name: string; avatar: string }[];
latest_at: string;
count: number; // Total in group
is_read: boolean; // All read?
target_preview: string;
action_url: string;
// Display: "Alice, Bob, and 3 others liked your post"
}Client State
interface NotificationState {
groups: NotificationGroup[]; // Grouped for display
unreadCount: number; // Badge number
isOpen: boolean; // Panel visibility
filter: 'all' | 'unread';
hasMore: boolean; // Pagination
cursor: string | null;
isConnected: boolean; // SSE status
// Optimistic state
pendingReadIds: Set<string>; // Marked read locally, awaiting server
}Grouping Logic
function groupNotifications(notifications: Notification[]): NotificationGroup[] {
const groups = new Map<string, Notification[]>();
for (const notif of notifications) {
const existing = groups.get(notif.group_key) || [];
existing.push(notif);
groups.set(notif.group_key, existing);
}
return Array.from(groups.entries()).map(([key, notifs]) => {
// Dedupe actors
const actorMap = new Map<string, Notification['actor']>();
notifs.forEach(n => actorMap.set(n.actor_id, n.actor));
const actors = Array.from(actorMap.values());
return {
group_key: key,
type: notifs[0].type,
actors: actors.slice(0, 3), // Show max 3 avatars
count: actors.length,
latest_at: notifs[0].created_at,
is_read: notifs.every(n => n.is_read),
target_preview: notifs[0].target_preview,
action_url: notifs[0].action_url,
};
}).sort((a, b) =>
new Date(b.latest_at).getTime() - new Date(a.latest_at).getTime()
);
}
// Display text generator
function getGroupText(group: NotificationGroup): string {
const { actors, count, type } = group;
const verb = { like: 'liked', comment: 'commented on', reply: 'replied to', mention: 'mentioned you in', follow: 'followed you' }[type];
if (count === 1) return `${actors[0].name} ${verb} ${group.target_preview}`;
if (count === 2) return `${actors[0].name} and ${actors[1].name} ${verb} ${group.target_preview}`;
return `${actors[0].name}, ${actors[1].name}, and ${count - 2} others ${verb} ${group.target_preview}`;
}I โ Interface Definition
SSE Connection
function useNotificationStream(userId: string) {
const [state, dispatch] = useReducer(notificationReducer, initialState);
useEffect(() => {
const eventSource = new EventSource(
`/api/notifications/stream?userId=${userId}`,
{ withCredentials: true }
);
eventSource.addEventListener('notification', (e) => {
const notification: Notification = JSON.parse(e.data);
dispatch({ type: 'NEW_NOTIFICATION', notification });
// Show toast if panel is closed
if (!state.isOpen) {
showNotificationToast(notification);
}
});
eventSource.addEventListener('count_sync', (e) => {
// Periodic sync to correct any drift
const { unreadCount } = JSON.parse(e.data);
dispatch({ type: 'SYNC_COUNT', unreadCount });
});
eventSource.onerror = () => {
dispatch({ type: 'SET_CONNECTED', connected: false });
// EventSource auto-reconnects โ no manual retry needed!
};
eventSource.onopen = () => {
dispatch({ type: 'SET_CONNECTED', connected: true });
};
return () => eventSource.close();
}, [userId]);
return state;
}Mark as Read โ Optimistic with Batching
// Batch mark-as-read calls to avoid N API calls
class ReadBatcher {
private queue: string[] = [];
private timer: ReturnType<typeof setTimeout> | null = null;
markRead(notificationId: string) {
this.queue.push(notificationId);
if (!this.timer) {
this.timer = setTimeout(() => {
this.flush();
}, 500); // Batch within 500ms window
}
}
markAllRead() {
this.queue = [];
if (this.timer) clearTimeout(this.timer);
this.timer = null;
return api.post('/notifications/mark-all-read');
}
private async flush() {
const ids = [...this.queue];
this.queue = [];
this.timer = null;
await api.post('/notifications/mark-read', { ids });
}
}
// Auto-mark as read when notification is visible for 1.5s
function useAutoMarkRead(notificationId: string, isVisible: boolean) {
useEffect(() => {
if (!isVisible) return;
const timer = setTimeout(() => {
readBatcher.markRead(notificationId);
}, 1500);
return () => clearTimeout(timer);
}, [notificationId, isVisible]);
}O โ Optimizations
1. Cross-Tab Synchronization
// Use BroadcastChannel to sync state across tabs
const channel = new BroadcastChannel('notifications');
// When one tab marks notifications as read:
function markAsRead(ids: string[]) {
dispatch({ type: 'MARK_READ', ids });
channel.postMessage({ type: 'MARK_READ', ids });
api.post('/notifications/mark-read', { ids });
}
// Other tabs receive:
channel.onmessage = (event) => {
dispatch(event.data);
};
// Only one tab should maintain the SSE connection
// Use a "leader election" pattern
const leader = await navigator.locks.request(
'notification-stream',
{ ifAvailable: true },
(lock) => {
if (lock) {
// This tab is the leader โ connect SSE
connectSSE();
return new Promise(() => {}); // Hold the lock
}
return false; // Another tab is leader
}
);
if (!leader) {
// This tab receives updates via BroadcastChannel from the leader tab
}2. Unread Count Badge Animation
function NotificationBadge({ count }: { count: number }) {
const prevCount = useRef(count);
const [animate, setAnimate] = useState(false);
useEffect(() => {
if (count > prevCount.current) {
setAnimate(true);
const timer = setTimeout(() => setAnimate(false), 300);
prevCount.current = count;
return () => clearTimeout(timer);
}
prevCount.current = count;
}, [count]);
if (count === 0) return null;
return (
<span className={cn(
"absolute -top-1 -right-1 flex items-center justify-center",
"min-w-[18px] h-[18px] rounded-full bg-destructive text-destructive-foreground",
"text-[10px] font-bold px-1",
animate && "animate-bounce"
)}>
{count > 99 ? '99+' : count}
</span>
);
}3. Browser Push Notifications
async function requestPushPermission(): Promise<boolean> {
if (!("Notification" in window)) return false;
const permission = await Notification.requestPermission();
if (permission !== 'granted') return false;
// Register service worker for background push
const registration = await navigator.serviceWorker.register('/sw.js');
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: VAPID_PUBLIC_KEY,
});
// Send subscription to server
await api.post('/push/subscribe', subscription.toJSON());
return true;
}
// Service worker handler (sw.js)
self.addEventListener('push', (event) => {
const data = event.data?.json();
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: data.icon,
badge: '/notification-badge.png',
data: { url: data.action_url },
tag: data.group_key, // Replaces previous notification with same tag
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(clients.openWindow(event.notification.data.url));
});4. Notification Preferences
interface NotificationPreferences {
channels: {
in_app: boolean; // Always true
push: boolean;
email: boolean;
};
categories: {
likes: { in_app: boolean; push: boolean; email: boolean };
comments: { in_app: boolean; push: boolean; email: boolean };
mentions: { in_app: boolean; push: boolean; email: boolean };
follows: { in_app: boolean; push: boolean; email: boolean };
system: { in_app: boolean; push: boolean; email: boolean }; // Can't disable
};
quiet_hours: {
enabled: boolean;
start: string; // "22:00"
end: string; // "07:00"
timezone: string;
};
}Production Gotchas Rahul Has Debugged ๐ฅ
- Unread Count Drift: If a notification is created between the initial fetch and SSE connection, you miss it. Solution: SSE connection sends a
count_syncevent every 60s to reconcile. - Self-Notifications: Don't notify users about their own actions. The backend must filter
actor_id !== recipient_idโ never rely on the frontend for this. - Notification Storms: A viral post generates thousands of likes. Without grouping, the user gets 1,000 notifications. Backend should coalesce within a time window (e.g., group all likes within 5 minutes into one notification).
- SSE Memory Leak:
EventSourceaccumulates all messages in memory if you don't handle them. Always consume events promptly. Some browsers also leak if you create/destroy EventSource instances rapidly. - Mobile Tab Backgrounding: iOS Safari kills SSE connections when the tab is backgrounded. On
visibilitychangeโ visible, reconnect and fetch missed notifications sincelastEventId.
Next up: #11: Design a Drag-and-Drop Kanban Board โ reorder algorithms, optimistic multi-list moves, and the fractional indexing trick. ๐