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
StrategyLatencyBatteryComplexityUse WhenPollingHigh (interval)PoorLowFallback onlyLong PollingMediumOKMediumSSE unavailableSSE (Server-Sent Events)LowGoodMediumRead-only stream β WebSocketLowestGoodHighAlready 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. π