Webhook Rate Limiting — Complete Guide
In this tutorial, you will learn about Webhook Rate Limiting. We cover key concepts, practical examples, and best practices to help you master this topic.
Learn webhook rate limiting: implement rate limits for webhook delivery, protect consumers from overload, design fair queuing, handle backpressure, and configure per-subscriber rate limits.
What You Learn
You will learn how to implement rate limiting for webhook delivery: per-subscriber rate limits, Sliding Window algorithms, queue management when limits are exceeded, backpressure handling, and consumer protection strategies.
Why It Matters
Without rate limiting, a single subscriber can be overwhelmed by webhook bursts, causing processing failures and retry storms. Rate limiting protects consumers, ensures fair resource allocation across subscribers, and maintains system stability during traffic spikes.
Real-World Use
DodaTech's webhook delivery system rate-limits each subscriber to 100 Webhooks per minute. During a security incident, 5000 threat alerts fired in 10 seconds. Rate limiting queued them and delivered at 100/minute, preventing consumer overload. Without rate limiting, the consumer would have crashed and retried for hours.
Rate Limiter Implementation
class WebhookRateLimiter {
constructor(options = {}) {
this.maxPerWindow = options.maxPerWindow || 100;
this.windowMs = options.windowMs || 60000; // 1 minute
this.buckets = new Map(); // subscriberId -> { tokens, resetAt }
}
allow(subscriberId) {
const now = Date.now();
let bucket = this.buckets.get(subscriberId);
if (!bucket || now >= bucket.resetAt) {
bucket = {
count: 0,
resetAt: now + this.windowMs,
};
this.buckets.set(subscriberId, bucket);
}
bucket.count++;
if (bucket.count > this.maxPerWindow) {
const retryAfter = Math.ceil((bucket.resetAt - now) / 1000);
return {
allowed: false,
retryAfter,
remaining: 0,
};
}
return {
allowed: true,
retryAfter: 0,
remaining: this.maxPerWindow - bucket.count,
};
}
getRateLimitHeaders(subscriberId) {
const result = this.allow(subscriberId);
return {
'X-RateLimit-Limit': this.maxPerWindow.toString(),
'X-RateLimit-Remaining': result.allowed
? result.remaining.toString()
: '0',
'X-RateLimit-Reset': result.allowed
? '0'
: result.retryAfter.toString(),
};
}
cleanup() {
const now = Date.now();
for (const [id, bucket] of this.buckets) {
if (now >= bucket.resetAt) {
this.buckets.delete(id);
}
}
}
}
// Start cleanup every 5 minutes
const rateLimiter = new WebhookRateLimiter({ maxPerWindow: 100 });
setInterval(() => rateLimiter.cleanup(), 300000);
Expected output: Rate limiter allows 100 webhooks per minute per subscriber. Excess webhooks are rejected with retry-after headers. Cleanup removes expired buckets to prevent memory leaks.
Sliding Window Rate Limiting
// Sliding window log for more precise rate limiting
class SlidingWindowRateLimiter {
constructor(options = {}) {
this.maxInWindow = options.maxInWindow || 100;
this.windowMs = options.windowMs || 60000;
this.logs = new Map(); // subscriberId -> [timestamps]
}
allow(subscriberId) {
const now = Date.now();
const windowStart = now - this.windowMs;
let timestamps = this.logs.get(subscriberId) || [];
timestamps = timestamps.filter(ts => ts > windowStart);
if (timestamps.length >= this.maxInWindow) {
const oldest = timestamps[0];
const retryAfter = Math.ceil((oldest + this.windowMs - now) / 1000);
this.logs.set(subscriberId, timestamps);
return {
allowed: false,
retryAfter,
windowUtilization: timestamps.length / this.maxInWindow,
};
}
timestamps.push(now);
this.logs.set(subscriberId, timestamps);
return {
allowed: true,
retryAfter: 0,
windowUtilization: timestamps.length / this.maxInWindow,
};
}
}
Expected output: Sliding window tracks timestamps of recent requests. Old timestamps are filtered out. The window slides continuously instead of resetting at fixed intervals. More precise but uses more memory.
Consumer Protection Queue
class WebhookDeliveryQueue {
constructor(rateLimiter, options = {}) {
this.rateLimiter = rateLimiter;
this.queues = new Map(); // subscriberId -> webhook[]
this.maxQueueSize = options.maxQueueSize || 1000;
this.deliveryInterval = options.deliveryInterval || 1000;
this.isProcessing = false;
}
enqueue(subscriberId, webhook) {
let queue = this.queues.get(subscriberId);
if (!queue) {
queue = [];
this.queues.set(subscriberId, queue);
}
if (queue.length >= this.maxQueueSize) {
console.warn(`Queue full for ${subscriberId}, dropping webhook`);
return { queued: false, reason: 'queue_full' };
}
queue.push(webhook);
this.startProcessing();
return { queued: true, queueLength: queue.length };
}
async startProcessing() {
if (this.isProcessing) return;
this.isProcessing = true;
while (this.hasQueuedItems()) {
await this.processBatch();
await this.sleep(this.deliveryInterval);
}
this.isProcessing = false;
}
async processBatch() {
for (const [subscriberId, queue] of this.queues) {
if (queue.length === 0) continue;
const rateCheck = this.rateLimiter.allow(subscriberId);
if (!rateCheck.allowed) continue;
const webhook = queue.shift();
try {
await this.deliver(subscriberId, webhook);
} catch (err) {
console.error(`Delivery failed: ${err.message}`);
// Re-queue or dead letter
}
}
// Cleanup empty queues
for (const [id, queue] of this.queues) {
if (queue.length === 0) this.queues.delete(id);
}
}
hasQueuedItems() {
for (const queue of this.queues.values()) {
if (queue.length > 0) return true;
}
return false;
}
async deliver(subscriberId, webhook) {
// Actual delivery logic
console.log(`Delivering to ${subscriberId}: ${webhook.id}`);
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
Expected output: Delivery queue holds webhooks for rate-limited subscribers. Processing loop respects rate limits, delivering as capacity becomes available. Queue prevents webhook loss during rate limit periods.
Redis-Based Rate Limiting
const redis = require('redis');
class RedisRateLimiter {
constructor(client, options = {}) {
this.client = client;
this.maxInWindow = options.maxInWindow || 100;
this.windowMs = options.windowMs || 60; // seconds
}
async allow(subscriberId) {
const key = `wh:ratelimit:${subscriberId}`;
const now = Math.floor(Date.now() / 1000);
const window = now - this.windowMs;
// Remove old entries
await this.client.zremrangebyscore(key, 0, window);
// Count entries in window
const count = await this.client.zcard(key);
if (count >= this.maxInWindow) {
// Get oldest entry timestamp
const oldest = await this.client.zrange(key, 0, 0, { WITHSCORES: true });
const retryAfter = oldest[1] ? parseInt(oldest[1]) + this.windowMs - now : this.windowMs;
return { allowed: false, retryAfter };
}
// Add current request
await this.client.zadd(key, now, `${now}:${Math.random()}`);
await this.client.expire(key, this.windowMs * 2);
return { allowed: true, remaining: this.maxInWindow - count - 1 };
}
async allowSliding(subscriberId) {
// Lua script for atomic sliding window
const script = `
local key = KEYS[1]
local max = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count >= max then
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
local retryAfter = oldest[2] + window - now
return {0, retryAfter}
end
redis.call('ZADD', key, now, now .. ':' .. math.random())
redis.call('EXPIRE', key, window * 2)
return {1, max - count - 1}
`;
const result = await this.client.eval(script, {
keys: [`wh:ratelimit:${subscriberId}`],
arguments: [
this.maxInWindow.toString(),
this.windowMs.toString(),
Math.floor(Date.now() / 1000).toString(),
],
});
return {
allowed: result[0] === 1,
remaining: result[1],
};
}
}
Expected output: Redis sorted sets provide distributed rate limiting. Lua script ensures atomic operations. Multiple provider instances share the same rate limit state. Entries auto-expire.
Adaptive Rate Limiting
class AdaptiveRateLimiter {
constructor(options = {}) {
this.baseRate = options.baseRate || 100;
this.minRate = options.minRate || 10;
this.consumerHealth = new Map(); // subscriberId -> { successRate, windowData }
}
recordDelivery(subscriberId, success) {
let health = this.consumerHealth.get(subscriberId);
if (!health) {
health = {
windowStart: Date.now(),
total: 0,
success: 0,
rate: this.baseRate,
};
this.consumerHealth.set(subscriberId, health);
}
// Reset window every 5 minutes
if (Date.now() - health.windowStart > 300000) {
health.windowStart = Date.now();
health.total = 0;
health.success = 0;
}
health.total++;
if (success) health.success++;
// Adjust rate based on success rate
const successRate = health.success / health.total;
if (successRate < 0.5 && health.rate > this.minRate) {
health.rate = Math.max(health.rate * 0.8, this.minRate);
console.log(`Reduced rate for ${subscriberId} to ${health.rate} (success: ${(successRate*100).toFixed(0)}%)`);
} else if (successRate > 0.95 && health.rate < this.baseRate) {
health.rate = Math.min(health.rate * 1.1, this.baseRate);
}
return health.rate;
}
getEffectiveRate(subscriberId) {
const health = this.consumerHealth.get(subscriberId);
return health ? health.rate : this.baseRate;
}
}
Expected output: Adaptive rate limiter adjusts delivery rate based on consumer success rate. Failing consumers get reduced rate to prevent overload. Healthy consumers get increased rate up to the base limit.
Common Mistakes
1. No Rate Limiting
Without rate limiting, a single subscriber can consume all delivery resources. Other subscribers experience delays. The consumer may crash under load. Always implement per-subscriber rate limits.
2. Fixed Window with Burst at Boundaries
Fixed window rate limits allow bursts at window boundaries (all 100 requests in the first second). Use sliding window for smooth distribution. Or use token bucket algorithm.
3. In-Memory Rate Limiting in Multi-Instance Deployments
In-memory rate limits are per-instance. With 10 instances, each allows 100/minute, so the subscriber receives 1000/minute. Use Redis for distributed rate limiting.
4. No Backpressure Signal
When rate limited, webhooks are dropped without feedback. Return rate limit headers (X-RateLimit-Remaining, X-RateLimit-Reset). Queue excess webhooks instead of dropping them.
5. Same Rate for All Subscribers
Different subscribers have different capacities. Small startups handle 10 webhooks/minute. Enterprise systems handle 10000/minute. Allow per-subscriber rate limit configuration.
Practice Questions
1. Why use sliding window instead of fixed window rate limiting?
Fixed window resets at boundary, allowing burst of all requests at the start. Sliding window distributes requests evenly. Token bucket or sliding window log provides smoother rate limiting.
2. How does Redis help with distributed rate limiting?
Redis provides shared state across instances. Sorted sets track request timestamps. Lua scripts provide atomic operations. All instances enforce the same rate limit for each subscriber.
3. What is adaptive rate limiting?
The rate limit adjusts based on consumer health. If a consumer fails frequently, the rate is reduced. If the consumer handles all requests successfully, the rate is increased up to the configured maximum.
4. How do you handle webhooks that exceed rate limits?
Queue them for later delivery when rate capacity is available. If the queue exceeds max size, return 429 to the provider or dead letter the webhook. Never silently drop webhooks.
Challenge
Build a rate-limited webhook delivery system with: Redis-backed sliding window rate limiter, per-subscriber configurable limits (default 100/min), adaptive rate adjustment based on consumer success rate, delivery queue for rate-limited webhooks, and Prometheus metrics for rate limit hits and queue depth.
FAQ
Mini Project: Rate Limiting Dashboard
Build a dashboard that shows: per-subscriber rate limit configuration, current delivery rate vs limit, queue depth for rate-limited webhooks, rate limit hit count over time, adaptive rate adjustments, and alerts for subscribers approaching or exceeding limits.
What's Next
Now that you understand rate limiting, learn about Monitoring Webhooks to track delivery health and performance.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro