Webhook Delivery Queue Architecture: Brokers, Retry Tiers and Worker Topology
Every durable webhook sender in Resilient Delivery & Retry Strategies eventually resolves into the same shape — a queue between the thing that produced an event and the thing that makes an HTTP request — and almost every delivery incident traces back to a decision made about that queue rather than about the HTTP call itself. The dispatcher is easy: sign the body, POST it, read the status code. The queue is where the hard properties live. How long may a worker hold a message before the broker assumes it died? Where does a message wait out a 40-second backoff without pinning a thread? Does a single tenant’s 200,000-event backlog sit in front of everyone else’s password-reset webhooks?
This guide covers the topology, not the failure sink. The dead-letter queue architecture pages deal with what happens after a delivery is given up on; here the subject is the live path — the ready queue, the delay tiers that implement backoff, the leases that make at-least-once delivery honest, and the partitioning decisions that bound how much one noisy publisher can hurt everyone else.
Where the queue sits in a webhook dispatcher
A production dispatcher has at least three logical queues, even if a small deployment collapses them onto one broker. The ready queue holds envelopes eligible for an attempt right now. The delay queue (or ladder of delay queues) holds envelopes waiting out a backoff interval. The dead-letter sink holds envelopes whose retry budget is spent. Workers only ever read from the ready queue; everything else is a scheduler feeding it.
That separation matters because the three queues have completely different shapes. The ready queue should be shallow — if it is deep, you are under-provisioned. The delay queue is supposed to be deep; depth there is just the integral of your backoff schedule and says nothing about health. The dead-letter sink should be near-empty and any growth is an incident. Alerting on “queue depth” without distinguishing the three produces pages that mean nothing, which is why the metric that actually matters is the age of the oldest ready message.
The arrow that people forget is the one going back from the delay queue into the ready queue. That path is a producer, and it competes with the real producer for ready-queue capacity. During a large provider outage the retry path can produce far more messages per second than the business does, which is the mechanism behind most retry storms. Bounding it is the job of exponential backoff algorithms and, when an endpoint is comprehensively down, of circuit breaker patterns that stop generating attempts at all.
Leases, visibility timeouts and honest at-least-once semantics
Queues do not hand a message to a worker; they lease it. The broker makes the message invisible for a configured window — SQS calls it the visibility timeout, RabbitMQ implements it as an unacknowledged delivery on a channel, Redis Streams as a pending entry with an idle time. If the worker acknowledges within the window, the message is deleted. If it crashes, the lease expires and the message becomes visible to someone else. That is the entire mechanism behind at-least-once delivery, and every duplicate webhook your consumers ever see comes out of it.
The failure mode is mundane and extremely common: the lease is shorter than the worst-case HTTP attempt. A worker POSTs to an endpoint that takes 45 seconds to respond while the visibility timeout is 30 seconds. At second 30 the broker re-leases the message, a second worker POSTs the same event, and the tenant receives the webhook twice — while both workers eventually get a 200 and both try to delete a message that has already moved on. The rule is simple: the lease window must exceed connect timeout plus read timeout plus scheduling slack, and your HTTP client must actually enforce those timeouts. If the endpoint’s response time is unpredictable, cap it explicitly as described in handling slow webhook consumers.
Because that edge exists, the queue cannot promise exactly-once delivery no matter what the broker’s marketing says, and the correct response is to make the consumer side tolerate repeats rather than to chase a stronger guarantee. That trade-off is laid out in at-least-once vs exactly-once delivery trade-offs, and the practical consumer contract in how to design idempotent webhook consumers. Send a stable Idempotency-Key header derived from the envelope ID — never regenerated per attempt — and duplicates become a non-event.
A second, quieter consequence of leasing: the in-flight count is a real capacity limit. SQS allows 120,000 in-flight messages per standard queue and 20,000 for FIFO; RabbitMQ bounds it with per-channel prefetch. If your workers hold long leases on a large pool, you can hit that ceiling and the broker will simply stop delivering messages while the queue still shows depth — a genuinely confusing outage where nothing is broken and nothing is moving.
Three topologies for the retry path
The interesting design choice is not “which broker” but “how many queues, and what distinguishes them”. Three patterns cover nearly all production webhook senders.
Pattern A — single work queue with in-process retry. One queue, one worker pool; failed attempts sleep inside the worker and retry in place while holding the lease. Attractive because it is fifty lines of code, and it is fine below roughly ten deliveries per second with sub-second endpoints. It breaks the moment backoff exceeds the lease window: a worker sleeping 60 seconds either loses its lease (duplicate) or must heartbeat the lease (complexity), and it burns a concurrency slot doing nothing. Every worker asleep is a worker not delivering.
Pattern B — a retry ladder of delay queues. The ready queue holds first attempts. On failure, the envelope is written to a delay tier — retry.10s, retry.1m, retry.15m, retry.2h — and a scheduler promotes it back to ready when its due time passes. Workers never sleep. Depth in each tier is directly readable as “how many deliveries are currently in backoff at this stage”, which is the single most useful diagnostic a webhook platform can have. The cost is more moving parts and a scheduler you must keep running.
Pattern C — partitioned or per-tenant queues. Ready traffic is split by tenant, either into physically separate queues or into partitions of one queue keyed by tenant ID. This is the only pattern that gives real isolation, and it is the only one where one customer’s outage cannot consume the whole worker pool. It is also the one that generates operational sprawl; the shard-explosion problem and the alternatives are worked through in partitioning webhook queues by tenant.
Most platforms end up at B plus a narrow slice of C: a shared ladder for the great majority of tenants, and dedicated queues for the handful of accounts big enough to hurt the rest. Choosing A permanently is a decision to cap your throughput at whatever a sleeping worker pool can sustain.
Scheduling backoff without pinning a worker
The delay tier is the piece brokers implement least consistently, and it drives more broker selection decisions than anything else. SQS has per-message DelaySeconds but caps it at 900 seconds, so a 2-hour backoff needs either a chain of delay queues or an external scheduler. RabbitMQ has no native per-message delay; the idiom is a TTL queue with a dead-letter exchange pointing back at the work queue, one queue per delay value, or the delayed-message-exchange plugin. Kafka has nothing at all — the community pattern is a set of retry topics with a consumer that pauses partitions until the record’s due time. Redis Streams likewise has no delay, but a sorted set keyed by due timestamp is trivial and exact to the millisecond. Those differences are compared head to head in choosing a message broker for webhook delivery.
The implementation below is the Redis variant, because it shows every moving part explicitly: a ready list, a scheduled sorted set, an in-flight sorted set that doubles as the lease table, and a reaper that returns abandoned leases. It runs as written against Redis 7 with redis-py 5.0 and httpx 0.27.
"""Webhook dispatch worker with a Redis-backed retry ladder.
Keys
wh:ready LIST envelope ids eligible for an attempt now
wh:scheduled ZSET member=envelope id, score=unix ts when it becomes due
wh:inflight ZSET member=envelope id, score=unix ts when the lease expires
wh:dead LIST envelope ids whose retry budget is spent
wh:body HASH envelope id -> JSON envelope
"""
import json
import os
import random
import signal
import time
import uuid
import httpx
import redis
R = redis.Redis.from_url(os.environ.get("REDIS_URL", "redis://localhost:6379/0"))
READY, SCHEDULED, INFLIGHT, DEAD, BODY = (
"wh:ready", "wh:scheduled", "wh:inflight", "wh:dead", "wh:body",
)
CONNECT_TIMEOUT = 3.0
READ_TIMEOUT = 10.0
LEASE_SECONDS = 45 # > connect + read + scheduling slack
MAX_ATTEMPTS = 8
BASE_DELAY = 2.0
MAX_DELAY = 7200.0
RETRYABLE_STATUS = {408, 425, 429, 500, 502, 503, 504}
def enqueue(tenant: str, endpoint: str, event: dict) -> str:
"""Called by the producer (ideally from an outbox, not a request handler)."""
envelope_id = uuid.uuid4().hex
envelope = {
"id": envelope_id,
"tenant": tenant,
"endpoint": endpoint,
"event": event,
"attempt": 0,
"enqueued_at": time.time(),
}
pipe = R.pipeline()
pipe.hset(BODY, envelope_id, json.dumps(envelope))
pipe.lpush(READY, envelope_id)
pipe.execute()
return envelope_id
def promote_due(batch: int = 500) -> int:
"""Scheduler tick: move envelopes whose backoff has elapsed onto ready."""
now = time.time()
due = R.zrangebyscore(SCHEDULED, "-inf", now, start=0, num=batch)
if not due:
return 0
pipe = R.pipeline()
for envelope_id in due:
pipe.zrem(SCHEDULED, envelope_id)
pipe.lpush(READY, envelope_id)
pipe.execute()
return len(due)
def reap_expired_leases(batch: int = 500) -> int:
"""At-least-once safety net: a worker that died mid-attempt loses its lease."""
now = time.time()
expired = R.zrangebyscore(INFLIGHT, "-inf", now, start=0, num=batch)
if not expired:
return 0
pipe = R.pipeline()
for envelope_id in expired:
pipe.zrem(INFLIGHT, envelope_id)
pipe.lpush(READY, envelope_id)
pipe.execute()
return len(expired)
def lease(block_seconds: int = 5) -> dict | None:
item = R.brpop(READY, timeout=block_seconds)
if item is None:
return None
envelope_id = item[1].decode()
R.zadd(INFLIGHT, {envelope_id: time.time() + LEASE_SECONDS})
raw = R.hget(BODY, envelope_id)
if raw is None: # body reaped by retention; drop the lease
R.zrem(INFLIGHT, envelope_id)
return None
return json.loads(raw)
def backoff_seconds(attempt: int) -> float:
"""Full-jitter exponential backoff, capped at MAX_DELAY."""
ceiling = min(MAX_DELAY, BASE_DELAY * (2 ** attempt))
return random.uniform(BASE_DELAY, ceiling)
def complete(envelope: dict) -> None:
pipe = R.pipeline()
pipe.zrem(INFLIGHT, envelope["id"])
pipe.hdel(BODY, envelope["id"])
pipe.execute()
def reschedule(envelope: dict, reason: str) -> str:
envelope["attempt"] += 1
envelope["last_error"] = reason
pipe = R.pipeline()
pipe.zrem(INFLIGHT, envelope["id"])
pipe.hset(BODY, envelope["id"], json.dumps(envelope))
if envelope["attempt"] >= MAX_ATTEMPTS:
pipe.lpush(DEAD, envelope["id"])
outcome = "dead_lettered"
else:
pipe.zadd(SCHEDULED, {envelope["id"]: time.time() + backoff_seconds(envelope["attempt"])})
outcome = "rescheduled"
pipe.execute()
return outcome
def attempt_delivery(client: httpx.Client, envelope: dict) -> str:
headers = {
"Content-Type": "application/json",
"Idempotency-Key": envelope["id"], # stable across every attempt
"X-Delivery-Attempt": str(envelope["attempt"] + 1),
}
try:
response = client.post(envelope["endpoint"], json=envelope["event"], headers=headers)
except httpx.RequestError as exc:
return reschedule(envelope, f"transport:{type(exc).__name__}")
if response.status_code < 300:
complete(envelope)
return "delivered"
if response.status_code in RETRYABLE_STATUS:
return reschedule(envelope, f"http:{response.status_code}")
complete(envelope) # 4xx is permanent: do not retry
R.lpush(DEAD, envelope["id"])
return "rejected"
def run_worker() -> None:
running = True
def stop(*_):
nonlocal running
running = False
signal.signal(signal.SIGTERM, stop)
timeout = httpx.Timeout(connect=CONNECT_TIMEOUT, read=READ_TIMEOUT, write=5.0, pool=5.0)
with httpx.Client(timeout=timeout, limits=httpx.Limits(max_connections=64)) as client:
while running:
promote_due()
reap_expired_leases()
envelope = lease()
if envelope is not None:
attempt_delivery(client, envelope)
if __name__ == "__main__":
run_worker()
Two details are load-bearing. promote_due and reap_expired_leases are called from the worker loop for brevity; in production run them in one or two dedicated schedulers, because every worker scanning the same sorted set every iteration is pure contention. And Idempotency-Key is the envelope ID, not a per-attempt UUID — regenerating it per attempt silently defeats consumer-side deduplication and is the most common way teams turn a duplicate-suppression system into a duplicate-delivery system.
What has to be validated before an envelope enters the queue
A delivery queue is a persistence layer holding customer data, often for days, and it is read by more services than the endpoint that produced it. Three controls matter.
Validate and canonicalise at enqueue, sign at dispatch. Reject malformed events before they are durable; a payload that cannot be serialised is far cheaper to fail at the producer than at attempt seven. But compute the signature in the worker, at the moment of the request, using the tenant’s current secret. Signing at enqueue freezes a secret into the message and breaks mid-flight rotation, so a queued backlog starts failing verification the moment a customer rotates a key.
Never store the secret in the message. Store a key reference (secret_version, kms_key_id) and resolve it at dispatch. Queue payloads land in broker snapshots, in dead-letter dumps, in the support tooling that a triage engineer screenshots into a ticket. Enable server-side encryption with a customer-managed key on every queue and on the dead-letter sink, and set retention explicitly rather than accepting the four-day default.
Bound the payload. SQS caps a message at 256 KB, and a webhook body plus headers plus delivery metadata gets there faster than teams expect. Enforce a limit at enqueue and, above it, use the claim-check pattern: put the body in object storage, put a pointer in the queue, and give the worker read access with a short-lived presigned URL. Silent truncation at the broker boundary produces payloads that fail signature verification at the consumer with no useful error anywhere.
Finally, the endpoint URL in the envelope is attacker-influenced data — a tenant chose it. It must be re-validated against your egress policy at dispatch time, not only at subscription time, because DNS for a hostname that resolved publicly last week can resolve to a link-local address today.
Operating the queue: metrics, deploys and capacity
Export four numbers per queue and treat them as the platform’s vital signs: ready depth, age of the oldest ready message, in-flight count, and promotion rate out of each delay tier. Age is the one to alert on. Depth is a function of both arrival rate and consumption rate and is therefore ambiguous; a ready message older than your delivery SLO means the pool cannot keep up, full stop. Tie the threshold to the target in defining SLOs for webhook delivery and derive the pool size from the arrival rate as described in sizing worker pools for webhook dispatch.
Deploys need a drain protocol. On SIGTERM a worker must stop leasing, finish or release in-flight envelopes, and only then exit — and the orchestrator’s grace period must exceed the lease window, or Kubernetes will SIGKILL workers mid-attempt and every rolling deploy will manufacture a small batch of duplicate deliveries. Set terminationGracePeriodSeconds above LEASE_SECONDS, not below it.
Queues themselves belong in infrastructure as code, with the redrive policy, retention, encryption key and delay-tier definitions in the same module as the workers that read them. Queue topology drifts silently otherwise: someone widens a visibility timeout during an incident and it stays widened for two years. Assert the topology in CI — a test that provisions the tiers against a local broker and checks that a failed delivery lands in the expected tier catches ladder misconfiguration long before a customer does.
Rolling out a delivery queue
1. Provision the ingest queue and retry ladder
Create the ready queue, every delay tier, and the dead-letter sink in one infrastructure module, with retention, encryption key and redrive policy set explicitly rather than defaulted. Name the tiers after their delay (retry-10s, retry-2h) so a dashboard is readable without a lookup table.
2. Pin the lease window to the HTTP timeout budget
Derive the visibility timeout from the client’s connect and read timeouts plus scheduling slack, and assert the relationship in a test so nobody can lower one without the other. This single setting decides your duplicate rate.
3. Separate first attempts from retry traffic
Put retries on their own queue with a bounded share of worker concurrency — typically 20% to 40%. Without the split, a large backlog of failing deliveries pushes time-to-first-attempt for brand-new events past your objective while throughput charts look normal.
4. Wire depth, age and in-flight metrics before launch
Export ready depth, oldest-message age, in-flight count and per-tier promotion rate on day one, and page on age. Retro-fitting these during an incident is how teams end up guessing which of the three queues is misbehaving.
5. Rehearse a drain and a redrive
In staging, stop the consumers, build a backlog of at least an hour of traffic, restart, and time the recovery. Then redrive the dead-letter sink and confirm the replayed envelopes carry their original idempotency keys. A topology that has never been drained is a topology whose recovery time is unknown.
Failure modes
| Failure mode | What you observe | Mitigation |
|---|---|---|
| Lease shorter than the HTTP timeout budget | Consumers report duplicate events; workers log “message no longer in flight” on ack | Set the visibility timeout above connect + read timeout + slack; cap request time in the client |
| Delay ceiling below the backoff schedule | Late retries fire far earlier than the ladder intends; retry storms during long outages | Chain delay queues or use an external scheduler once backoff exceeds the broker’s per-message delay cap |
| Retry traffic sharing the ready queue with first attempts | New events sit behind a backlog of failing ones; time-to-first-attempt spikes with no throughput change | Give retries a separate queue and a bounded share of worker concurrency |
| Scheduler stopped, workers healthy | Ready depth near zero, scheduled set growing without bound, deliveries never resume | Alert on promotion rate and oldest scheduled score, not only on ready depth |
| In-flight ceiling reached | Broker stops dispatching while depth stays high; no worker errors | Shorten leases, reduce prefetch, or shard across queues to stay under the in-flight limit |
| Message body exceeds the broker limit | Enqueue fails at the producer, or bodies are truncated and fail signature checks downstream | Enforce a size cap at enqueue and switch to a claim check with object storage above it |
Debugging checklist
- Compare
oldest_ready_ageagainst the delivery SLO before looking at anything else. - Confirm the scheduler is promoting: promotion rate greater than zero while the scheduled set is non-empty.
- Check in-flight count against the broker’s ceiling; a stalled queue with healthy workers is usually this.
- Verify visibility timeout is greater than connect timeout + read timeout for the slowest endpoint class.
- Sample five envelopes and confirm
Idempotency-Keyis stable across their attempt history. - Break down ready depth by tenant; a single tenant above 50% of depth means isolation is the real problem.
- Confirm dead-letter growth rate is flat; a rising slope is an incident, not a backlog.
- Check that
terminationGracePeriodSecondsexceeds the lease window on the worker deployment. - Verify the signing secret is resolved at dispatch, not frozen into queued envelopes.
Frequently Asked Questions
How many delay tiers does a retry ladder actually need?
Four to six is the usual sweet spot: enough to approximate an exponential curve, few enough that a dashboard stays readable. Each tier costs a broker object, a metric series and a line in the promotion logic, so tiers that differ by less than a factor of four buy you almost nothing. Pick the boundaries from your retry budget rather than from round numbers, so the last tier lands close to the point where you give up entirely.
Is it safe to purge a delay tier when it fills up during an incident?
Purging destroys customer events with no record, and a filling tier is almost never the problem you think it is. If you need the pressure off, stop the scheduler from promoting that tier instead: envelopes keep their due times, nothing is lost, and you can resume promotion at a throttled rate once the endpoint recovers. If envelopes genuinely have to leave the ladder, move them to the dead-letter sink so they remain replayable.
Should the scheduler run inside the workers or as its own deployment?
Run it as one or two dedicated replicas with a leader lease, not inside every worker. Promotion is a scan over shared state, so N workers scanning it produce N times the load for exactly the same result, and the contention grows as you scale the pool out. Two replicas with a lock give you failover without letting both promote the same envelope.
What happens to envelopes already queued when a tenant repoints or deletes a subscription?
Whatever your dispatcher resolves at attempt time wins, which is why the worker should look up the current subscription rather than trusting the URL frozen in the envelope. A deleted subscription should cause the worker to complete the envelope as cancelled and record that outcome, not to keep retrying a dead endpoint. Repointing mid-backlog is legitimate, but it means the tenant may receive old events at a new URL, so log the substitution explicitly.
How do I tell a healthy backlog of scheduled retries from a stalled scheduler?
Compare the lowest due timestamp in the scheduled set against the current clock. In a healthy system that gap is negative by only a second or two, because anything past due is promoted almost immediately regardless of how many millions of envelopes sit behind it. A gap that grows monotonically means promotion has stopped, and the size of the set tells you nothing on its own.
Does clock drift on the scheduler hosts affect the retry ladder?
Yes, because due times are absolute wall-clock timestamps written by one host and compared by another. A scheduler running a minute fast promotes short-tier envelopes before their backoff has elapsed, which quietly converts your ladder into a tighter retry loop against an endpoint that is already struggling. Keep NTP enforced on scheduler hosts and alert on offset, and never compute a due time from a monotonic clock that resets on restart.
Is a separate retry queue enough, or do retries need their own workers too?
A separate queue only helps if something bounds how much of the pool it can occupy. One pool polling both queues greedily will happily spend every slot on retries the moment the retry queue is deeper, which is the outcome the split was meant to prevent. Either run a second deployment with its own replica count, or make the poll loop weighted so retries can never claim more than their configured share of concurrency.
Related
- Choosing a Message Broker for Webhook Delivery — SQS, RabbitMQ, Kafka and Redis Streams scored on delay, ordering and DLQ primitives.
- Partitioning Webhook Queues by Tenant — isolating a noisy tenant without provisioning thousands of queues.
- Sizing Worker Pools for Webhook Dispatch — Little’s Law applied to delivery rate, latency and autoscaling signals.
- Dead-Letter Queue Architecture — what happens to envelopes the ladder gives up on.
- Resilient Delivery & Retry Strategies — how the queue fits into the end-to-end delivery model.