DEV Community

Timevolt
Timevolt

Posted on

Rate Limiting Like a Jedi: Building a Real‑Time Notification System at Scale

The Quest Begins (The "Why")

I still remember the night our notification service went from “quiet hum” to “screaming alarm”. We’d just launched a new feature that let users get instant push alerts for every comment on their posts. Within minutes, our downstream SMS gateway started returning 429s, our email provider throttled us, and the ops team was paging at 3 a.m. because the queue depth had exploded to millions of pending jobs.

It felt like we’d awakened a dragon and had no shield. The core problem wasn’t that we couldn’t send notifications—it was that we had no way to pace the flood of events hitting our workers. Without a traffic‑cop, any burst of user activity would overwhelm downstream services, cause dropped messages, and erode trust with our users.

We needed a guard that could let normal traffic flow smoothly while instantly reining in the surge when things got hot. Enter the rate limiter—not just any limiter, but one that could adapt to the current load on its consumers.

The Revelation (The Insight)

The breakthrough came when we stopped thinking of a rate limiter as a static ceiling and started viewing it as a feedback‑driven valve. Imagine a leaky bucket: water (incoming notification events) drips in at the top, leaks out at a steady rate (the bucket’s outflow), and any excess simply overflows and is discarded or buffered.

Our twist? The leak rate isn’t fixed. We monitor the depth of the internal work queue (or the lag of our consumer group) and adjust the leak speed in real time:

  • If the queue is shallow, we open the valve wider—allowing more events per second.
  • If the queue starts to fill, we tighten the valve—slowing the inflow to give workers a chance to catch up.

This creates a self‑balancing system that never starves the workers nor floods the downstream services. The key insight is simple yet powerful: backpressure from the consumer side should drive the producer’s throttling rate.

Here’s an ASCII sketch of the flow:

+----------------+        +---------------------+        +------------------+
|   Event Source | --->   |  Adaptive Rate      | --->   |  Worker Pool     |
| (webhook, DB)  |        |  Limiter (leaky)    |        | (notification)   |
+----------------+        +---------------------+        +------------------+
                                 ^   |
                                 |   v
                           +---------------+
                           |  Queue Depth  |
                           |  Monitor      |
                           +---------------+
Enter fullscreen mode Exit fullscreen mode
  • Event Source pushes raw notification requests into the limiter.
  • The Adaptive Rate Limiter decides, based on the current queue depth, how many tokens to grant per second.
  • Approved events flow to the Worker Pool which actually sends emails, SMS, push, etc.
  • The Queue Depth Monitor feeds the lag back to the limiter, closing the loop.

Why does this beat a fixed‑limit limiter or “no limiter at all”?

Approach Pros Cons
No limiter Simplest code downstream overload, message loss, alert fatigue
Fixed token bucket Easy to reason about either too restrictive (under‑utilizes capacity) or too lax (still overwhelms)
Adaptive leaky Self‑tuning, handles burst & lull Slightly more moving parts, needs metrics collection

The adaptive version gives us the best of both worlds: we absorb traffic spikes without hurting users, and we stay responsive when traffic dies down.

Wielding the Power (Code & Examples)

Let’s look at a concrete implementation in Python (the ideas translate 1‑to‑1 to Node, Go, or Java).

The Struggle: Naïve fire‑and‑forget

# Before: every incoming event triggers a send straight away
def handle_event(event):
    send_notification(event)   # blocks until the provider replies
Enter fullscreen mode Exit fullscreen mode

When a burst of 100 k events hits, we open 100 k concurrent connections to the SMS gateway—guaranteed throttling and failed deliveries.

The Victory: Adaptive Leaky Bucket

import time
import threading
from collections import deque

class AdaptiveLeakyBucket:
    def __init__(self, base_rate=100, max_rate=2000, min_rate=20,
                 queue_monitor=None, adjustment_interval=1.0):
        """
        base_rate  – tokens added per second when queue is empty
        max_rate   – hard ceiling we never exceed
        min_rate   – floor to avoid starving workers
        queue_monitor – callable returning current queue depth (int)
        """
        self.rate = base_rate
        self.base_rate = base_rate
        self.max_rate = max_rate
        self.min_rate = min_rate
        self.queue_monitor = queue_monitor or (lambda: 0)
        self.tokens = base_rate          # start with a full bucket
        self.timestamp = time.monotonic()
        self.lock = threading.Lock()
        self._start_adjuster(adjustment_interval)

    def _start_adjuster(self, interval):
        def adjust():
            while True:
                time.sleep(interval)
                depth = self.queue_monitor()
                # Simple proportional controller: more depth → slower rate
                if depth > 10000:
                    self.rate = max(self.min_rate, self.rate * 0.8)
                elif depth < 1000:
                    self.rate = min(self.max_rate, self.rate * 1.2)
                # Clamp to bounds
                self.rate = max(self.min_rate, min(self.max_rate, self.rate))
        threading.Thread(target=adjust, daemon=True).start()

    def allow(self):
        now = time.monotonic()
        with self.lock:
            elapsed = now - self.timestamp
            self.tokens += elapsed * self.rate
            self.timestamp = now
            # Cap tokens to max_rate to avoid unbounded burst
            if self.tokens > self.max_rate:
                self.tokens = self.max_rate
            if self.tokens >= 1.0:
                self.tokens -= 1.0
                return True
            return False
Enter fullscreen mode Exit fullscreen mode

How it works

  1. Token replenishment happens continuously based on the current self.rate.
  2. The adjuster thread runs every second, reads the queue depth via queue_monitor, and nudges the rate up or down using a simple proportional rule. Feel free to swap in a PID controller if you need finer control.
  3. allow() returns True when a token is available, letting the caller proceed; otherwise the event is dropped or buffered for later retry.

Wiring it up

from queue import Queue

# Shared queue that workers pull from
work_q = Queue()

def queue_depth():
    return work_q.qsize()

limiter = AdaptiveLeakyBucket(base_rate=150,
                              max_rate=3000,
                              min_rate=30,
                              queue_monitor=queue_depth)

def event_handler(event):
    if limiter.allow():
        work_q.put(event)          # hand off to workers
    else:
        # Optional: store in a dead‑letter queue or emit a metric
        metrics.increment("rate_limited_dropped")

def worker():
    while True:
        event = work_q.get()
        try:
            send_notification(event)
        finally:
            work_q.task_done()
Enter fullscreen mode Exit fullscreen mode

A few traps I fell into the first time around:

  • Forgetting to re‑acquire the lock around token updates → race conditions where tokens could go negative, causing bursts.
  • Setting the adjustment interval too long (e.g., 10 s) → the limiter reacted sluggishly to sudden spikes, still overwhelming downstream.
  • Using the raw queue depth without smoothing → jittery rate changes that made the system feel “ twitchy”. A short moving average or exponential decay fixed that.

Why This New Power Matters

With the adaptive leaky bucket in place, our notification service now behaves like a well‑tuned engine:

  • Burst absorption – a flash mob of users posting comments results in a smooth, throttled flow rather than a hammer‑like spike.
  • Self‑healing – if a downstream provider slows down (say, the SMS gateway experiences latency), the queue depth rises, the limiter auto‑throttles, and we back off until the provider recovers.
  • Observability – exporting current_rate, tokens, and queue_depth as metrics gives us an instant health dashboard; we can spot a misbehaving worker before users notice.
  • Cost efficiency – we never over‑provision workers just to survive peaks; the rate limiter lets us run with a smaller, steady pool and still meet SLAs.

In short, we turned a reactive “oh‑no‑we’re‑overloaded” scramble into a proactive, self‑regulating system that scales with the load, not against it.

Your Turn

Grab a queue, a simple token bucket, and a feedback loop. Try it out in your own service—maybe for webhook deliveries, image‑processing pipelines, or even chat‑room message fan‑out. Tweak the adjustment algorithm, watch the metrics, and share what you learned.

What’s the biggest surprise you hit when you let the system self‑throttle? Drop a comment below; I’d love to hear your war stories and celebrate the wins together!

Top comments (0)