How to Build a Notification System: Push, Email, SMS Architecture
A notification system sounds simple - "just send an email." In production: delivery failures, user preferences, rate limiting, unsubscribes, timezone awareness, and analytics make it a real engineering problem.
Notification Channels and When to Use Each
| Channel | Use case | Urgency | Open rate |
|---|---|---|---|
| Push (mobile) | Activity, alerts, re-engagement | High | 7-10% |
| Push (web) | Updates, promotions | Medium | 6-8% |
| Transactional, digests, marketing | Low-Medium | 20-40% | |
| SMS | OTP, critical alerts | High | 95%+ |
| In-app | Contextual, while using product | High (user is active) | 100% if seen |
| WhatsApp/Telegram | High-engagement markets | High | 60-80% |
Channel selection logic:
- OTP/auth: SMS (reliability over cost)
- Order confirmed: Email (reference document) + Push (immediacy)
- Flash sale: Push + Email
- Account security alert: Email + SMS
- New message: Push (mobile) + In-app
Architecture
Event Source → Event Bus (Redis / BullMQ) → Notification Service
↓
User Preference Check
↓
Route to channel workers:
- EmailWorker (Resend/SendGrid)
- PushWorker (FCM/APNs)
- SMSWorker (Twilio/SMS.ru)
- InAppWorker (WebSocket/DB)
↓
Delivery Tracking
Decouple notification sending from business logic. The order service fires an event; it doesn't send emails directly.
The Event Model
interface NotificationEvent {
id: string;
type: 'order_confirmed' | 'message_received' | 'password_reset' | ...;
userId: string;
data: Record<string, unknown>; // Template variables
priority: 'low' | 'medium' | 'high' | 'critical';
channels?: string[]; // Override if specific channel required
scheduledAt?: Date; // For delayed/digest notifications
}
Producer (order service):
await queue.add('notification', {
type: 'order_confirmed',
userId: order.userId,
data: { orderId: order.id, total: order.total, items: order.items },
priority: 'high'
});
User Preferences
Users must control what they receive. Never skip this - it's both good UX and legally required (CAN-SPAM, GDPR).
notification_preferences (
user_id UUID,
notification_type VARCHAR(64),
channel VARCHAR(32), -- 'push', 'email', 'sms'
enabled BOOLEAN DEFAULT true,
PRIMARY KEY (user_id, notification_type, channel)
)
Before sending any notification, check preferences:
async function shouldSend(userId: string, type: string, channel: string) {
// Critical notifications ignore preferences (OTP, security)
if (CRITICAL_TYPES.includes(type)) return true;
const pref = await db.query(
`SELECT enabled FROM notification_preferences
WHERE user_id = $1 AND notification_type = $2 AND channel = $3`,
[userId, type, channel]
);
// Default: enabled unless explicitly disabled
return pref?.enabled ?? true;
}
Push Notifications: FCM + APNs
Android (Firebase Cloud Messaging)
import { initializeApp } from 'firebase-admin/app';
import { getMessaging } from 'firebase-admin/messaging';
const messaging = getMessaging();
async function sendPush(token: string, notification: PushPayload) {
await messaging.send({
token,
notification: {
title: notification.title,
body: notification.body,
},
data: notification.data, // Custom key-value pairs
android: {
priority: 'high',
ttl: 86400 * 1000, // 24 hours
},
});
}
iOS (APNs via FCM)
FCM handles APNs routing - you don't need a separate APNs client when using Firebase. But if sending directly to APNs:
// Using node-apn
const note = new apn.Notification();
note.alert = { title, body };
note.payload = data;
note.topic = 'com.yourapp.bundleid';
await provider.send(note, deviceToken);
Handling Invalid Tokens
Tokens expire or become invalid when users uninstall. Always handle failure responses:
try {
await messaging.send(message);
} catch (error) {
if (error.code === 'messaging/registration-token-not-registered') {
// Token invalid - remove from database
await db.deletePushToken(token);
}
}
Email: Transactional vs Marketing
Transactional (order confirm, password reset, receipts): Use dedicated transactional providers. Don't mix with marketing email - deliverability must be protected.
- Resend (excellent DX, modern API)
- Postmark (excellent deliverability, strict ToS)
- SendGrid Transactional
Marketing (newsletters, promotions): Separate IP/domain from transactional.
- Mailchimp, Brevo, SendGrid Marketing
- For Russia: Unisender, DashaMail
Template approach:
// Use React Email or MJML for templates
import { render } from '@react-email/render';
import OrderConfirmed from './templates/OrderConfirmed';
const html = render(<OrderConfirmed order={order} />);
await resend.emails.send({
from: 'orders@yourcompany.com',
to: user.email,
subject: `Order #${order.id} confirmed`,
html,
});
Rate Limiting Notifications
Users who receive too many notifications unsubscribe. Apply rate limits:
async function rateLimitCheck(userId: string, channel: string): Promise<boolean> {
const key = `notif_rate:${userId}:${channel}:${getCurrentHour()}`;
const count = await redis.incr(key);
await redis.expire(key, 3600);
const limits = { push: 5, email: 3, sms: 2 }; // Per hour
return count <= limits[channel];
}
Digest pattern: Instead of 10 separate "new comment" emails, batch into one "You have 10 new comments" email sent daily.
Delivery Tracking
notification_logs (
id UUID,
user_id UUID,
notification_type VARCHAR,
channel VARCHAR,
status ENUM('queued', 'sent', 'delivered', 'failed', 'opened'),
provider_message_id TEXT,
error_message TEXT,
sent_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ,
opened_at TIMESTAMPTZ
)
Track delivery rates per channel. If push delivery drops below 80%, investigate token quality. If email open rate drops, check spam folder placement.
Build your notification system with Aunimeda →
Aunimeda develops websites and web applications for businesses — corporate sites, e-commerce, portals, and custom platforms.
Contact us to discuss your web project. See also: Web Development, E-commerce Development