Design a Notification System
04 Aug 2026A notification system that doesn’t let users turn things off will eventually get everything turned off, including the notifications they actually wanted. Design the preference model before you design the delivery pipeline — it’s the part that determines whether the feature builds trust or burns it.
Separate the event from the delivery
Don’t hardcode “send an email” inside the business logic that creates the event. Emit an event, and let a separate notification service decide what to do with it:
await notifications.emit({
type: 'order.shipped',
userId: order.userId,
data: { orderId: order.id, trackingUrl: order.trackingUrl },
})
The notification service looks up the user’s preferences for order.shipped, decides which channels apply, formats the message per channel, and dispatches. This separation is what lets you add a new channel (say, SMS) later without touching every place in the codebase that creates an event.
Preferences, per channel, per notification type
A single global “email notifications: on/off” toggle is not enough — users want security alerts always on and product update emails off, and forcing them into one setting for both is how they end up disabling everything to make the noise stop.
CREATE TABLE notification_preferences (
user_id uuid,
notification_type text,
channel text,
enabled boolean DEFAULT true,
PRIMARY KEY (user_id, notification_type, channel)
);
Default new notification types to your best judgment of what’s expected, not to “on” for everything — a new marketing notification type defaulting to enabled for existing users is how you end up on a spam complaint list. Security and account-critical notifications (password changed, new device login) shouldn’t be optional at all; don’t put them in the preference table.
Batch and digest instead of firing on every event
If five things happen to a user’s account in ten minutes, five emails is not five times the engagement — it’s one unsubscribe. Debounce non-urgent notifications into a digest:
async function queueForDigest(userId, notification) {
await redis.rpush(`digest:${userId}`, JSON.stringify(notification))
}
// cron, every 15 minutes
async function flushDigests() {
const userIds = await redis.keys('digest:*')
for (const key of userIds) {
const items = await redis.lrange(key, 0, -1)
if (items.length) {
await sendDigestEmail(key, items.map(JSON.parse))
await redis.del(key)
}
}
}
Reserve immediate delivery for things that are actually time-sensitive — a payment failure, a security alert, someone @mentioning you. Everything else can wait 15 minutes and arrive as one email instead of five.
Architecture sketch
flowchart LR
A["Event source"] --> B["Notification service"]
B --> C["Preference lookup"]
C --> D{"Channel dispatch"}
D --> E["Email (queue, provider)"]
D --> F["Push (queue, FCM/APNs)"]
D --> G["In-app (DB write, badge)"]
D --> H["SMS (queue, Twilio)"]
Put a queue between the notification service and each channel’s dispatch. A slow email provider shouldn’t block an in-app notification from showing up instantly, and a failed SMS send shouldn’t retry-loop into the same code path as email. Each channel has its own failure modes and its own retry logic — keep them decoupled.
In-app notifications need read state
Don’t forget the simplest channel is often the one users actually want: a bell icon with unread state, no external delivery at all. It has no deliverability problems, no spam complaints, and no preference fatigue — just a read_at timestamp on the row. For a lot of product notifications (“someone commented on your doc”), in-app plus an optional daily digest email covers the need better than an immediate push notification would.
What to actually measure
Track unsubscribe and preference-disable rate per notification type, not just delivery success. A notification type with a climbing disable rate is telling you it’s not valuable enough to justify its frequency — that’s a product signal, and it shows up in this data well before it shows up as a churn reason in an exit survey.