Respect notification preferences
I've seen production systems tank their user engagement by flooding inboxes with notifications that users eventually tune out entirely. When you fail to respect notification preferences, you're not just annoying people—you're training them to ignore every alert you send, including the critical ones. Over 81 days of building real-time systems with Kafka and WebSockets, I've learned that a notification architecture worth its salt must let users control what, when, and how they're notified.
Why notification fatigue is a real problem
Last year I worked on a ride-hailing platform where the ops team complained that drivers weren't responding to "urgent" platform updates. Turns out, drivers were getting 40+ notifications daily—cancellations, promotions, maintenance alerts, you name it. Most were low-priority. Within weeks, drivers had trained themselves to reflexively dismiss notifications. The irony? Critical safety alerts got ignored too.
Notification fatigue is measurable. Studies show that users who receive more than 5–10 notifications per day from a single source start ignoring them. Your unread badge becomes wallpaper. Push notifications get swiped away unread. Email lands in trash. At that point, you've lost your channel entirely.
The fix isn't "send fewer notifications"—sometimes you need to send many. The fix is letting users choose what matters to them and honoring that choice.
Per-channel and per-type settings
Users should have granular control over which notification types reach which channels. A user might want order updates via email but marketing announcements only in-app. Another might want critical security alerts on SMS but community activity on Slack only.
Here's how I structure this in practice. Each user has a preferences document that maps notification types and channels:
interface NotificationPreference {
userId: string;
notificationType: 'order_update' | 'security_alert' | 'promotional' | 'community' | 'system';
channel: 'email' | 'sms' | 'push' | 'in_app' | 'slack';
enabled: boolean;
quietHourStart?: number; // 0-23 hour
quietHourEnd?: number;
digestFrequency?: 'immediate' | 'daily' | 'weekly' | 'never';
}
interface UserNotificationSettings {
userId: string;
preferences: NotificationPreference[];
criticalAlwaysNotify: boolean; // Critical alerts ignore quiet hours
}
When your Kafka consumer receives an event destined for notification, it queries the user's preferences before sending anything:
async function shouldNotifyUser(
userId: string,
notificationType: string,
channel: string,
isCritical: boolean,
): Promise {
const settings = await db.getUserNotificationSettings(userId);
const pref = settings.preferences.find(
p => p.notificationType === notificationType && p.channel === channel
);
if (!pref || !pref.enabled) {
return false;
}
const now = new Date();
const hour = now.getHours();
// Critical alerts bypass quiet hours
if (isCritical) {
return true;
}
// Check quiet hours for non-critical
if (pref.quietHourStart !== undefined && pref.quietHourEnd !== undefined) {
const inQuietHours =
(pref.quietHourStart < pref.quietHourEnd &&
hour >= pref.quietHourStart && hour < pref.quietHourEnd) ||
(pref.quietHourStart > pref.quietHourEnd &&
(hour >= pref.quietHourStart || hour < pref.quietHourEnd));
if (inQuietHours && pref.digestFrequency !== 'immediate') {
return false;
}
}
return true;
}
In my e-commerce platform, we differentiated between order statuses. Order shipped? That's probably immediate for most users. Recommended products based on browsing? That goes into a daily digest at 9 AM. Abandoned cart reminders? User-selectable, often 24 hours later via email.
Quiet hours and digests
Quiet hours respect user sleep and work schedules. But they introduce complexity: if you silence notifications during quiet hours, they don't vanish—they need somewhere to go. That's where digests shine.
A digest is a batched summary of notifications, typically delivered once daily at a user-preferred time. Instead of getting 12 notifications about community activity between midnight and 8 AM, the user gets one digest email at 8:15 AM with all 12 items.
Here's a digest service I built for a SaaS platform:
interface PendingNotification {
id: string;
userId: string;
notificationType: string;
channel: string;
payload: Record;
createdAt: Date;
isCritical: boolean;
}
async function queueForDigest(
notification: PendingNotification,
digestTime: string, // "09:00"
): Promise {
const [hours, minutes] = digestTime.split(':').map(Number);
const nextDelivery = getNextDeliveryTime(hours, minutes);
await db.insertPendingDigestNotification({
...notification,
scheduledDeliveryTime: nextDelivery,
});
}
async function buildAndSendDigest(userId: string, channel: string): Promise {
const pending = await db.getPendingDigestNotifications(
userId,
channel,
new Date(),
);
if (pending.length === 0) {
return;
}
const grouped = groupNotificationsByType(pending);
const digestContent = renderDigestTemplate(grouped);
await notificationService.send({
userId,
channel,
content: digestContent,
template: 'digest',
});
await db.markNotificationsAsSent(pending.map(n => n.id));
}
The key insight: a digest is still a notification, so it respects quiet hours too. Don't send a 2 AM digest just because you batch notifications. Schedule digests for reasonable hours that the user specifies.
Critical alerts bypass digests
Not everything is digestible. A security breach, a payment failure, or a production incident needs immediate attention. Critical alerts should bypass quiet hours and digest settings entirely, but sparingly.
Define "critical" narrowly. I've seen teams mark 30% of their alerts as critical, which defeats the purpose. In my trading platform, only these triggered immediate notifications:
- Account compromised (potential unauthorized access)
- Payment declined on automatic settlement
- Margin call or liquidation risk
- API authentication failure
Everything else—fill notifications, balance updates, audit logs—went through normal preference channels.
async function notifyWithCriticalBypass(
userId: string,
notificationType: string,
channel: string,
payload: Record,
isCritical: boolean,
): Promise {
if (isCritical) {
// Skip all preference checks, send immediately to all critical channels
const criticalChannels = ['sms', 'push', 'email'];
for (const ch of criticalChannels) {
await notificationService.send({
userId,
channel: ch,
notificationType,
payload,
immediate: true,
});
}
// Log for audit
await auditLog('critical_alert_sent', { userId, notificationType });
return;
}
// Non-critical: respect preferences
if (await shouldNotifyUser(userId, notificationType, channel, false)) {
await notificationService.send({
userId,
channel,
notificationType,
payload,
});
}
}
Even with critical alerts, log them. Users should be able to see that a critical notification was sent, when, and why. This builds trust and helps with support tickets ("Why didn't I see that?").
Audit what you send
You can't manage what you don't measure. Implement comprehensive logging of every notification: what was sent, to whom, via which channel, when, and why it was sent or skipped.
I structure notification logs like this:
interface NotificationAuditLog {
id: string;
userId: string;
notificationType: string;
channel: string;
status: 'sent' | 'skipped' | 'failed' | 'bounced';
reason?: string; // e.g., "user_preference_disabled", "quiet_hours", "digest_pending"
isCritical: boolean;
attemptedAt: Date;
deliveredAt?: Date;
metadata: {
templateId?: string;
contentLength?: number;
externalId?: string; // SMS message ID, email message ID, etc.
};
}
Store these in a time-series database or a partitioned table. Run daily queries to answer:
- How many notifications did we send yesterday by type and channel?
- What's our skip rate? (Should be non-zero if preferences work.)
- Which users are receiving the most notifications?
- What's our delivery success rate by channel?
In my CRM platform, I discovered that one notification type had a 60% skip rate. Investigation showed it was overly aggressive—triggered for nearly every customer interaction. We changed the threshold, and user engagement actually improved because we were sending fewer, higher-value notifications.
async function getNotificationMetrics(dateRange: { start: Date; end: Date }) {
const logs = await db.query(`
SELECT
notificationType,
channel,
status,
COUNT(*) as count
FROM notification_audit_logs
WHERE attemptedAt BETWEEN $1 AND $2
GROUP BY notificationType, channel, status
`, [dateRange.start, dateRange.end]);
return logs;
}
Common mistakes
Mistake 1: "Critical" becomes meaningless. If you mark 50 notification types as critical, users will tune them all out. Be brutal. Critical means "requires action within minutes."
Mistake 2: Ignoring digest delays. Developers often build digests but forget that queuing a notification is still a decision. If a notification sits in a digest queue for 24 hours, it's stale. Add a TTL or re-evaluate if digest is even appropriate.
Mistake 3: Not migrating existing users. You've shipped preferences, but existing users have no settings. Default to conservative (low frequency, important channels only) and let them opt up, not opt out.
Mistake 4: No audit trail for users. Users sometimes claim they didn't receive a notification. If you can't show them the audit log, you lose credibility. Build a "notification history" view in your app.
Mistake 5: Batch processing that doesn't respect timezone. If your digest runs at 09:00 UTC for all users, someone in Tokyo gets it at 6 PM and someone in New York gets it at 4 AM. Always calculate digest times in user's local timezone.
Key takeaways
- Notification fatigue is real and kills your engagement. Users trained to ignore notifications will ignore critical ones too.
- Build per-channel and per-type preference settings from day one. Let users choose where they're notified and for what.
- Use digests to batch non-urgent notifications during quiet hours, but respect quiet hours for digests too.
- Keep "critical" truly critical. Mark only high-urgency alerts as critical to preserve their impact.
- Audit every notification: sent, skipped, failed. Use metrics to identify over-aggressive notification types.
- Calculate quiet hours and digest times in user's local timezone, not server time.
- Provide users visibility into their notification history and why specific notifications were or weren't delivered.






Comments
Loading comments…