Chuyển đến nội dung chính

Lesson 10: Rate Limiting & Throttling — Control sending speed

Why is rate limiting needed for email system. Token bucket, sliding window, leaky bucket. Multi-level throttling by provider, domain, IP. Adaptive rate limiting is based on bounce rate. Redis-based distributed limiter.

🏗️ Architecture — Lesson 10 Lesson 10: Rate Limiting & Throttling — Check Control sending speed

Design a Notification System to send millions of Emails

Part 4: Handling scale — Scaling to Millions

xdev.asia

Introduction

A system that can render and enqueue millions of emails may not be able to send them safely. The real bottleneck often lies in the valid sending rate: limits from ESP, domain/IP reputation, and absorption capacity of downstream systems.

This article focuses on the traffic regulation layer, which determines whether a 10 million email campaign will be successful or push the entire domain into the spam folder.


1. Why is rate limiting required?

Practical limits

Limited SourceExampleConsequences if the threshold is exceeded
ESP send quotaSES 200 emails/sec defaultThrottlingException, queue backlog
Domain reputationNew domain suddenly sent 1M emailsSpam placement increased sharply
IP reputationDedicated IP not yet warm-upBounce and complaints increase
ISP limitsGmail, Yahoo throttling by domainDeferred delivery, soft bounce
Internal systemsToo many workers, DB updates too thickHigh CPU, lock contention

The goal of the throttling layer

  • Protect the sender's reputation.
  • Protect email providers from traffic burst.
  • Prioritize transactional emails over email marketing.
  • Maintain stable throughput instead of short spikes then crashing.
  • Keep queue backlog under control.

2. Popular algorithms

Quick comparison

AlgorithmStrengthsWeaknessSuitable for
Fixed WindowEasy to implementBurst at window boundarySimple Counter
Sliding WindowMore accurateConsumes more statePer-domain limits
Leaky BucketOutput is equalSomewhat stiff with valid traffic burstsSmoothing traffic
Token BucketAllows controlled burstsNeed to synchronize stateESP/API throttling

Recommendations for email systems

  • Use token bucket to limit by provider and by IP pool.
  • Use sliding window for complaint rate, bounce rate, open rate by campaign/domain.
  • Use priority-aware scheduler to prevent important emails from being consumed by marketing.

3. Multi-level throttling architecture

Campaign Queue
    │
    ▼
Priority Scheduler
    │
    ├── Check provider quota (SES / SendGrid / Mailgun)
    ├── Check domain quota (gmail.com / yahoo.com / outlook.com)
    ├── Check IP pool quota
    ├── Check campaign quota
    └── Check suppression / reputation guard
    │
    ▼
Dispatch Queue
    │
    ▼
Workers -> ESP

Reasonable test order

  1. Check suppression list and bounce history.
  2. Check the priority class of the message.
  3. Check quota by provider.
  4. Check quota by recipient domain.
  5. Check quota according to sender IP / dedicated IP pool.
  6. If it fails, reschedule instead of dropping.

Priority policy example

PrioritiesUse casesSLARules
criticalOTP, reset password< 30sThere is always reserved capacity
highOrder confirmation< 2 minutesNot blocked by marketing
normalProduct updates< 15 minutesDynamic quota sharing
lowNewsletter, drip campaignAccording to scheduleCut off before bad reputation

4. Distributed rate limiter with Redis

Token bucket model

import time
import redis

class RedisTokenBucket:
    def __init__(self, client: redis.Redis, key: str, rate: int, burst: int):
        self.client = client
        self.key = key
        self.rate = rate
        self.burst = burst

    def allow(self, tokens: int = 1) -> bool:
        now_ms = int(time.time() * 1000)
        script = """
        local key = KEYS[1]
        local now_ms = tonumber(ARGV[1])
        local rate = tonumber(ARGV[2])
        local burst = tonumber(ARGV[3])
        local requested = tonumber(ARGV[4])

        local data = redis.call('HMGET', key, 'tokens', 'updated_at')
        local tokens = tonumber(data[1])
        local updated_at = tonumber(data[2])

        if tokens == nil then
          tokens = burst
          updated_at = now_ms
        end

        local elapsed = math.max(0, now_ms - updated_at)
        local refill = (elapsed / 1000.0) * rate
        tokens = math.min(burst, tokens + refill)

        local allowed = 0
        if tokens >= requested then
          tokens = tokens - requested
          allowed = 1
        end

        redis.call('HMSET', key, 'tokens', tokens, 'updated_at', now_ms)
        redis.call('PEXPIRE', key, 60000)
        return allowed
        """

        result = self.client.eval(script, 1, self.key, now_ms, self.rate, self.burst, tokens)
        return result == 1

Keys to follow

rate:provider:ses
rate:provider:sendgrid
rate:domain:gmail.com
rate:domain:yahoo.com
rate:ip-pool:warm-01
rate:campaign:camp_flash_sale_april

The important point is that the limiter must be shared between all workers, otherwise each worker will think they have a quota and the system will immediately exceed the limit.


5. Adaptive throttling based on signal deliverability

Sending speed should not be fixed. It must respond to signals from reality.

Signals to monitor

SignalMeaningAction
Soft bounce increasesISP is defer traffic20-40% off send rate
Complaint rate increasedContent/list quality is badStrong reduction, pause campaign
Open rate decreased abnormallySpam placement increasesReduce speed, change IP/domain mix
Queue delay is too highThe system is missing workers/quotaIncrease workers or extend ETA

Policy engine example

def compute_send_rate(base_rate, metrics):
    rate = base_rate

    if metrics.soft_bounce_rate > 0.03:
        rate *= 0.7

    if metrics.complaint_rate > 0.001:
        rate *= 0.5

    if metrics.provider_throttling_rate > 0.05:
        rate *= 0.8

    if metrics.critical_queue_depth > 1000:
        rate = max(rate, metrics.reserve_for_critical)

    return max(int(rate), 10)

Principles

  • Marketing traffic is the first place to sacrifice.
  • Transactional traffic should have a fixed quota reserve.
  • Do not reduce quota to 0 immediately unless the complaint spike is very high or ESP requires a pause.

6. IP warming and domain warm-up

A common mistake is a new domain/IP but sending a full load on the first day.

Sample warm-up roadmap for new dedicated IP

DateMaximum emails/dayObject
15,000Users are highly engaged
210,000Segment clean, recently active
320,000Slight expansion
440,000Start mixed traffic
580,000Keep up with complaints/bounce
6160,000Continue scaling
7+According to reputationOnly increase if metrics are okay

Guardrails during warm-up

  • Only send to recipients who have clearly opted in.
  • Prioritize segments with good open/click rates.
  • Do not reuse old low quality lists.
  • Monitor Gmail Postmaster Tools and Microsoft SNDS if available.

7. Common failure modes

When the limiter design is wrong

  1. Local limiter per worker: total throughput exceeds quota even though each worker is "correct".
  2. No reserve capacity: OTP is slow because the marketing campaign is consuming all the tokens.
  3. Throttle according to provider but forgot domain: ESP still receives, but Gmail starts defer.
  4. Infinite retry: throttle is amplified due to retry storm.

Checklist production

  • There are quotas by provider, domain, IP, campaign.
  • Has reserve for critical traffic.
  • There is a manual pause campaign mechanism.
  • Has adaptive throttling based on feedback loop.
  • There is a dashboard that displays the current send rate and remaining quota.

Summary

Rate limiting is not just a technical problem but a vital layer of protection for deliverability. A good system must know how to send quickly when allowed, slow down when there is a bad signal, and always prioritize the most important traffic.

Next article: We will organize batch processing and worker pool to handle millions of recipients effectively without running out of memory or clogging the database.