Designing a Scalable Notification System From Scratch
Designing a Scalable Notification System From Scratch
Notifications look simple. A badge with a number. A dropdown with messages. How hard can it be?
Then you build one and discover: real-time delivery across devices, read state that syncs everywhere, history that doesn't explode your database, and a React UI that doesn't re-render the entire app every time a notification arrives. Suddenly you're deep in distributed systems territory.
This is the end-to-end architecture for notifications that actually scale.
The Deceptive Simplicity
Here's what a notification system actually needs to handle:
┌─────────────────────────────────────────────────────────────────────┐
│ NOTIFICATION SYSTEM SCOPE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ INGESTION PROCESSING DELIVERY DISPLAY │
│ ───────── ────────── ──────── ─────── │
│ • Events from • Deduplication • Real-time • Badge │
│ multiple • Aggregation (WebSocket) count │
│ services • User prefs • Push (mobile) • Dropdown │
│ • Webhooks • Rate limiting • Email • History │
│ • Scheduled • Templating • SMS page │
│ • User actions • Routing • In-app • Actions │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Event │ ───▶ │ Queue │ ───▶ │ Delivery │───▶│ Client │ │
│ │ Source │ │ + Worker │ │ Layer │ │ Apps │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ PERSISTENCE STATE SYNC PREFERENCES │
│ ─────────── ────────── ─────────── │
│ • History • Read/unread • Channel opts │
│ • Archival across devices • Frequency │
│ • Search • Badge counts • Do not disturb │
│ │
└─────────────────────────────────────────────────────────────────────┘
Push vs Pull: The Fundamental Choice
Pull Architecture
Client periodically asks: "Any new notifications?"
┌────────────────────────────────────────────────────────────────┐
│ PULL ARCHITECTURE │
├────────────────────────────────────────────────────────────────┤
│ │
│ Client Server │
│ ────── ────── │
│ │
│ ┌─────────┐ GET /notifications ┌─────────┐ │
│ │ │ ─────────────────────▶ │ │ │
│ │ React │ (every 30 seconds) │ API │ │
│ │ App │ ◀───────────────────── │ │ │
│ │ │ [notifications] │ │ │
│ └─────────┘ └─────────┘ │
│ │ │ │
│ │ │ │
│ ┌─────────┐ ┌─────────┐ │
│ │ Local │ │ DB │ │
│ │ State │ │ │ │
│ └─────────┘ └─────────┘ │
│ │
│ Pros: Cons: │
│ • Simple to implement • Latency (up to interval) │
│ • Works everywhere • Wasted requests │
│ • Easy to debug • Doesn't scale well │
│ • No connection state • Battery drain (mobile) │
│ │
└────────────────────────────────────────────────────────────────┘
// Pull-based implementation
function useNotifications() {
const [notifications, setNotifications] = useState<Notification[]>([]);
useEffect(() => {
const fetchNotifications = async () => {
const response = await fetch('/api/notifications?unread=true');
const data = await response.json();
setNotifications(data.notifications);
};
// Initial fetch
fetchNotifications();
// Poll every 30 seconds
const interval = setInterval(fetchNotifications, 30000);
return () => clearInterval(interval);
}, []);
return notifications;
}
Push Architecture
Server tells client: "Here's a new notification."
┌────────────────────────────────────────────────────────────────┐
│ PUSH ARCHITECTURE │
├────────────────────────────────────────────────────────────────┤
│ │
│ Client Server │
│ ────── ────── │
│ │
│ ┌─────────┐ WebSocket Connect ┌─────────┐ │
│ │ │ ════════════════════════│ │ │
│ │ React │ │ WS │ │
│ │ App │ ◀─── notification ──────│ Server │ │
│ │ │ ◀─── notification ──────│ │ │
│ └─────────┘ ◀─── notification ──────└─────────┘ │
│ │ │ │ │
│ │ Persistent ┌─────────┐ │
│ ┌─────────┐ Connection │ Pub/ │ │
│ │ Local │ │ Sub │ │
│ │ State │ └─────────┘ │
│ └─────────┘ │ │
│ ┌─────────┐ │
│ Pros: │ Workers │ │
│ • Instant delivery └─────────┘ │
│ • No wasted requests │
│ • Scales better Cons: │
│ • Better UX • Connection management │
│ • Reconnection logic │
│ • Horizontal scaling hard │
│ │
└────────────────────────────────────────────────────────────────┘
The Hybrid Approach (What You Actually Want)
┌────────────────────────────────────────────────────────────────────┐
│ HYBRID ARCHITECTURE │
├────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ │
│ │ Client │ │
│ └──────┬──────┘ │
│ │ │
│ ┌─────────────────┼─────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ Initial │ │ WebSocket │ │ Sync on │ │
│ │ REST │ │ Push │ │ Focus │ │
│ │ Fetch │ │ │ │ │ │
│ └────────────┘ └────────────┘ └────────────┘ │
│ │ │ │ │
│ │ │ │ │
│ On page load Real-time When tab becomes │
│ get last N updates visible, sync │
│ notifications arrive missed notifications │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ const { notifications, unreadCount } = useNotifications(); │ │
│ │ │ │
│ │ // Internally: │ │
│ │ // 1. REST fetch on mount │ │
│ │ // 2. WebSocket subscription for new │ │
│ │ // 3. Visibility API sync when tab refocused │ │
│ │ // 4. Fallback to polling if WS unavailable │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────────┘
The Data Model
Core Notification Schema
// Base notification structure
interface Notification {
id: string;
userId: string;
// What happened
type: NotificationType;
title: string;
body: string;
// Context
actorId?: string; // Who triggered this
targetType?: string; // 'post' | 'comment' | 'order'
targetId?: string; // ID of the related entity
// State
read: boolean;
seen: boolean; // Seen in dropdown vs actually read
archived: boolean;
// Timestamps
createdAt: Date;
readAt?: Date;
expiresAt?: Date;
// Metadata
priority: 'low' | 'normal' | 'high' | 'urgent';
category: string; // For filtering
actions?: NotificationAction[];
data?: Record<string, any>; // Flexible payload
}
interface NotificationAction {
id: string;
label: string;
action: string; // 'navigate' | 'api_call' | 'dismiss'
url?: string;
method?: string;
payload?: Record<string, any>;
}
type NotificationType =
| 'mention'
| 'comment'
| 'like'
| 'follow'
| 'order_update'
| 'system'
| 'reminder';
Database Schema (PostgreSQL)
-- Main notifications table
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL,
title VARCHAR(255) NOT NULL,
body TEXT,
actor_id UUID REFERENCES users(id),
target_type VARCHAR(50),
target_id UUID,
read BOOLEAN DEFAULT FALSE,
seen BOOLEAN DEFAULT FALSE,
archived BOOLEAN DEFAULT FALSE,
priority VARCHAR(20) DEFAULT 'normal',
category VARCHAR(50),
actions JSONB DEFAULT '[]',
data JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
read_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
-- Composite index for common queries
INDEX idx_notifications_user_unread (user_id, read, created_at DESC)
WHERE archived = FALSE,
INDEX idx_notifications_user_created (user_id, created_at DESC),
INDEX idx_notifications_expires (expires_at) WHERE expires_at IS NOT NULL
);
-- Aggregation tracking (for "John and 5 others liked your post")
CREATE TABLE notification_aggregations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
aggregation_key VARCHAR(255) NOT NULL, -- e.g., 'like:post:123'
notification_id UUID REFERENCES notifications(id),
actor_ids UUID[] DEFAULT '{}',
count INTEGER DEFAULT 1,
last_actor_id UUID,
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, aggregation_key)
);
-- User notification preferences
CREATE TABLE notification_preferences (
user_id UUID PRIMARY KEY REFERENCES users(id),
-- Channel preferences per type
preferences JSONB DEFAULT '{
"mention": {"inApp": true, "email": true, "push": true},
"comment": {"inApp": true, "email": true, "push": false},
"like": {"inApp": true, "email": false, "push": false},
"follow": {"inApp": true, "email": true, "push": true},
"system": {"inApp": true, "email": true, "push": true}
}',
-- Global settings
email_frequency VARCHAR(20) DEFAULT 'instant', -- instant | daily | weekly
quiet_hours_start TIME,
quiet_hours_end TIME,
timezone VARCHAR(50) DEFAULT 'UTC',
updated_at TIMESTAMPTZ DEFAULT NOW()
);
Aggregation Logic
// Notification aggregation service
class NotificationAggregator {
private readonly AGGREGATION_WINDOW = 60 * 60 * 1000; // 1 hour
async createOrAggregate(
notification: CreateNotificationInput
): Promise<Notification> {
const aggregationKey = this.getAggregationKey(notification);
if (!aggregationKey) {
// Not aggregatable, create directly
return this.createNotification(notification);
}
const existing = await this.findRecentAggregation(
notification.userId,
aggregationKey
);
if (existing && !existing.notification.read) {
// Aggregate into existing
return this.aggregateInto(existing, notification);
}
// Create new notification with aggregation tracking
return this.createWithAggregation(notification, aggregationKey);
}
private getAggregationKey(notification: CreateNotificationInput): string | null {
// Only aggregate certain types
const aggregatableTypes = ['like', 'follow', 'comment'];
if (!aggregatableTypes.includes(notification.type)) {
return null;
}
// Key format: type:targetType:targetId
if (notification.targetType && notification.targetId) {
return `${notification.type}:${notification.targetType}:${notification.targetId}`;
}
return null;
}
private async aggregateInto(
existing: AggregationRecord,
newNotification: CreateNotificationInput
): Promise<Notification> {
const updatedActors = [
...new Set([...existing.actorIds, newNotification.actorId])
].slice(0, 10); // Keep max 10 actors
const count = existing.count + 1;
// Update aggregation record
await db.notificationAggregations.update({
where: { id: existing.id },
data: {
actorIds: updatedActors,
count,
lastActorId: newNotification.actorId,
updatedAt: new Date()
}
});
// Update notification text
const notification = await db.notifications.update({
where: { id: existing.notificationId },
data: {
title: this.getAggregatedTitle(newNotification.type, updatedActors, count),
createdAt: new Date() // Bump to top
}
});
return notification;
}
private getAggregatedTitle(
type: string,
actorIds: string[],
count: number
): string {
const actors = await this.getActorNames(actorIds.slice(0, 2));
const othersCount = count - actors.length;
const actorText = othersCount > 0
? `${actors.join(', ')} and ${othersCount} others`
: actors.join(' and ');
switch (type) {
case 'like':
return `${actorText} liked your post`;
case 'follow':
return `${actorText} followed you`;
case 'comment':
return `${actorText} commented on your post`;
default:
return `${actorText} interacted with your content`;
}
}
}
Read/Unread State Management
The Complexity
┌─────────────────────────────────────────────────────────────────────┐
│ READ STATE CHALLENGES │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ User has multiple devices/tabs: │
│ │
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ Phone │ │ Tablet │ │ Laptop │ │Desktop │ │
│ │ App │ │ Web │ │ Web │ │ Web │ │
│ └───┬────┘ └───┬────┘ └───┬────┘ └───┬────┘ │
│ │ │ │ │ │
│ └───────────┴─────┬─────┴───────────┘ │
│ │ │
│ ▼ │
│ User reads notification on phone │
│ │ │
│ ▼ │
│ All other devices should update: │
│ • Badge count: 5 → 4 │
│ • Notification list: mark as read │
│ • No user action required │
│ │
│ ─────────────────────────────────────────────────────────────── │
│ │
│ Additional states to track: │
│ │
│ • "Seen" - User opened dropdown (counts as seen, not read) │
│ • "Read" - User clicked or explicitly marked as read │
│ • "Clicked" - User clicked the notification action │
│ • "Archived" - User dismissed/archived │
│ │
└─────────────────────────────────────────────────────────────────────┘
Server-Side Implementation
// Notification state service
class NotificationStateService {
constructor(
private db: Database,
private pubsub: PubSubService,
private cache: CacheService
) {}
async markAsRead(
userId: string,
notificationIds: string[],
source: 'click' | 'mark_read' | 'mark_all_read'
): Promise<void> {
const now = new Date();
// Update database
await this.db.notifications.updateMany({
where: {
id: { in: notificationIds },
userId,
read: false
},
data: {
read: true,
seen: true,
readAt: now
}
});
// Invalidate cache
await this.cache.del(`unread_count:${userId}`);
// Broadcast to all user's connections
await this.pubsub.publish(`user:${userId}:notifications`, {
type: 'NOTIFICATIONS_READ',
notificationIds,
readAt: now.toISOString()
});
// Analytics
await this.trackReadEvent(userId, notificationIds, source);
}
async markAsSeen(userId: string, notificationIds: string[]): Promise<void> {
// "Seen" means the dropdown was opened, not that they clicked
await this.db.notifications.updateMany({
where: {
id: { in: notificationIds },
userId,
seen: false
},
data: {
seen: true
}
});
}
async markAllAsRead(userId: string): Promise<{ count: number }> {
const result = await this.db.notifications.updateMany({
where: {
userId,
read: false,
archived: false
},
data: {
read: true,
seen: true,
readAt: new Date()
}
});
await this.cache.del(`unread_count:${userId}`);
await this.pubsub.publish(`user:${userId}:notifications`, {
type: 'ALL_NOTIFICATIONS_READ'
});
return { count: result.count };
}
async getUnreadCount(userId: string): Promise<number> {
const cacheKey = `unread_count:${userId}`;
// Check cache first
const cached = await this.cache.get(cacheKey);
if (cached !== null) {
return parseInt(cached, 10);
}
// Query database
const count = await this.db.notifications.count({
where: {
userId,
read: false,
archived: false
}
});
// Cache for 5 minutes
await this.cache.set(cacheKey, count.toString(), 300);
return count;
}
}
Client-Side State Sync
// React context for notification state
interface NotificationState {
notifications: Notification[];
unreadCount: number;
isLoading: boolean;
error: Error | null;
}
type NotificationAction =
| { type: 'SET_NOTIFICATIONS'; payload: Notification[] }
| { type: 'ADD_NOTIFICATION'; payload: Notification }
| { type: 'MARK_READ'; payload: { ids: string[]; readAt: string } }
| { type: 'MARK_ALL_READ' }
| { type: 'REMOVE_NOTIFICATION'; payload: string }
| { type: 'SET_UNREAD_COUNT'; payload: number };
function notificationReducer(
state: NotificationState,
action: NotificationAction
): NotificationState {
switch (action.type) {
case 'SET_NOTIFICATIONS':
return {
...state,
notifications: action.payload,
unreadCount: action.payload.filter(n => !n.read).length
};
case 'ADD_NOTIFICATION':
// Check for aggregation - replace if same aggregation key
const existingIndex = state.notifications.findIndex(
n => n.aggregationKey === action.payload.aggregationKey &&
action.payload.aggregationKey !== undefined
);
if (existingIndex >= 0) {
const updated = [...state.notifications];
updated[existingIndex] = action.payload;
return {
...state,
notifications: updated,
unreadCount: updated.filter(n => !n.read).length
};
}
return {
...state,
notifications: [action.payload, ...state.notifications],
unreadCount: state.unreadCount + 1
};
case 'MARK_READ':
return {
...state,
notifications: state.notifications.map(n =>
action.payload.ids.includes(n.id)
? { ...n, read: true, readAt: action.payload.readAt }
: n
),
unreadCount: Math.max(0, state.unreadCount - action.payload.ids.length)
};
case 'MARK_ALL_READ':
return {
...state,
notifications: state.notifications.map(n => ({
...n,
read: true,
readAt: new Date().toISOString()
})),
unreadCount: 0
};
default:
return state;
}
}
Real-Time Delivery with WebSockets
Server Architecture
┌─────────────────────────────────────────────────────────────────────┐
│ WEBSOCKET INFRASTRUCTURE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Load Balancer │ │
│ │ (sticky sessions OR) │ │
│ │ (connection-aware routing) │ │
│ └─────────────────────────┬───────────────────────────────────┘ │
│ │ │
│ ┌──────────────────┼──────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ WS Node │ │ WS Node │ │ WS Node │ │
│ │ 1 │ │ 2 │ │ 3 │ │
│ │ │ │ │ │ │ │
│ │ Users: │ │ Users: │ │ Users: │ │
│ │ A, D, G │ │ B, E, H │ │ C, F, I │ │
│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │
│ │ │ │ │
│ └─────────────────┼─────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Redis Pub/Sub │ │
│ │ │ │
│ │ Channel: user:{userId}:notifications │ │
│ │ │ │
│ │ Any node can publish, all nodes with that user │ │
│ │ subscribed will receive and forward to client │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Background Workers │ │
│ │ │ │
│ │ • Process notification queue │ │
│ │ • Publish to Redis │ │
│ │ • Handle delivery confirmations │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
WebSocket Server Implementation
// WebSocket server with Redis pub/sub
import { WebSocketServer, WebSocket } from 'ws';
import Redis from 'ioredis';
interface Connection {
ws: WebSocket;
userId: string;
deviceId: string;
subscribedChannels: Set<string>;
}
class NotificationWebSocketServer {
private wss: WebSocketServer;
private connections: Map<string, Set<Connection>> = new Map();
private redisSub: Redis;
private redisPub: Redis;
constructor(server: Server) {
this.wss = new WebSocketServer({ server, path: '/ws/notifications' });
this.redisSub = new Redis(process.env.REDIS_URL);
this.redisPub = new Redis(process.env.REDIS_URL);
this.setupWebSocketHandlers();
this.setupRedisSubscriptions();
}
private setupWebSocketHandlers() {
this.wss.on('connection', async (ws, req) => {
try {
// Authenticate connection
const token = this.extractToken(req);
const user = await this.authenticateToken(token);
if (!user) {
ws.close(4001, 'Unauthorized');
return;
}
const connection: Connection = {
ws,
userId: user.id,
deviceId: this.extractDeviceId(req),
subscribedChannels: new Set()
};
// Track connection
this.addConnection(connection);
// Subscribe to user's notification channel
await this.subscribeToUserChannel(connection);
// Send initial state
this.sendInitialState(connection);
// Handle messages
ws.on('message', (data) => this.handleMessage(connection, data));
// Handle disconnect
ws.on('close', () => this.handleDisconnect(connection));
// Heartbeat
this.setupHeartbeat(connection);
} catch (error) {
console.error('WebSocket connection error:', error);
ws.close(4000, 'Connection error');
}
});
}
private addConnection(connection: Connection) {
const userConnections = this.connections.get(connection.userId) || new Set();
userConnections.add(connection);
this.connections.set(connection.userId, userConnections);
}
private async subscribeToUserChannel(connection: Connection) {
const channel = `user:${connection.userId}:notifications`;
if (!connection.subscribedChannels.has(channel)) {
await this.redisSub.subscribe(channel);
connection.subscribedChannels.add(channel);
}
}
private setupRedisSubscriptions() {
this.redisSub.on('message', (channel, message) => {
const match = channel.match(/^user:(.+):notifications$/);
if (!match) return;
const userId = match[1];
const userConnections = this.connections.get(userId);
if (!userConnections) return;
const payload = JSON.parse(message);
// Send to all user's connections
for (const connection of userConnections) {
if (connection.ws.readyState === WebSocket.OPEN) {
connection.ws.send(JSON.stringify(payload));
}
}
});
}
private handleMessage(connection: Connection, data: WebSocket.Data) {
try {
const message = JSON.parse(data.toString());
switch (message.type) {
case 'MARK_READ':
this.handleMarkRead(connection, message.notificationIds);
break;
case 'MARK_ALL_READ':
this.handleMarkAllRead(connection);
break;
case 'ACK':
this.handleAcknowledge(connection, message.notificationId);
break;
case 'PING':
connection.ws.send(JSON.stringify({ type: 'PONG' }));
break;
}
} catch (error) {
console.error('Message handling error:', error);
}
}
private handleDisconnect(connection: Connection) {
const userConnections = this.connections.get(connection.userId);
if (userConnections) {
userConnections.delete(connection);
if (userConnections.size === 0) {
this.connections.delete(connection.userId);
// Unsubscribe from Redis if no more connections for this user
for (const channel of connection.subscribedChannels) {
this.redisSub.unsubscribe(channel);
}
}
}
}
private setupHeartbeat(connection: Connection) {
const interval = setInterval(() => {
if (connection.ws.readyState === WebSocket.OPEN) {
connection.ws.ping();
} else {
clearInterval(interval);
}
}, 30000);
connection.ws.on('close', () => clearInterval(interval));
}
// Called by notification service when new notification is created
async broadcastNotification(userId: string, notification: Notification) {
await this.redisPub.publish(
`user:${userId}:notifications`,
JSON.stringify({
type: 'NEW_NOTIFICATION',
notification
})
);
}
}
Client-Side WebSocket Hook
// useNotificationSocket.ts
import { useEffect, useRef, useCallback } from 'react';
interface UseNotificationSocketOptions {
onNotification: (notification: Notification) => void;
onRead: (ids: string[], readAt: string) => void;
onAllRead: () => void;
onConnect?: () => void;
onDisconnect?: () => void;
}
export function useNotificationSocket(options: UseNotificationSocketOptions) {
const wsRef = useRef<WebSocket | null>(null);
const reconnectAttemptsRef = useRef(0);
const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
const connect = useCallback(() => {
const token = getAuthToken();
if (!token) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/notifications?token=${token}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
console.log('Notification WebSocket connected');
reconnectAttemptsRef.current = 0;
options.onConnect?.();
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
switch (message.type) {
case 'NEW_NOTIFICATION':
options.onNotification(message.notification);
// Send acknowledgment
ws.send(JSON.stringify({
type: 'ACK',
notificationId: message.notification.id
}));
break;
case 'NOTIFICATIONS_READ':
options.onRead(message.notificationIds, message.readAt);
break;
case 'ALL_NOTIFICATIONS_READ':
options.onAllRead();
break;
case 'PONG':
// Heartbeat response
break;
}
} catch (error) {
console.error('WebSocket message parse error:', error);
}
};
ws.onclose = (event) => {
console.log('WebSocket closed:', event.code, event.reason);
options.onDisconnect?.();
// Reconnect with exponential backoff
if (event.code !== 4001) { // Don't reconnect if unauthorized
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000);
reconnectTimeoutRef.current = setTimeout(() => {
reconnectAttemptsRef.current++;
connect();
}, delay);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
// Heartbeat
const heartbeat = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'PING' }));
}
}, 25000);
return () => {
clearInterval(heartbeat);
ws.close();
};
}, [options]);
useEffect(() => {
const cleanup = connect();
// Reconnect when tab becomes visible
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
if (wsRef.current?.readyState !== WebSocket.OPEN) {
connect();
}
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
cleanup?.();
document.removeEventListener('visibilitychange', handleVisibilityChange);
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
};
}, [connect]);
const markAsRead = useCallback((notificationIds: string[]) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: 'MARK_READ',
notificationIds
}));
}
}, []);
const markAllAsRead = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'MARK_ALL_READ' }));
}
}, []);
return { markAsRead, markAllAsRead };
}
Storage and History
Scaling Notification Storage
┌─────────────────────────────────────────────────────────────────────┐
│ STORAGE STRATEGY │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ HOT PATH (Recent/Unread) COLD PATH (History) │
│ ───────────────────────── ─────────────────── │
│ │
│ ┌────────────────────────┐ ┌────────────────────────┐ │
│ │ Redis │ │ PostgreSQL │ │
│ │ │ │ │ │
│ │ • Last 100 per user │ │ • All notifications │ │
│ │ • Unread count │ │ • Partitioned by │ │
│ │ • Real-time updates │ │ created_at │ │
│ │ • Sub-ms reads │ │ • Indexed for search │ │
│ │ │ │ │ │
│ └───────────┬────────────┘ └───────────┬────────────┘ │
│ │ │ │
│ │ │ │
│ ▼ ▼ │
│ User opens dropdown User clicks "View all" │
│ → Redis lookup → Postgres query │
│ → <5ms response → 50-100ms response │
│ │
│ ─────────────────────────────────────────────────────────────── │
│ │
│ ARCHIVAL │
│ ──────── │
│ │
│ ┌────────────────────────┐ ┌────────────────────────┐ │
│ │ Postgres (read) │ ───▶ │ S3 / Cold Storage │ │
│ │ > 90 days old │ │ Compressed JSON │ │
│ │ read = true │ │ > 1 year old │ │
│ └────────────────────────┘ └────────────────────────┘ │
│ │
│ Nightly job moves old read notifications to archive │
│ │
└─────────────────────────────────────────────────────────────────────┘
Redis Cache Layer
// Notification cache service
class NotificationCacheService {
private redis: Redis;
private readonly MAX_CACHED_PER_USER = 100;
private readonly CACHE_TTL = 3600; // 1 hour
constructor(redis: Redis) {
this.redis = redis;
}
private getUserKey(userId: string): string {
return `notifications:${userId}`;
}
private getUnreadCountKey(userId: string): string {
return `notifications:${userId}:unread`;
}
async cacheNotifications(
userId: string,
notifications: Notification[]
): Promise<void> {
const key = this.getUserKey(userId);
const pipeline = this.redis.pipeline();
// Clear existing
pipeline.del(key);
// Add as sorted set (score = timestamp)
for (const notification of notifications) {
pipeline.zadd(
key,
new Date(notification.createdAt).getTime(),
JSON.stringify(notification)
);
}
// Trim to max size
pipeline.zremrangebyrank(key, 0, -this.MAX_CACHED_PER_USER - 1);
// Set TTL
pipeline.expire(key, this.CACHE_TTL);
await pipeline.exec();
}
async getCachedNotifications(
userId: string,
limit: number = 20
): Promise<Notification[] | null> {
const key = this.getUserKey(userId);
// Get most recent N notifications
const results = await this.redis.zrevrange(key, 0, limit - 1);
if (results.length === 0) {
return null; // Cache miss
}
return results.map(r => JSON.parse(r));
}
async addNotification(
userId: string,
notification: Notification
): Promise<void> {
const key = this.getUserKey(userId);
const pipeline = this.redis.pipeline();
// Add to sorted set
pipeline.zadd(
key,
new Date(notification.createdAt).getTime(),
JSON.stringify(notification)
);
// Trim oldest
pipeline.zremrangebyrank(key, 0, -this.MAX_CACHED_PER_USER - 1);
// Increment unread count
pipeline.incr(this.getUnreadCountKey(userId));
await pipeline.exec();
}
async markAsRead(userId: string, notificationIds: string[]): Promise<void> {
const key = this.getUserKey(userId);
// Get all cached notifications
const cached = await this.getCachedNotifications(userId, this.MAX_CACHED_PER_USER);
if (!cached) return;
const pipeline = this.redis.pipeline();
// Update read status in cache
for (const notification of cached) {
if (notificationIds.includes(notification.id) && !notification.read) {
// Remove old entry
pipeline.zrem(key, JSON.stringify(notification));
// Add updated entry
const updated = { ...notification, read: true, readAt: new Date().toISOString() };
pipeline.zadd(
key,
new Date(notification.createdAt).getTime(),
JSON.stringify(updated)
);
}
}
// Decrement unread count
pipeline.decrby(this.getUnreadCountKey(userId), notificationIds.length);
await pipeline.exec();
}
async getUnreadCount(userId: string): Promise<number | null> {
const count = await this.redis.get(this.getUnreadCountKey(userId));
return count ? parseInt(count, 10) : null;
}
async setUnreadCount(userId: string, count: number): Promise<void> {
await this.redis.set(
this.getUnreadCountKey(userId),
count,
'EX',
this.CACHE_TTL
);
}
}
Database Partitioning for Scale
-- Partition notifications by month for efficient archival
CREATE TABLE notifications (
id UUID NOT NULL,
user_id UUID NOT NULL,
type VARCHAR(50) NOT NULL,
title VARCHAR(255) NOT NULL,
body TEXT,
read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- ... other columns
) PARTITION BY RANGE (created_at);
-- Create partitions
CREATE TABLE notifications_2024_01 PARTITION OF notifications
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
CREATE TABLE notifications_2024_02 PARTITION OF notifications
FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
-- Automated partition creation (run monthly via cron)
CREATE OR REPLACE FUNCTION create_notification_partition()
RETURNS void AS $$
DECLARE
partition_date DATE;
partition_name TEXT;
start_date DATE;
end_date DATE;
BEGIN
partition_date := DATE_TRUNC('month', NOW() + INTERVAL '1 month');
partition_name := 'notifications_' || TO_CHAR(partition_date, 'YYYY_MM');
start_date := partition_date;
end_date := partition_date + INTERVAL '1 month';
EXECUTE format(
'CREATE TABLE IF NOT EXISTS %I PARTITION OF notifications
FOR VALUES FROM (%L) TO (%L)',
partition_name, start_date, end_date
);
END;
$$ LANGUAGE plpgsql;
-- Archive old notifications
CREATE OR REPLACE FUNCTION archive_old_notifications()
RETURNS void AS $$
BEGIN
-- Move to archive table
INSERT INTO notifications_archive
SELECT * FROM notifications
WHERE created_at < NOW() - INTERVAL '90 days'
AND read = true;
-- Delete from main table
DELETE FROM notifications
WHERE created_at < NOW() - INTERVAL '90 days'
AND read = true;
END;
$$ LANGUAGE plpgsql;
React Architecture
Component Hierarchy
┌─────────────────────────────────────────────────────────────────────┐
│ REACT COMPONENT STRUCTURE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ NotificationProvider │ │
│ │ │ │
│ │ • WebSocket connection │ │
│ │ • State management (useReducer) │ │
│ │ • Initial data fetch │ │
│ │ • Exposes: notifications, unreadCount, actions │ │
│ └───────────────────────────┬─────────────────────────────────┘ │
│ │ │
│ ┌────────────────────┴───────────────────┐ │
│ │ │ │
│ ▼ ▼ │
│ ┌────────────────────┐ ┌────────────────────┐ │
│ │ NotificationBell │ │ NotificationPage │ │
│ │ │ │ │ │
│ │ • Header icon │ │ • Full history │ │
│ │ • Badge count │ │ • Filters │ │
│ │ • Dropdown toggle │ │ • Pagination │ │
│ └─────────┬──────────┘ └─────────┬──────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌────────────────────┐ ┌────────────────────┐ │
│ │NotificationDropdown│ │ NotificationList │ │
│ │ │ │ │ │
│ │ • Recent items │ │ • Virtualized │ │
│ │ • Quick actions │ │ • Infinite scroll │ │
│ │ • Mark all read │ │ • Date groups │ │
│ └─────────┬──────────┘ └─────────┬──────────┘ │
│ │ │ │
│ └───────────────┬───────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────┐ │
│ │ NotificationItem │ │
│ │ │ │
│ │ • Icon/Avatar │ │
│ │ • Title/Body │ │
│ │ • Timestamp │ │
│ │ • Actions │ │
│ │ • Read state │ │
│ └────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
Provider Implementation
// NotificationProvider.tsx
import React, { createContext, useContext, useReducer, useEffect, useCallback } from 'react';
interface NotificationContextValue {
state: NotificationState;
markAsRead: (ids: string[]) => Promise<void>;
markAllAsRead: () => Promise<void>;
archiveNotification: (id: string) => Promise<void>;
refreshNotifications: () => Promise<void>;
}
const NotificationContext = createContext<NotificationContextValue | null>(null);
export function NotificationProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(notificationReducer, {
notifications: [],
unreadCount: 0,
isLoading: true,
error: null
});
// Initial fetch
const fetchNotifications = useCallback(async () => {
try {
const response = await fetch('/api/notifications?limit=50');
const data = await response.json();
dispatch({ type: 'SET_NOTIFICATIONS', payload: data.notifications });
} catch (error) {
dispatch({ type: 'SET_ERROR', payload: error as Error });
}
}, []);
useEffect(() => {
fetchNotifications();
}, [fetchNotifications]);
// WebSocket connection
const { markAsRead: wsMarkAsRead, markAllAsRead: wsMarkAllAsRead } = useNotificationSocket({
onNotification: (notification) => {
dispatch({ type: 'ADD_NOTIFICATION', payload: notification });
// Show browser notification if permitted
if (Notification.permission === 'granted' && document.hidden) {
new Notification(notification.title, {
body: notification.body,
icon: '/notification-icon.png'
});
}
},
onRead: (ids, readAt) => {
dispatch({ type: 'MARK_READ', payload: { ids, readAt } });
},
onAllRead: () => {
dispatch({ type: 'MARK_ALL_READ' });
}
});
// Sync on visibility change
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
// Sync unread count
fetch('/api/notifications/unread-count')
.then(r => r.json())
.then(data => {
dispatch({ type: 'SET_UNREAD_COUNT', payload: data.count });
});
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
}, []);
const markAsRead = useCallback(async (ids: string[]) => {
// Optimistic update
dispatch({ type: 'MARK_READ', payload: { ids, readAt: new Date().toISOString() } });
try {
await fetch('/api/notifications/read', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notificationIds: ids })
});
// Also notify via WebSocket for other tabs
wsMarkAsRead(ids);
} catch (error) {
// Rollback on error
fetchNotifications();
}
}, [wsMarkAsRead, fetchNotifications]);
const markAllAsRead = useCallback(async () => {
dispatch({ type: 'MARK_ALL_READ' });
try {
await fetch('/api/notifications/read-all', { method: 'POST' });
wsMarkAllAsRead();
} catch (error) {
fetchNotifications();
}
}, [wsMarkAllAsRead, fetchNotifications]);
const archiveNotification = useCallback(async (id: string) => {
dispatch({ type: 'REMOVE_NOTIFICATION', payload: id });
await fetch(`/api/notifications/${id}/archive`, { method: 'POST' });
}, []);
return (
<NotificationContext.Provider
value={{
state,
markAsRead,
markAllAsRead,
archiveNotification,
refreshNotifications: fetchNotifications
}}
>
{children}
</NotificationContext.Provider>
);
}
export function useNotifications() {
const context = useContext(NotificationContext);
if (!context) {
throw new Error('useNotifications must be used within NotificationProvider');
}
return context;
}
Bell and Dropdown Components
// NotificationBell.tsx
import { useState, useRef, useEffect } from 'react';
import { useNotifications } from './NotificationProvider';
export function NotificationBell() {
const { state, markAsRead } = useNotifications();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close on outside click
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]);
// Mark visible as seen when dropdown opens
useEffect(() => {
if (isOpen && state.notifications.length > 0) {
const unseenIds = state.notifications
.filter(n => !n.seen)
.slice(0, 10)
.map(n => n.id);
if (unseenIds.length > 0) {
fetch('/api/notifications/seen', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notificationIds: unseenIds })
});
}
}
}, [isOpen, state.notifications]);
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="relative p-2 hover:bg-gray-100 rounded-full"
aria-label={`Notifications ${state.unreadCount > 0 ? `(${state.unreadCount} unread)` : ''}`}
>
<BellIcon className="w-6 h-6" />
{state.unreadCount > 0 && (
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs rounded-full flex items-center justify-center">
{state.unreadCount > 99 ? '99+' : state.unreadCount}
</span>
)}
</button>
{isOpen && (
<NotificationDropdown
onClose={() => setIsOpen(false)}
onNotificationClick={(id) => {
markAsRead([id]);
setIsOpen(false);
}}
/>
)}
</div>
);
}
// NotificationDropdown.tsx
function NotificationDropdown({
onClose,
onNotificationClick
}: {
onClose: () => void;
onNotificationClick: (id: string) => void;
}) {
const { state, markAllAsRead } = useNotifications();
return (
<div className="absolute right-0 mt-2 w-96 bg-white rounded-lg shadow-xl border z-50 max-h-[480px] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b">
<h3 className="font-semibold">Notifications</h3>
{state.unreadCount > 0 && (
<button
onClick={markAllAsRead}
className="text-sm text-blue-600 hover:text-blue-800"
>
Mark all as read
</button>
)}
</div>
{/* Notification list */}
<div className="overflow-y-auto flex-1">
{state.notifications.length === 0 ? (
<div className="p-8 text-center text-gray-500">
No notifications yet
</div>
) : (
state.notifications.slice(0, 10).map(notification => (
<NotificationItem
key={notification.id}
notification={notification}
onClick={() => onNotificationClick(notification.id)}
compact
/>
))
)}
</div>
{/* Footer */}
<div className="border-t px-4 py-2">
<Link
href="/notifications"
className="text-sm text-blue-600 hover:text-blue-800"
onClick={onClose}
>
View all notifications
</Link>
</div>
</div>
);
}
Notification Item Component
// NotificationItem.tsx
import { formatDistanceToNow } from 'date-fns';
interface NotificationItemProps {
notification: Notification;
onClick: () => void;
compact?: boolean;
}
export function NotificationItem({
notification,
onClick,
compact = false
}: NotificationItemProps) {
const navigate = useNavigate();
const handleClick = () => {
onClick();
// Navigate to target if available
if (notification.targetType && notification.targetId) {
const url = getTargetUrl(notification.targetType, notification.targetId);
navigate(url);
}
};
const handleAction = async (action: NotificationAction, e: React.MouseEvent) => {
e.stopPropagation();
switch (action.action) {
case 'navigate':
navigate(action.url!);
break;
case 'api_call':
await fetch(action.url!, {
method: action.method || 'POST',
headers: { 'Content-Type': 'application/json' },
body: action.payload ? JSON.stringify(action.payload) : undefined
});
break;
case 'dismiss':
await archiveNotification(notification.id);
break;
}
};
return (
<div
onClick={handleClick}
className={`
flex gap-3 p-4 cursor-pointer hover:bg-gray-50 transition-colors
${!notification.read ? 'bg-blue-50' : ''}
${compact ? 'py-3' : 'py-4'}
`}
>
{/* Icon or Avatar */}
<div className="flex-shrink-0">
{notification.actorId ? (
<Avatar userId={notification.actorId} size={compact ? 'sm' : 'md'} />
) : (
<NotificationIcon type={notification.type} />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p className={`text-sm ${!notification.read ? 'font-medium' : ''}`}>
{notification.title}
</p>
{!compact && notification.body && (
<p className="text-sm text-gray-600 mt-1 line-clamp-2">
{notification.body}
</p>
)}
<p className="text-xs text-gray-400 mt-1">
{formatDistanceToNow(new Date(notification.createdAt), { addSuffix: true })}
</p>
{/* Actions */}
{!compact && notification.actions && notification.actions.length > 0 && (
<div className="flex gap-2 mt-2">
{notification.actions.map(action => (
<button
key={action.id}
onClick={(e) => handleAction(action, e)}
className="text-sm text-blue-600 hover:text-blue-800"
>
{action.label}
</button>
))}
</div>
)}
</div>
{/* Unread indicator */}
{!notification.read && (
<div className="flex-shrink-0">
<div className="w-2 h-2 bg-blue-500 rounded-full" />
</div>
)}
</div>
);
}
function NotificationIcon({ type }: { type: NotificationType }) {
const icons: Record<NotificationType, React.ReactNode> = {
mention: <AtSymbolIcon className="w-8 h-8 text-blue-500" />,
comment: <ChatBubbleIcon className="w-8 h-8 text-green-500" />,
like: <HeartIcon className="w-8 h-8 text-red-500" />,
follow: <UserPlusIcon className="w-8 h-8 text-purple-500" />,
order_update: <PackageIcon className="w-8 h-8 text-orange-500" />,
system: <CogIcon className="w-8 h-8 text-gray-500" />,
reminder: <ClockIcon className="w-8 h-8 text-yellow-500" />
};
return (
<div className="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center">
{icons[type]}
</div>
);
}
Full Page with Virtualization
// NotificationsPage.tsx
import { useVirtualizer } from '@tanstack/react-virtual';
import { useInfiniteQuery } from '@tanstack/react-query';
export function NotificationsPage() {
const parentRef = useRef<HTMLDivElement>(null);
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage
} = useInfiniteQuery({
queryKey: ['notifications'],
queryFn: async ({ pageParam = null }) => {
const params = new URLSearchParams({ limit: '50' });
if (pageParam) params.set('cursor', pageParam);
const response = await fetch(`/api/notifications?${params}`);
return response.json();
},
getNextPageParam: (lastPage) => lastPage.nextCursor
});
const allNotifications = data?.pages.flatMap(p => p.notifications) ?? [];
const virtualizer = useVirtualizer({
count: hasNextPage ? allNotifications.length + 1 : allNotifications.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 80,
overscan: 5
});
// Infinite scroll trigger
useEffect(() => {
const [lastItem] = [...virtualizer.getVirtualItems()].reverse();
if (!lastItem) return;
if (
lastItem.index >= allNotifications.length - 1 &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
}, [
hasNextPage,
fetchNextPage,
allNotifications.length,
isFetchingNextPage,
virtualizer.getVirtualItems()
]);
return (
<div className="max-w-2xl mx-auto py-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Notifications</h1>
<NotificationFilters />
</div>
<div
ref={parentRef}
className="h-[calc(100vh-200px)] overflow-auto"
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative'
}}
>
{virtualizer.getVirtualItems().map(virtualRow => {
const isLoaderRow = virtualRow.index > allNotifications.length - 1;
const notification = allNotifications[virtualRow.index];
return (
<div
key={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`
}}
>
{isLoaderRow ? (
<div className="p-4 text-center">
<Spinner />
</div>
) : (
<NotificationItem
notification={notification}
onClick={() => handleNotificationClick(notification)}
/>
)}
</div>
);
})}
</div>
</div>
</div>
);
}
Complete System Architecture
┌─────────────────────────────────────────────────────────────────────────────┐
│ FULL NOTIFICATION SYSTEM │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ EVENT SOURCES │
│ ───────────── │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ User │ │ System │ │ Webhook │ │Scheduled│ │
│ │ Actions │ │ Events │ │ Events │ │ Jobs │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │ │
│ └───────────┴───────┬───┴───────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ MESSAGE QUEUE │ │
│ │ (Redis/RabbitMQ) │ │
│ │ │ │
│ │ notifications:create │ │
│ └───────────────────────────────┬─────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ NOTIFICATION WORKERS │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ Aggregation │ │ Preferences │ │ Rate │ │ │
│ │ │ Check │──│ Check │──│ Limiting │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │
│ │ │ │ │ │
│ │ └────────────────┬───────────────────┘ │ │
│ │ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ Template │ │ Create in │ │ Delivery │ │ │
│ │ │ Render │──│ Database │──│ Routing │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │
│ │ │ │ │
│ └──────────────────────────────────────────────┼──────────────────────┘ │
│ │ │
│ ┌────────────────────┬──────────────────┼─────────────────┐ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ Redis │ │ Redis │ │ Email │ │ Push │ │
│ │ Cache │ │ Pub/Sub │ │ Queue │ │ Queue │ │
│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │
│ │ │ │ │ │
│ │ ▼ ▼ ▼ │
│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ │ WebSocket │ │ Email │ │ Push │ │
│ │ │ Servers │ │ Provider │ │ Provider │ │
│ │ └─────┬──────┘ └────────────┘ └────────────┘ │
│ │ │ │
│ │ ▼ │
│ │ ┌────────────────────────────────────────────┐ │
│ │ │ CLIENT APPS │ │
│ │ │ │ │
│ │ │ ┌────────┐ ┌────────┐ ┌────────┐ │ │
│ │ │ │ React │ │ React │ │ Mobile │ │ │
│ │ │ │ Web │ │ Native │ │ Apps │ │ │
│ │ │ └───┬────┘ └───┬────┘ └───┬────┘ │ │
│ │ │ │ │ │ │ │
│ │ │ └───────────┴───────────┘ │ │
│ │ └────────────────────────────────────────────┘ │
│ │ │ │
│ │ ▼ │
│ │ ┌─────────────────┐ │
│ │ │ REST API Fetch │ (initial load, history) │
│ │ └────────┬────────┘ │
│ │ │ │
│ └───────────────────────┘ │
│ │
│ STORAGE LAYER │
│ ───────────── │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ PostgreSQL (partitioned) ─────▶ S3 Archive (>1 year) │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Decision Matrix
| Aspect | Simple (MVP) | Standard | Enterprise |
|---|---|---|---|
| Delivery | Pull (polling) | WebSocket + pull | WebSocket + push + SMS |
| Storage | Single Postgres table | Postgres + Redis cache | Partitioned + Redis + archive |
| Read State | Server-side only | Server + cache | Server + cache + cross-device sync |
| Aggregation | None | Time-window | Smart aggregation with ML |
| Scale | <10K users | <1M users | >1M users |
| Latency | 30s (poll interval) | <1s (WebSocket) | <100ms (edge + WebSocket) |
| Complexity | Low | Medium | High |
Common Pitfalls
┌─────────────────────────────────────────────────────────────────────┐
│ PITFALLS TO AVOID │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 1. N+1 queries when loading notifications │
│ ──────────────────────────────────────── │
│ ✗ Loading actor info one by one │
│ ✓ Batch load with IN clause or dataloader │
│ │
│ 2. No rate limiting on notification creation │
│ ───────────────────────────────────────── │
│ ✗ Spam 1000 notifications when someone gets 1000 likes │
│ ✓ Aggregate or rate limit per user per type │
│ │
│ 3. Blocking on WebSocket send │
│ ────────────────────────────── │
│ ✗ await ws.send() in notification worker │
│ ✓ Fire and forget via Redis pub/sub │
│ │
│ 4. No reconnection strategy │
│ ───────────────────────── │
│ ✗ WebSocket dies, user never gets notifications │
│ ✓ Exponential backoff + visibility API sync │
│ │
│ 5. Re-rendering entire list on new notification │
│ ───────────────────────────────────────── │
│ ✗ notifications.map() in parent, re-renders all items │
│ ✓ Memoize NotificationItem, use stable keys │
│ │
│ 6. No cleanup of old notifications │
│ ───────────────────────────── │
│ ✗ Notifications table grows forever │
│ ✓ Archive job, partitioning, TTL on cache │
│ │
│ 7. Assuming WebSocket is always connected │
│ ─────────────────────────────────────── │
│ ✗ Only WebSocket delivery, nothing on disconnect │
│ ✓ Hybrid: REST fetch on reconnect to catch missed │
│ │
│ 8. Not handling duplicate notifications │
│ ───────────────────────────────────── │
│ ✗ Same notification appears multiple times │
│ ✓ Idempotency key, dedup in worker and client │
│ │
└─────────────────────────────────────────────────────────────────────┘
Quick Reference
API Endpoints
// GET /api/notifications
// Query params: limit, cursor, unread, type
// Returns: { notifications: Notification[], nextCursor?: string }
// GET /api/notifications/unread-count
// Returns: { count: number }
// POST /api/notifications/read
// Body: { notificationIds: string[] }
// Returns: { success: true }
// POST /api/notifications/read-all
// Returns: { count: number }
// POST /api/notifications/seen
// Body: { notificationIds: string[] }
// Returns: { success: true }
// POST /api/notifications/:id/archive
// Returns: { success: true }
// GET /api/notifications/preferences
// Returns: NotificationPreferences
// PUT /api/notifications/preferences
// Body: NotificationPreferences
// Returns: NotificationPreferences
WebSocket Message Types
// Server → Client
{ type: 'NEW_NOTIFICATION', notification: Notification }
{ type: 'NOTIFICATIONS_READ', notificationIds: string[], readAt: string }
{ type: 'ALL_NOTIFICATIONS_READ' }
{ type: 'PONG' }
// Client → Server
{ type: 'MARK_READ', notificationIds: string[] }
{ type: 'MARK_ALL_READ' }
{ type: 'ACK', notificationId: string }
{ type: 'PING' }
Checklist
□ Data model supports aggregation
□ Read/seen/archived states tracked separately
□ Redis cache for hot path
□ WebSocket with reconnection logic
□ Visibility API sync for tab refocus
□ Optimistic updates in React
□ Rate limiting on creation
□ Aggregation for high-volume events
□ User preferences per channel per type
□ Archive job for old notifications
□ Virtualized list for history page
□ Browser notification permission handling
□ Cross-device state sync
□ Idempotency for duplicate prevention
Closing Thoughts
A notification system is a microcosm of distributed systems challenges packed into a seemingly simple feature. Push vs pull, state synchronization, real-time delivery, storage at scale—it touches everything.
Start with the hybrid approach: REST for initial load and history, WebSocket for real-time, visibility API for sync. Add Redis caching early because the read pattern (check unread count constantly) will crush your database. Build aggregation from day one because you can't retrofit it without migration pain.
The React architecture matters more than people think. A notification arriving shouldn't re-render your entire app. Memoize aggressively, use stable keys, and consider virtualization for the history view.
Most importantly: design for the case where WebSocket isn't connected. Users switch tabs, lose connection, close laptops. Your system should gracefully handle gaps and sync up when they return.
The best notification systems are invisible. Users don't notice the infrastructure—they just see the right information at the right time.
What did you think?