Skip to content

Webhook Rate Limiting — Complete Guide

DodaTech Updated 2026-06-28 9 min read

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

What is a good default rate limit for webhooks?

100 per minute is a safe default. Adjust based on consumer capacity. Large enterprise consumers may handle 1000+/minute. Small integrations may struggle with 10/minute.

How do I communicate rate limits to webhook consumers?

Include rate limit headers in delivery responses: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. Document limits in your API reference.

Should rate limits be per subscriber or per endpoint?

Per subscriber. Different subscribers have different URLs and capacities. Endpoint-level limits protect the provider system. Subscriber-level limits protect the consumer.

How do rate limits interact with retries?

Rate limits are checked before each delivery attempt, including retries. Retry delays should account for rate limit reset times. A rate-limited retry should wait until the next window.

Can rate limiting cause webhook delivery failures?

Yes, if webhooks are dropped instead of queued. Use queuing to hold rate-limited webhooks. Only drop if the queue exceeds maximum size. Alert on queue growth.

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