Notification Rate Limiting: Protecting Users from Alert Fatigue
How to send fewer, more impactful notifications that users actually engage with
Alert fatigue is killing notification engagement. According to Localytics research, 60% of users disable push notifications entirely due to receiving too many irrelevant alerts. Meanwhile, HubSpot data shows email unsubscribe rates increase 5x when sending more than 5 emails per week.
The solution is not to stop notifying users, but to notify them smarter. This guide covers rate limiting strategies, batching techniques, and preference management to maximize engagement while respecting user attention.
What you will learn:
- Implement frequency capping per user
- Build smart batching and digest notifications
- Create preference-based routing
- Configure quiet hours and DND modes
- Handle notification prioritization
- Measure and optimize notification effectiveness
Understanding Alert Fatigue
The Cost of Over-Notification
| Metric | Over-Notifying | Optimized |
|---|---|---|
| Push opt-out rate | 40-60% | < 10% |
| Email unsubscribe rate | 2-5% per campaign | < 0.5% |
| Notification open rate | 2-5% | 15-30% |
| User satisfaction | Low | High |
| Support tickets | High | Low |
Signs Your Users Have Alert Fatigue
- Declining open rates - Users ignoring notifications
- High unsubscribe rates - Users opting out entirely
- Negative feedback - "Too many emails" complaints
- App uninstalls - Push notifications causing deletions
- Channel switching - Users requesting different channels
Frequency Capping Strategies
Per-User Limits
Limit notifications per user within time windows:
interface FrequencyLimit {
channel: string
maxCount: number
windowMinutes: number
}
const limits: FrequencyLimit[] = [
{ channel: 'email', maxCount: 3, windowMinutes: 1440 }, // 3 emails per day
{ channel: 'push', maxCount: 5, windowMinutes: 60 }, // 5 push per hour
{ channel: 'sms', maxCount: 2, windowMinutes: 1440 }, // 2 SMS per day
{ channel: 'slack', maxCount: 10, windowMinutes: 60 } // 10 Slack per hour
]
async function checkRateLimit(
userId: string,
channel: string
): Promise<boolean> {
const limit = limits.find(l => l.channel === channel)
if (!limit) return true
const count = await redis.get(`ratelimit:${userId}:${channel}`)
if (parseInt(count || '0') >= limit.maxCount) {
return false // Rate limited
}
// Increment counter
await redis.incr(`ratelimit:${userId}:${channel}`)
await redis.expire(
`ratelimit:${userId}:${channel}`,
limit.windowMinutes * 60
)
return true // Allowed
}Category-Based Limits
Different notification types warrant different limits:
const categoryLimits = {
// Critical: No limits
'password-reset': { maxPerDay: Infinity, canBypassDND: true },
'security-alert': { maxPerDay: Infinity, canBypassDND: true },
// Transactional: Generous limits
'order-confirmation': { maxPerDay: 10, canBypassDND: false },
'shipping-update': { maxPerDay: 5, canBypassDND: false },
// Product: Moderate limits
'feature-announcement': { maxPerDay: 1, canBypassDND: false },
'tips-and-tricks': { maxPerDay: 1, canBypassDND: false },
// Marketing: Strict limits
'promotional': { maxPerDay: 1, canBypassDND: false },
'newsletter': { maxPerWeek: 2, canBypassDND: false }
}
async function shouldSend(
userId: string,
category: string
): Promise<{ allowed: boolean; reason?: string }> {
const limit = categoryLimits[category]
if (!limit) {
return { allowed: true }
}
const sent = await getNotificationCount(userId, category, '24h')
if (sent >= limit.maxPerDay) {
return {
allowed: false,
reason: `Rate limit exceeded: ${sent}/${limit.maxPerDay} per day`
}
}
return { allowed: true }
}Smart Batching and Digests
Batching Similar Notifications
Instead of sending 10 separate notifications, batch them:
interface PendingNotification {
userId: string
type: string
data: Record<string, any>
createdAt: Date
}
class NotificationBatcher {
private pending: Map<string, PendingNotification[]> = new Map()
private batchWindow = 5 * 60 * 1000 // 5 minutes
async add(notification: PendingNotification) {
const key = `${notification.userId}:${notification.type}`
if (!this.pending.has(key)) {
this.pending.set(key, [])
// Schedule batch send
setTimeout(() => this.flush(key), this.batchWindow)
}
this.pending.get(key)!.push(notification)
}
async flush(key: string) {
const notifications = this.pending.get(key)
if (!notifications || notifications.length === 0) return
this.pending.delete(key)
if (notifications.length === 1) {
// Single notification, send normally
await this.sendSingle(notifications[0])
} else {
// Multiple notifications, send batched
await this.sendBatched(notifications)
}
}
private async sendBatched(notifications: PendingNotification[]) {
const userId = notifications[0].userId
const type = notifications[0].type
await notigrid.notify({
channelId: `${type}-batch`,
to: userId,
variables: {
count: notifications.length,
items: notifications.map(n => n.data),
summary: this.generateSummary(notifications)
}
})
}
private generateSummary(notifications: PendingNotification[]): string {
// "3 new comments on your post"
// "5 people liked your photo"
return `${notifications.length} new ${notifications[0].type}`
}
}
// Usage
const batcher = new NotificationBatcher()
// These will be batched together
await batcher.add({ userId: 'user_123', type: 'comment', data: { ... } })
await batcher.add({ userId: 'user_123', type: 'comment', data: { ... } })
await batcher.add({ userId: 'user_123', type: 'comment', data: { ... } })
// After 5 minutes: "3 new comments on your post"Daily/Weekly Digests
Aggregate low-priority notifications into periodic digests:
interface DigestConfig {
schedule: 'daily' | 'weekly'
time: string // "09:00" in user's timezone
categories: string[]
}
async function scheduleDigest(userId: string, config: DigestConfig) {
const notifications = await db.pendingDigest.find({
userId,
category: { $in: config.categories },
createdAt: { $gte: getDigestStart(config.schedule) }
})
if (notifications.length === 0) return
await notigrid.notify({
channelId: 'daily-digest',
to: userId,
scheduledFor: getNextDigestTime(config),
variables: {
period: config.schedule === 'daily' ? 'today' : 'this week',
totalCount: notifications.length,
byCategory: groupByCategory(notifications),
highlights: getTopNotifications(notifications, 5)
}
})
// Mark as digested
await db.pendingDigest.updateMany(
{ _id: { $in: notifications.map(n => n._id) } },
{ digested: true }
)
}
// Email template for digest
const digestTemplate = `
Subject: Your [period] update - [totalCount] things happened
<h1>Here is what happened [period]</h1>
[#each byCategory]
<h2>[category] ([count])</h2>
<ul>
[#each items]
<li>[title] - [description]</li>
[/each]
</ul>
[/each]
<p><a href="[dashboardUrl]">View all activity</a></p>
`User Preference Management
Preference Schema
Let users control their notification experience:
interface NotificationPreferences {
// Global settings
pauseAll: boolean
pauseUntil?: Date
// Channel preferences
channels: {
email: boolean
push: boolean
sms: boolean
slack: boolean
}
// Category preferences
categories: {
[category: string]: {
enabled: boolean
channels: string[] // Which channels for this category
frequency: 'instant' | 'digest' | 'weekly'
}
}
// Time preferences
quietHours: {
enabled: boolean
start: string // "22:00"
end: string // "08:00"
timezone: string
allowCritical: boolean
}
}
// Example preferences
const userPrefs: NotificationPreferences = {
pauseAll: false,
channels: {
email: true,
push: true,
sms: false, // User opted out of SMS
slack: true
},
categories: {
'order-updates': {
enabled: true,
channels: ['email', 'push'],
frequency: 'instant'
},
'marketing': {
enabled: true,
channels: ['email'],
frequency: 'weekly' // Weekly digest only
},
'social': {
enabled: true,
channels: ['push'],
frequency: 'digest' // Daily digest
}
},
quietHours: {
enabled: true,
start: '22:00',
end: '08:00',
timezone: 'America/New_York',
allowCritical: true
}
}Preference-Based Routing
Route notifications based on user preferences:
async function routeNotification(
userId: string,
category: string,
notification: NotificationPayload
): Promise<void> {
const prefs = await getUserPreferences(userId)
// Check global pause
if (prefs.pauseAll) {
if (prefs.pauseUntil && new Date() < prefs.pauseUntil) {
await queueForLater(userId, notification, prefs.pauseUntil)
return
}
}
// Get category preferences
const categoryPrefs = prefs.categories[category]
if (!categoryPrefs?.enabled) {
return // User disabled this category
}
// Check quiet hours
if (prefs.quietHours.enabled && isQuietHours(prefs.quietHours)) {
if (!notification.critical || !prefs.quietHours.allowCritical) {
await queueForLater(userId, notification, getQuietHoursEnd(prefs.quietHours))
return
}
}
// Route based on frequency preference
switch (categoryPrefs.frequency) {
case 'instant':
await sendImmediate(userId, notification, categoryPrefs.channels)
break
case 'digest':
await addToDigest(userId, notification, 'daily')
break
case 'weekly':
await addToDigest(userId, notification, 'weekly')
break
}
}
async function sendImmediate(
userId: string,
notification: NotificationPayload,
channels: string[]
): Promise<void> {
const prefs = await getUserPreferences(userId)
// Filter to enabled channels only
const enabledChannels = channels.filter(c => prefs.channels[c])
for (const channel of enabledChannels) {
await notigrid.notify({
channelId: `${notification.type}-${channel}`,
to: userId,
variables: notification.variables
})
}
}Quiet Hours and DND Mode
Implementing Quiet Hours
function isQuietHours(config: QuietHoursConfig): boolean {
const now = new Date()
const userTime = new Date(
now.toLocaleString('en-US', { timeZone: config.timezone })
)
const currentHour = userTime.getHours()
const currentMinute = userTime.getMinutes()
const currentTime = currentHour * 60 + currentMinute
const [startHour, startMinute] = config.start.split(':').map(Number)
const [endHour, endMinute] = config.end.split(':').map(Number)
const startTime = startHour * 60 + startMinute
const endTime = endHour * 60 + endMinute
// Handle overnight quiet hours (e.g., 22:00 to 08:00)
if (startTime > endTime) {
return currentTime >= startTime || currentTime < endTime
}
return currentTime >= startTime && currentTime < endTime
}
function getQuietHoursEnd(config: QuietHoursConfig): Date {
const now = new Date()
const [endHour, endMinute] = config.end.split(':').map(Number)
const end = new Date(now)
end.setHours(endHour, endMinute, 0, 0)
// If end time is earlier than now, it's tomorrow
if (end <= now) {
end.setDate(end.getDate() + 1)
}
return end
}Temporary Mute
Allow users to temporarily pause notifications:
async function muteNotifications(
userId: string,
duration: 'hour' | 'day' | 'week' | 'custom',
customUntil?: Date
): Promise<void> {
const durations = {
'hour': 60 * 60 * 1000,
'day': 24 * 60 * 60 * 1000,
'week': 7 * 24 * 60 * 60 * 1000
}
const pauseUntil = duration === 'custom'
? customUntil
: new Date(Date.now() + durations[duration])
await db.preferences.update(userId, {
pauseAll: true,
pauseUntil
})
// Schedule unmute
await scheduler.schedule({
type: 'unmute-notifications',
userId,
runAt: pauseUntil
})
}
async function handleUnmute(userId: string): Promise<void> {
await db.preferences.update(userId, {
pauseAll: false,
pauseUntil: null
})
// Send queued notifications
const queued = await db.queuedNotifications.find({ userId })
for (const notification of queued) {
await routeNotification(userId, notification.category, notification.payload)
}
await db.queuedNotifications.deleteMany({ userId })
}Priority-Based Delivery
Notification Priority Levels
enum NotificationPriority {
CRITICAL = 1, // Security alerts, password resets
HIGH = 2, // Order confirmations, payments
NORMAL = 3, // General updates
LOW = 4 // Marketing, newsletters
}
interface PrioritizedNotification {
priority: NotificationPriority
payload: NotificationPayload
canBypassLimits: boolean
canBypassQuietHours: boolean
}
const priorityConfig: Record<string, PrioritizedNotification> = {
'password-reset': {
priority: NotificationPriority.CRITICAL,
canBypassLimits: true,
canBypassQuietHours: true
},
'security-alert': {
priority: NotificationPriority.CRITICAL,
canBypassLimits: true,
canBypassQuietHours: true
},
'order-confirmation': {
priority: NotificationPriority.HIGH,
canBypassLimits: false,
canBypassQuietHours: false
},
'feature-update': {
priority: NotificationPriority.LOW,
canBypassLimits: false,
canBypassQuietHours: false
}
}Priority Queue Processing
class PriorityNotificationQueue {
private queues: Map<NotificationPriority, NotificationPayload[]> = new Map()
async add(notification: PrioritizedNotification) {
const queue = this.queues.get(notification.priority) || []
queue.push(notification.payload)
this.queues.set(notification.priority, queue)
}
async process() {
// Process in priority order
const priorities = [
NotificationPriority.CRITICAL,
NotificationPriority.HIGH,
NotificationPriority.NORMAL,
NotificationPriority.LOW
]
for (const priority of priorities) {
const queue = this.queues.get(priority) || []
for (const notification of queue) {
await this.send(notification)
}
this.queues.set(priority, [])
}
}
}Measuring Effectiveness
Key Metrics to Track
interface NotificationMetrics {
// Engagement
deliveryRate: number // Delivered / Sent
openRate: number // Opened / Delivered
clickRate: number // Clicked / Opened
conversionRate: number // Converted / Clicked
// Health
optOutRate: number // Unsubscribed / Total
bounceRate: number // Bounced / Sent
complaintRate: number // Marked spam / Sent
// User sentiment
nps: number // Net Promoter Score for notifications
}
async function calculateMetrics(
period: string
): Promise<NotificationMetrics> {
const stats = await notigrid.stats.get({ period })
return {
deliveryRate: stats.delivered / stats.sent,
openRate: stats.opened / stats.delivered,
clickRate: stats.clicked / stats.opened,
conversionRate: stats.converted / stats.clicked,
optOutRate: stats.optedOut / stats.totalRecipients,
bounceRate: stats.bounced / stats.sent,
complaintRate: stats.complained / stats.sent,
nps: await calculateNotificationNPS()
}
}A/B Testing Frequency
async function testNotificationFrequency(
userId: string,
category: string
): Promise<'control' | 'variant'> {
// 50/50 split
const variant = userId.charCodeAt(0) % 2 === 0 ? 'control' : 'variant'
const frequencies = {
control: 'instant', // Send immediately
variant: 'digest' // Daily digest
}
await trackExperiment({
userId,
experiment: 'notification-frequency',
variant,
category
})
return variant
}Frequently Asked Questions
How many notifications per day is too many?
Research suggests 3-5 push notifications per day is the maximum before users start disabling notifications. For email, 1-2 per day maximum for non-transactional content. SMS should be reserved for critical messages only (1-2 per week).
Should critical notifications bypass rate limits?
Yes, security alerts and password resets should always reach users immediately. Implement a canBypassLimits flag for critical notification types.
How do I handle users in different time zones?
Always store quiet hours in the user's local timezone. Use libraries like Luxon or date-fns-tz for accurate timezone handling.
What is the best digest frequency?
It depends on your product. For social apps, daily digests work well. For B2B SaaS, weekly digests are often preferred. Let users choose their preferred frequency.
How do I re-engage users who opted out?
Respect their choice. For email, you might send a single "We miss you" message after 30-60 days. For push notifications, prompt users to re-enable in-app after they've been active for a while.
Summary
Effective notification rate limiting requires:
- Frequency Capping - Limit per-user, per-channel, per-category
- Smart Batching - Combine similar notifications
- Digest Options - Aggregate low-priority updates
- Preference Management - Let users control their experience
- Quiet Hours - Respect when users don't want interruptions
- Priority Levels - Critical notifications should always reach users
The goal is fewer, more impactful notifications that users engage with rather than ignore or disable.
Next Steps
- Email vs Slack vs SMS Guide - Choose the right channel
- Notification Monitoring Guide - Track your delivery rates
- Template Guide - Create effective notification content
- Getting Started with NotiGrid - Set up your first notification
Need Help?
Email Support: support@notigrid.com Documentation: docs.notigrid.com Schedule a Demo: notigrid.com/demo
Ready to send your first notification?
Get started with NotiGrid today and send notifications across email, SMS, Slack, and more.