Skip to content
Unverified — AI-generated content. Help verify this page

Notification Rate Limiting

Why Notification Rate Limiting Is Different

API rate limiting protects your infrastructure from being overloaded. Notification rate limiting protects your users from being overwhelmed — and protects your deliverability from being destroyed.

The costs of over-notifying:

  • User churn: Users unsubscribe or disable notifications entirely
  • Spam complaints: Gmail/Outlook users marking email as spam destroys your sender reputation
  • App uninstalls: Too many push notifications = app deleted
  • Legal risk: Excessive SMS in regulated jurisdictions = TCPA exposure

Rate Limiting Dimensions

Notification rate limiting operates across multiple dimensions simultaneously:

Implementation: Sliding Window Rate Limiter

Uses Redis sorted sets for precise sliding window counts:

typescript
import Redis from 'ioredis';

export interface RateLimitRule {
  maxCount: number;
  windowSeconds: number;
}

export interface RateLimitCheck {
  allowed: boolean;
  remaining: number;
  resetAt: Date;
  rule: string;
}

export class NotificationRateLimiter {
  constructor(private readonly redis: Redis) {}

  // Check and consume from a sliding window counter
  async checkAndConsume(params: {
    userId: string;
    channel: NotificationChannel;
    category: NotificationCategory;
  }): Promise<RateLimitCheck> {
    const rules = this.getRulesForCategory(params.category, params.channel);

    // Check all rules — reject if ANY rule is exceeded
    for (const [ruleName, rule] of Object.entries(rules)) {
      const key = this.buildKey(params.userId, params.channel, params.category, ruleName);
      const result = await this.slidingWindowCheck(key, rule);

      if (!result.allowed) {
        return { ...result, rule: ruleName };
      }
    }

    // All rules passed — consume a token from all
    for (const [ruleName, rule] of Object.entries(rules)) {
      const key = this.buildKey(params.userId, params.channel, params.category, ruleName);
      await this.slidingWindowConsume(key, rule);
    }

    const mostRestrictiveRule = Object.values(rules)[0];
    const key = this.buildKey(
      params.userId, params.channel, params.category,
      Object.keys(rules)[0]
    );
    const status = await this.getStatus(key, mostRestrictiveRule);

    return { allowed: true, remaining: status.remaining, resetAt: status.resetAt, rule: 'all' };
  }

  private async slidingWindowCheck(
    key: string,
    rule: RateLimitRule
  ): Promise<{ allowed: boolean; remaining: number; resetAt: Date }> {
    const now = Date.now();
    const windowStart = now - rule.windowSeconds * 1000;

    // Count events in the current window
    const count = await this.redis.zcount(key, windowStart, '+inf');

    const remaining = Math.max(0, rule.maxCount - count);
    const resetAt = new Date(now + rule.windowSeconds * 1000);

    return {
      allowed: count < rule.maxCount,
      remaining,
      resetAt,
    };
  }

  private async slidingWindowConsume(key: string, rule: RateLimitRule): Promise<void> {
    const now = Date.now();
    const windowStart = now - rule.windowSeconds * 1000;

    const pipeline = this.redis.pipeline();

    // Remove old entries outside the window
    pipeline.zremrangebyscore(key, '-inf', windowStart - 1);

    // Add current timestamp
    pipeline.zadd(key, now, `${now}-${Math.random()}`);

    // Set expiry to window size + buffer
    pipeline.expire(key, rule.windowSeconds + 60);

    await pipeline.exec();
  }

  private async getStatus(key: string, rule: RateLimitRule): Promise<{
    remaining: number;
    resetAt: Date;
  }> {
    const now = Date.now();
    const windowStart = now - rule.windowSeconds * 1000;
    const count = await this.redis.zcount(key, windowStart, '+inf');

    return {
      remaining: Math.max(0, rule.maxCount - count),
      resetAt: new Date(now + rule.windowSeconds * 1000),
    };
  }

  private buildKey(
    userId: string,
    channel: string,
    category: string,
    rule: string
  ): string {
    return `ratelimit:notif:${userId}:${channel}:${category}:${rule}`;
  }

  private getRulesForCategory(
    category: NotificationCategory,
    channel: NotificationChannel
  ): Record<string, RateLimitRule> {
    // Separate rate limits per category and channel
    const rules: Record<string, Record<string, Record<string, RateLimitRule>>> = {
      security: {
        email: {
          per_hour:  { maxCount: 10, windowSeconds: 3600 },
          per_day:   { maxCount: 50, windowSeconds: 86400 },
        },
        sms: {
          per_hour:  { maxCount: 5, windowSeconds: 3600 },
          per_day:   { maxCount: 20, windowSeconds: 86400 },
        },
        push: {
          per_hour:  { maxCount: 20, windowSeconds: 3600 },
          per_day:   { maxCount: 100, windowSeconds: 86400 },
        },
      },
      billing: {
        email: {
          per_day:   { maxCount: 3,  windowSeconds: 86400 },
          per_week:  { maxCount: 10, windowSeconds: 604800 },
        },
        sms: {
          per_day:   { maxCount: 2, windowSeconds: 86400 },
          per_week:  { maxCount: 5, windowSeconds: 604800 },
        },
      },
      marketing: {
        email: {
          per_day:   { maxCount: 2,  windowSeconds: 86400 },
          per_week:  { maxCount: 5,  windowSeconds: 604800 },
          per_month: { maxCount: 10, windowSeconds: 2592000 },
        },
        sms: {
          per_day:   { maxCount: 1, windowSeconds: 86400 },
          per_week:  { maxCount: 3, windowSeconds: 604800 },
          per_month: { maxCount: 8, windowSeconds: 2592000 },
        },
        push: {
          per_day:   { maxCount: 3,  windowSeconds: 86400 },
          per_week:  { maxCount: 10, windowSeconds: 604800 },
        },
      },
      product: {
        email: {
          per_day:  { maxCount: 5,  windowSeconds: 86400 },
          per_week: { maxCount: 15, windowSeconds: 604800 },
        },
        push: {
          per_hour: { maxCount: 5,  windowSeconds: 3600 },
          per_day:  { maxCount: 20, windowSeconds: 86400 },
        },
      },
    };

    return rules[category]?.[channel] ?? {
      per_day: { maxCount: 5, windowSeconds: 86400 },
    };
  }
}

Quiet Hours

typescript
export interface QuietHoursConfig {
  start: string;   // HH:MM in user's timezone (e.g., "22:00")
  end: string;     // HH:MM in user's timezone (e.g., "08:00")
}

export class QuietHoursService {
  constructor(
    private readonly preferenceRepo: NotificationPreferenceRepository
  ) {}

  async isInQuietHours(
    userId: string,
    channel: NotificationChannel
  ): Promise<boolean> {
    const prefs = await this.preferenceRepo.getByUserAndChannel(userId, channel);
    if (!prefs?.quietHoursStart || !prefs?.quietHoursEnd) return false;

    const timezone = prefs.timezone ?? 'UTC';
    const now = new Date();

    // Convert current time to user's timezone
    const userTime = new Date(
      now.toLocaleString('en-US', { timeZone: timezone })
    );

    const currentMinutes =
      userTime.getHours() * 60 + userTime.getMinutes();

    const [startHour, startMin] = prefs.quietHoursStart.split(':').map(Number);
    const [endHour, endMin] = prefs.quietHoursEnd.split(':').map(Number);

    const startMinutes = startHour * 60 + startMin;
    const endMinutes = endHour * 60 + endMin;

    // Handle overnight quiet hours (e.g., 22:00 - 08:00)
    if (startMinutes > endMinutes) {
      // Overnight: in quiet hours if after start OR before end
      return currentMinutes >= startMinutes || currentMinutes < endMinutes;
    } else {
      // Same-day: in quiet hours if between start and end
      return currentMinutes >= startMinutes && currentMinutes < endMinutes;
    }
  }

  async getNextAllowedTime(
    userId: string,
    channel: NotificationChannel
  ): Promise<Date> {
    const prefs = await this.preferenceRepo.getByUserAndChannel(userId, channel);
    if (!prefs?.quietHoursEnd) return new Date();

    const timezone = prefs.timezone ?? 'UTC';
    const [endHour, endMin] = prefs.quietHoursEnd.split(':').map(Number);

    // Find next occurrence of quiet hours end time in user's timezone
    const now = new Date();
    const userNow = new Date(now.toLocaleString('en-US', { timeZone: timezone }));

    const nextAllowed = new Date(userNow);
    nextAllowed.setHours(endHour, endMin, 0, 0);

    // If end time has already passed today, use tomorrow
    if (nextAllowed <= userNow) {
      nextAllowed.setDate(nextAllowed.getDate() + 1);
    }

    // Convert back to UTC
    const utcNextAllowed = new Date(
      nextAllowed.toLocaleString('en-US', { timeZone: 'UTC' })
    );

    return utcNextAllowed;
  }
}

Digest Batching

Instead of sending 10 separate "new comment" notifications, batch them into a single digest:

typescript
export interface DigestConfig {
  templateId: string;
  windowMinutes: number;    // How long to accumulate before sending
  maxItems: number;         // Max items in a single digest
  minItems: number;         // Don't send digest with fewer items (send individual instead)
}

export const DIGEST_CONFIGS: Record<string, DigestConfig> = {
  'new-comment': {
    templateId: 'activity-digest',
    windowMinutes: 60,
    maxItems: 50,
    minItems: 3,      // < 3 comments = send individually
  },
  'new-follower': {
    templateId: 'follower-digest',
    windowMinutes: 1440,  // Daily digest
    maxItems: 100,
    minItems: 5,
  },
};

export class DigestService {
  constructor(
    private readonly redis: Redis,
    private readonly notificationApi: NotificationApiClient,
    private readonly templateEngine: TemplateEngine
  ) {}

  async addToDigest(params: {
    userId: string;
    digestType: string;
    item: Record<string, unknown>;
  }): Promise<void> {
    const config = DIGEST_CONFIGS[params.digestType];
    if (!config) throw new Error(`Unknown digest type: ${params.digestType}`);

    const key = `digest:${params.digestType}:${params.userId}`;
    const now = Date.now();

    // Add item to sorted set (score = timestamp for ordering)
    await this.redis.zadd(key, now, JSON.stringify(params.item));

    // Set expiry for cleanup (window + buffer)
    await this.redis.expire(key, config.windowMinutes * 60 + 3600);

    // Count items in window
    const windowStart = now - config.windowMinutes * 60 * 1000;
    const count = await this.redis.zcount(key, windowStart, '+inf');

    if (count >= config.minItems) {
      // Schedule a digest job if not already scheduled
      await this.scheduleDigestIfNeeded(params.userId, params.digestType, config);
    }
  }

  async sendDigest(params: {
    userId: string;
    digestType: string;
  }): Promise<void> {
    const config = DIGEST_CONFIGS[params.digestType];
    const key = `digest:${params.digestType}:${params.userId}`;

    const now = Date.now();
    const windowStart = now - config.windowMinutes * 60 * 1000;

    // Get all items in the window
    const rawItems = await this.redis.zrangebyscore(key, windowStart, '+inf');
    const items = rawItems.map(r => JSON.parse(r));

    if (items.length < config.minItems) {
      // Not enough items for a digest — send individually
      for (const item of items) {
        await this.notificationApi.send({
          userId: params.userId,
          templateId: item.templateId,
          data: item,
          category: 'product',
          priority: 2,
        });
      }
    } else {
      // Send as digest
      await this.notificationApi.send({
        userId: params.userId,
        templateId: config.templateId,
        data: {
          items: items.slice(0, config.maxItems),
          totalCount: items.length,
          hasMore: items.length > config.maxItems,
        },
        category: 'product',
        priority: 2,
      });
    }

    // Remove sent items from the digest queue
    await this.redis.zremrangebyscore(key, windowStart, now);
  }

  private async scheduleDigestIfNeeded(
    userId: string,
    digestType: string,
    config: DigestConfig
  ): Promise<void> {
    const scheduledKey = `digest:scheduled:${digestType}:${userId}`;
    const alreadyScheduled = await this.redis.exists(scheduledKey);

    if (!alreadyScheduled) {
      const sendAt = Date.now() + config.windowMinutes * 60 * 1000;
      await this.redis.set(scheduledKey, '1', 'PX', config.windowMinutes * 60 * 1000);
      await digestQueue.add(
        'send-digest',
        { userId, digestType },
        { delay: config.windowMinutes * 60 * 1000 }
      );
    }
  }
}

Global System Rate Limits

Protect your sending infrastructure from quota violations:

typescript
export interface ProviderLimits {
  emailPerSecond: number;
  emailPerHour: number;
  smsPerSecond: number;
  pushPerSecond: number;
}

export const PROVIDER_LIMITS: ProviderLimits = {
  emailPerSecond: 100,    // SES default: 14 per second; increase via AWS support
  emailPerHour: 50_000,   // SES default limit
  smsPerSecond: 10,       // Twilio: varies by account
  pushPerSecond: 500,     // FCM: very high, use batch sends
};

export class GlobalRateLimiter {
  constructor(private readonly redis: Redis) {}

  async checkEmailQuota(): Promise<boolean> {
    return this.tokenBucketCheck('global:email:per_second', PROVIDER_LIMITS.emailPerSecond, 1);
  }

  async checkSmsQuota(): Promise<boolean> {
    return this.tokenBucketCheck('global:sms:per_second', PROVIDER_LIMITS.smsPerSecond, 1);
  }

  // Token bucket algorithm using Redis atomic operations
  private async tokenBucketCheck(
    key: string,
    capacity: number,
    refillPerSecond: number
  ): Promise<boolean> {
    const now = Date.now() / 1000;  // Seconds

    // Lua script for atomic token bucket
    const script = `
      local key = KEYS[1]
      local capacity = tonumber(ARGV[1])
      local refill_rate = tonumber(ARGV[2])
      local now = tonumber(ARGV[3])
      local requested = tonumber(ARGV[4])

      local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
      local tokens = tonumber(bucket[1]) or capacity
      local last_refill = tonumber(bucket[2]) or now

      -- Refill based on elapsed time
      local elapsed = now - last_refill
      tokens = math.min(capacity, tokens + elapsed * refill_rate)

      if tokens >= requested then
        tokens = tokens - requested
        redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
        redis.call('EXPIRE', key, 60)
        return 1
      else
        redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
        redis.call('EXPIRE', key, 60)
        return 0
      end
    `;

    const result = await this.redis.eval(
      script, 1, key, capacity, refillPerSecond, now, 1
    );

    return result === 1;
  }
}

Frequency Deduplication

Prevent sending the exact same notification twice in a short window:

typescript
export class NotificationDeduplicator {
  constructor(
    private readonly redis: Redis,
    private readonly deduplicationWindowSeconds = 3600  // 1 hour
  ) {}

  async isDuplicate(params: {
    userId: string;
    templateId: string;
    contentHash: string;  // Hash of rendered content
  }): Promise<boolean> {
    const key = `dedup:notif:${params.userId}:${params.templateId}:${params.contentHash}`;
    const exists = await this.redis.exists(key);
    return exists === 1;
  }

  async markSent(params: {
    userId: string;
    templateId: string;
    contentHash: string;
  }): Promise<void> {
    const key = `dedup:notif:${params.userId}:${params.templateId}:${params.contentHash}`;
    await this.redis.set(key, '1', 'EX', this.deduplicationWindowSeconds);
  }

  // Generate a stable content hash from notification data
  static hashContent(templateId: string, data: Record<string, unknown>): string {
    const crypto = require('crypto');
    const content = JSON.stringify({ templateId, ...data }, Object.keys({ templateId, ...data }).sort());
    return crypto.createHash('sha256').update(content).digest('hex').substring(0, 16);
  }
}

Mathematical Analysis

Email Deliverability

Google starts filtering to spam when complaint rate exceeds 0.1%:

P(spam filter)={lowif rc<0.001mediumif 0.001rc<0.002highif rc0.002

where rc = complaint rate = (complaints) / (sent emails).

To maintain rc<0.001 with 1 complaint per 1000 emails requires filtering every user who is likely to complain. The rate limiter prevents over-sending which drives complaints.

Optimal Notification Frequency

The optimal frequency f balances engagement against churn:

Value(f)=engagement(f)churn(f)

Empirically, engagement follows a log curve while churn follows an exponential:

engagement(f)αln(1+f)churn(f)βeγf

This gives optimal f by solving ddfValue(f)=0, which yields approximately 1-3 marketing notifications per week for typical B2B SaaS.

War Story

We launched a real-time activity feed that triggered a push notification for every activity event. Popular users with 10k followers would generate 10k push notifications per activity.

User A posts content → 10,000 followers receive a push notification. User A posts again 30 seconds later → 10,000 more push notifications.

We had an active creator who posted 47 times in one day. Their followers received 47 push notifications. Open rate: 2% (normal is 20%). Uninstall rate for that day: 8x normal.

The fix: digest batching for follow activity with a 4-hour window, and a hard cap of 3 activity notifications per day per creator per follower. Engagement improved because users trusted the notification to be meaningful.

"What I cannot create, I do not understand." — Richard Feynman