xiaohongshu
xiaohongshu
Delivering real-time updates to users via WebSocket, SSE, or Push API for live notification systems with proper architecture, queuing, and delivery mechanisms.
Full skill instructions
Current Level: Intermediate
Domain: Real-time / Communication
Live notification systems deliver real-time updates to users via WebSocket, SSE, or Push API. This guide covers architecture, implementation, and best practices for building notification systems that deliver timely updates to users across devices.
Event Source → Backend → Queue → Notification Service → Delivery
↓ ↓ ↓ ↓ ↓
Database Processing Redis WebSocket/SSE User Device
-- notifications table
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL,
title VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
data JSONB,
read BOOLEAN DEFAULT FALSE,
read_at TIMESTAMP,
action_url VARCHAR(500),
created_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_read (user_id, read),
INDEX idx_created (created_at)
);
-- notification_preferences table
CREATE TABLE notification_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL,
enabled BOOLEAN DEFAULT TRUE,
channels JSONB DEFAULT '{"web": true, "email": false, "push": false}',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, type)
);
-- notification_groups table
CREATE TABLE notification_groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL,
count INTEGER DEFAULT 1,
last_notification_id UUID REFERENCES notifications(id),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
INDEX idx_user_type (user_id, type)
);
// services/notification-socket.service.ts
import { Server, Socket } from 'socket.io';
export class NotificationSocketService {
constructor(private io: Server) {
this.setupHandlers();
}
private setupHandlers(): void {
this.io.on('connection', (socket) => {
const userId = socket.data.user.id;
// Join user's notification room
socket.join(`notifications:${userId}`);
// Send unread count
this.sendUnreadCount(socket, userId);
// Mark as read
socket.on('mark-as-read', async (notificationId: string) => {
await this.markAsRead(notificationId, userId);
});
// Mark all as read
socket.on('mark-all-as-read', async () => {
await this.markAllAsRead(userId);
});
});
}
async sendNotification(userId: string, notification: Notification): Promise<void> {
// Save to database
const saved = await db.notification.create({
data: {
userId,
type: notification.type,
title: notification.title,
message: notification.message,
data: notification.data,
actionUrl: notification.actionUrl
}
});
// Send via WebSocket
this.io.to(`notifications:${userId}`).emit('notification', {
id: saved.id,
type: saved.type,
title: saved.title,
message: saved.message,
data: saved.data,
actionUrl: saved.actionUrl,
createdAt: saved.createdAt
});
// Update unread count
await this.sendUnreadCount(null, userId);
}
private async sendUnreadCount(socket: Socket | null, userId: string): Promise<void> {
const count = await db.notification.count({
where: {
userId,
read: false
}
});
const target = socket || this.io.to(`notifications:${userId}`);
target.emit('unread-count', { count });
}
private async markAsRead(notificationId: string, userId: string): Promise<void> {
await db.notification.update({
where: {
id: notificationId,
userId
},
data: {
read: true,
readAt: new Date()
}
});
await this.sendUnreadCount(null, userId);
}
private async markAllAsRead(userId: string): Promise<void> {
await db.notification.updateMany({
where: {
userId,
read: false
},
data: {
read: true,
readAt: new Date()
}
});
await this.sendUnreadCount(null, userId);
}
}
interface Notification {
type: string;
title: string;
message: string;
data?: any;
actionUrl?: string;
}
// pages/api/notifications/stream.ts
import type { NextApiRequest, NextApiResponse } from 'next';
const clients = new Map<string, NextApiResponse>();
export default function handler(req: NextApiRequest, res: NextApiResponse) {
const userId = req.query.userId as string;
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// Store client
clients.set(userId, res);
// Send initial unread count
sendUnreadCount(userId, res);
// Cleanup on disconnect
req.on('close', () => {
clients.delete(userId);
res.end();
});
}
async function sendUnreadCount(userId: string, res: NextApiResponse): Promise<void> {
const count = await getUnreadCount(userId);
res.write(`data: ${JSON.stringify({ type: 'unread-count', count })}\n\n`);
}
export async function sendNotificationToUser(userId: string, notification: any): Promise<void> {
const client = clients.get(userId);
if (client) {
client.write(`data: ${JSON.stringify({ type: 'notification', ...notification })}\n\n`);
}
}
// types/notifications.ts
export enum NotificationType {
COMMENT = 'comment',
MENTION = 'mention',
LIKE = 'like',
FOLLOW = 'follow',
MESSAGE = 'message',
SYSTEM = 'system',
ORDER = 'order',
PAYMENT = 'payment'
}
export interface NotificationTemplate {
type: NotificationType;
title: (data: any) => string;
message: (data: any) => string;
actionUrl: (data: any) => string;
}
export const notificationTemplates: Record<NotificationType, NotificationTemplate> = {
[NotificationType.COMMENT]: {
type: NotificationType.COMMENT,
title: (data) => `New comment from ${data.userName}`,
message: (data) => data.comment,
actionUrl: (data) => `/posts/${data.postId}#comment-${data.commentId}`
},
[NotificationType.MENTION]: {
type: NotificationType.MENTION,
title: (data) => `${data.userName} mentioned you`,
message: (data) => data.text,
actionUrl: (data) => data.url
},
[NotificationType.LIKE]: {
type: NotificationType.LIKE,
title: (data) => `${data.userName} liked your post`,
message: (data) => data.postTitle,
actionUrl: (data) => `/posts/${data.postId}`
}
};
// components/NotificationCenter.tsx
import { useEffect, useState } from 'react';
import { Bell } from 'lucide-react';
export function NotificationCenter() {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const socket = getSocket();
// Listen for new notifications
socket.on('notification', (notification: Notification) => {
setNotifications(prev => [notification, ...prev]);
setUnreadCount(prev => prev + 1);
// Show browser notification
if (Notification.permission === 'granted') {
new Notification(notification.title, {
body: notification.message,
icon: '/icon.png'
});
}
});
// Listen for unread count
socket.on('unread-count', ({ count }: { count: number }) => {
setUnreadCount(count);
});
// Load initial notifications
loadNotifications();
return () => {
socket.off('notification');
socket.off('unread-count');
};
}, []);
const loadNotifications = async () => {
const response = await fetch('/api/notifications');
const data = await response.json();
setNotifications(data.notifications);
};
const markAsRead = async (notificationId: string) => {
const socket = getSocket();
socket.emit('mark-as-read', notificationId);
setNotifications(prev =>
prev.map(n => n.id === notificationId ? { ...n, read: true } : n)
);
};
const markAllAsRead = async () => {
const socket = getSocket();
socket.emit('mark-all-as-read');
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
setUnreadCount(0);
};
return (
<div className="notification-center">
<button
className="notification-button"
onClick={() => setIsOpen(!isOpen)}
>
<Bell />
{unreadCount > 0 && (
<span className="badge">{unreadCount}</span>
)}
</button>
{isOpen && (
<div className="notification-dropdown">
<div className="header">
<h3>Notifications</h3>
{unreadCount > 0 && (
<button onClick={markAllAsRead}>Mark all as read</button>
)}
</div>
<div className="notification-list">
{notifications.length === 0 ? (
<div className="empty">No notifications</div>
) : (
notifications.map(notification => (
<NotificationItem
key={notification.id}
notification={notification}
onRead={markAsRead}
/>
))
)}
</div>
</div>
)}
</div>
);
}
function NotificationItem({ notification, onRead }: NotificationItemProps) {
return (
<div
className={`notification-item ${!notification.read ? 'unread' : ''}`}
onClick={() => {
if (!notification.read) {
onRead(notification.id);
}
if (notification.actionUrl) {
window.location.href = notification.actionUrl;
}
}}
>
<div className="notification-icon">
{getNotificationIcon(notification.type)}
</div>
<div className="notification-content">
<div className="title">{notification.title}</div>
<div className="message">{notification.message}</div>
<div className="time">{formatTime(notification.createdAt)}</div>
</div>
{!notification.read && <div className="unread-dot" />}
</div>
);
}
// services/notification-preferences.service.ts
export class NotificationPreferencesService {
async getPreferences(userId: string): Promise<NotificationPreference[]> {
return db.notificationPreference.findMany({
where: { userId }
});
}
async updatePreference(
userId: string,
type: string,
updates: Partial<NotificationPreference>
): Promise<void> {
await db.notificationPreference.upsert({
where: {
userId_type: { userId, type }
},
create: {
userId,
type,
...updates
},
update: updates
});
}
async shouldSendNotification(
userId: string,
type: string,
channel: 'web' | 'email' | 'push'
): Promise<boolean> {
const preference = await db.notificationPreference.findUnique({
where: {
userId_type: { userId, type }
}
});
if (!preference || !preference.enabled) {
return false;
}
return preference.channels[channel] === true;
}
}
// services/notification-grouping.service.ts
export class NotificationGroupingService {
async groupNotifications(userId: string, type: string): Promise<void> {
const recentNotifications = await db.notification.findMany({
where: {
userId,
type,
createdAt: {
gte: new Date(Date.now() - 60 * 60 * 1000) // Last hour
}
},
orderBy: { createdAt: 'desc' }
});
if (recentNotifications.length >= 3) {
// Create or update group
await db.notificationGroup.upsert({
where: {
userId_type: { userId, type }
},
create: {
userId,
type,
count: recentNotifications.length,
lastNotificationId: recentNotifications[0].id
},
update: {
count: recentNotifications.length,
lastNotificationId: recentNotifications[0].id,
updatedAt: new Date()
}
});
// Send grouped notification
await notificationSocketService.sendNotification(userId, {
type: `${type}_group`,
title: `${recentNotifications.length} new ${type}s`,
message: 'Click to view all',
actionUrl: `/notifications?type=${type}`
});
}
}
}
// services/notification-rate-limiter.service.ts
export class NotificationRateLimiterService {
private redis: Redis;
constructor() {
this.redis = new Redis(process.env.REDIS_URL!);
}
async canSendNotification(userId: string, type: string): Promise<boolean> {
const key = `notification_rate:${userId}:${type}`;
const count = await this.redis.incr(key);
if (count === 1) {
await this.redis.expire(key, 3600); // 1 hour window
}
// Max 10 notifications per hour per type
return count <= 10;
}
async getRemainingQuota(userId: string, type: string): Promise<number> {
const key = `notification_rate:${userId}:${type}`;
const count = await this.redis.get(key);
return Math.max(0, 10 - parseInt(count || '0'));
}
}
// services/push-notification.service.ts
import admin from 'firebase-admin';
export class PushNotificationService {
async sendPushNotification(
userId: string,
notification: Notification
): Promise<void> {
// Get user's FCM tokens
const tokens = await this.getUserTokens(userId);
if (tokens.length === 0) {
return;
}
const message = {
notification: {
title: notification.title,
body: notification.message
},
data: {
notificationId: notification.id,
type: notification.type,
actionUrl: notification.actionUrl || ''
},
tokens
};
const response = await admin.messaging().sendMulticast(message);
// Remove invalid tokens
if (response.failureCount > 0) {
await this.removeInvalidTokens(userId, response.responses, tokens);
}
}
private async getUserTokens(userId: string): Promise<string[]> {
const devices = await db.device.findMany({
where: {
userId,
fcmToken: { not: null }
}
});
return devices.map(d => d.fcmToken!);
}
private async removeInvalidTokens(
userId: string,
responses: any[],
tokens: string[]
): Promise<void> {
const invalidTokens = responses
.map((response, index) => ({
response,
token: tokens[index]
}))
.filter(({ response }) => !response.success)
.map(({ token }) => token);
if (invalidTokens.length > 0) {
await db.device.deleteMany({
where: {
userId,
fcmToken: { in: invalidTokens }
}
});
}
}
}
// services/notification-email.service.ts
export class NotificationEmailService {
async sendEmailNotification(
userId: string,
notification: Notification
): Promise<void> {
const user = await db.user.findUnique({ where: { id: userId } });
if (!user || !user.email) {
return;
}
await emailService.send({
to: user.email,
subject: notification.title,
template: 'notification',
data: {
title: notification.title,
message: notification.message,
actionUrl: notification.actionUrl,
unsubscribeUrl: `/settings/notifications`
}
});
}
}
// services/notification-analytics.service.ts
export class NotificationAnalyticsService {
async trackNotificationSent(notification: Notification): Promise<void> {
await db.notificationAnalytics.create({
data: {
notificationId: notification.id,
type: notification.type,
event: 'sent',
timestamp: new Date()
}
});
}
async trackNotificationRead(notificationId: string): Promise<void> {
await db.notificationAnalytics.create({
data: {
notificationId,
event: 'read',
timestamp: new Date()
}
});
}
async trackNotificationClicked(notificationId: string): Promise<void> {
await db.notificationAnalytics.create({
data: {
notificationId,
event: 'clicked',
timestamp: new Date()
}
});
}
async getNotificationMetrics(type: string): Promise<NotificationMetrics> {
const analytics = await db.notificationAnalytics.findMany({
where: {
notification: { type }
}
});
const sent = analytics.filter(a => a.event === 'sent').length;
const read = analytics.filter(a => a.event === 'read').length;
const clicked = analytics.filter(a => a.event === 'clicked').length;
return {
sent,
read,
clicked,
readRate: sent > 0 ? (read / sent) * 100 : 0,
clickRate: sent > 0 ? (clicked / sent) * 100 : 0
};
}
}
interface NotificationMetrics {
sent: number;
read: number;
clicked: number;
readRate: number;
clickRate: number;
}
// Server
io.on('connection', (socket) => {
const userId = socket.handshake.auth.userId
// Join user room
socket.join(`user:${userId}`)
// Send notification
socket.emit('notification', {
id: '123',
type: 'message',
title: 'New message',
body: 'You have a new message',
timestamp: Date.now()
})
})
// Send to specific user
io.to('user:123').emit('notification', notificationData)
class NotificationQueue {
async enqueue(notification: Notification) {
await redis.lpush('notifications', JSON.stringify(notification))
}
async process() {
while (true) {
const notification = await redis.brpop('notifications', 10)
if (notification) {
await this.deliver(JSON.parse(notification[1]))
}
}
}
}
// ❌ Bad - No persistence
socket.emit('notification', notification)
// Lost if user offline!
// ✅ Good - Persist notifications
await db.notifications.create({ data: notification })
socket.emit('notification', notification)
// User can fetch when back online
// ❌ Bad - Too many notifications
user.actions.forEach(action => {
sendNotification(`You ${action}`) // Spam!
})
// ✅ Good - Group notifications
const actions = user.actions
sendNotification(`You have ${actions.length} updates`)
34-real-time-features/websocket-patterns/) - WebSocket implementation34-real-time-features/server-sent-events/) - SSE alternative31-mobile-development/push-notifications/) - Mobile pushxiaohongshu
technical spec
product ux expert
database patterns
Conduct multi-agent task orchestration and workflow coordination.
Initialize project with Conductor artifacts (product definition,
Expert in web animations, transitions, and motion design using Framer Motion and CSS
Creates Mermaid and ASCII diagrams for flowcharts, architecture, ERDs, state machines, mindmaps, and more. Use when user mentions diagram, flowchart, mermaid, ASCII diagram, text diagram, terminal diagram, visualize, C4, mindmap, architecture diagram, sequence diagram, ERD, or needs visual docume...
PostgreSQL bindings for H3 hexagonal grid system. Use when working with H3 cells in Postgres, including spatial indexing, geometry/geography integration, and raster analysis.
Context-Driven Development skill for projects using Conductor. Use this skill when you detect a `conductor/` directory in the project, when working on tasks defined in a `plan.md` file, or when the user asks about tracks, specs, or plans. Automatically applies TDD workflow, tracks task completion...
Display project status, active tracks, and next actions
Official Stakpak application containerization standard operating procedure, a step-by-step guidline to properly dockerize applications. This is a rule book curated by the Stakpak Team.
Generate, edit, and beat-sync AI video with leading models in one workspace.
The world's fastest calendar for remote work
Transform Your Design with AI Designer by ImgCreator.ai
Revolutionizing Video Production with AI-Powered Creativity
Extend an image past the frame and let AI fill the new aspect ratio.
Discover your celebrity doppelgänger with StarByFace!
ChainClarity explains 700+ crypto whitepapers in plain English, with layered summaries, comparisons, research tools, alerts, and a $4.99 Pro plan.
Opus.ai: Revolutionize Your Web Experience