Choosing a Message Broker for Webhook Delivery

You are building the queue described in webhook delivery queue architecture and someone has to decide what sits underneath it. The candidate list is almost always the same four: Amazon SQS, RabbitMQ, Apache Kafka, Redis Streams. The comparisons you find are written for generic asynchronous work, and they optimise for the wrong things — throughput benchmarks and fan-out semantics — while ignoring the two properties that actually decide a webhook dispatcher’s fate: can the broker hold a message for two hours without a worker sitting on it, and can it keep one tenant’s backlog from becoming everyone’s backlog.

This guide works through that decision with the numbers that matter. The eventual worker count is a separate calculation covered in sizing worker pools for webhook dispatch; assume for now you know roughly how much concurrency you need.

Prerequisites

Step 1: Turn the delivery requirement into numbers

Four numbers decide most of this, and all four come from metrics you already have. Peak arrival rate is events per second at the busiest minute of the week, not the daily average. Retry amplification is how many extra messages the retry ladder produces per failed delivery — with an 8-attempt ladder and a 3% steady-state failure rate you are producing about 1.2 messages per event, but during an outage affecting 5% of your endpoints that same ladder produces closer to 1.4, and during a large provider outage it can double. Longest backoff interval is the top rung of the ladder; it decides whether the broker’s delay primitive is usable at all. Required in-flight count is concurrency, and it decides whether you will hit a broker ceiling.

"""Derive the four broker-selection inputs from production metrics."""
from dataclasses import dataclass


@dataclass
class DeliveryProfile:
    peak_events_per_sec: float      # busiest minute, not the daily mean
    p95_endpoint_seconds: float     # how long a slow endpoint holds a worker
    failure_rate: float             # fraction of attempts that are retryable
    max_attempts: int
    backoff_ladder_seconds: tuple[int, ...]

    @property
    def retry_amplification(self) -> float:
        """Expected messages produced per event, including retries."""
        total, probability = 1.0, self.failure_rate
        for _ in range(self.max_attempts - 1):
            total += probability
            probability *= self.failure_rate
        return total

    @property
    def peak_messages_per_sec(self) -> float:
        return self.peak_events_per_sec * self.retry_amplification

    @property
    def longest_delay_seconds(self) -> int:
        return max(self.backoff_ladder_seconds)

    @property
    def required_in_flight(self) -> float:
        """Little's Law: concurrency = arrival rate x service time."""
        return self.peak_messages_per_sec * self.p95_endpoint_seconds

    def report(self) -> str:
        return (
            f"peak messages/s      : {self.peak_messages_per_sec:,.0f}\n"
            f"retry amplification  : {self.retry_amplification:.2f}x\n"
            f"longest delay needed : {self.longest_delay_seconds}s\n"
            f"in-flight concurrency: {self.required_in_flight:,.0f}"
        )


if __name__ == "__main__":
    profile = DeliveryProfile(
        peak_events_per_sec=850,
        p95_endpoint_seconds=1.4,
        failure_rate=0.06,
        max_attempts=8,
        backoff_ladder_seconds=(10, 30, 120, 600, 1800, 3600, 7200),
    )
    print(profile.report())

For that profile the output is roughly 900 messages per second, a 7200-second longest delay, and about 1,270 in-flight messages. Two of those numbers already eliminate options: 7200 seconds is eight times SQS’s per-message delay ceiling, and 900 messages per second is above the 300 TPS an SQS FIFO queue sustains without batching. Neither is fatal, but both mean extra machinery, and you now know that before you write any code.

Step 2: Score the brokers on webhook-specific primitives

Throughput is the least interesting axis. Every one of these brokers will move a few thousand webhook messages per second on modest hardware, and your bottleneck is the remote HTTP endpoint anyway. What differs is the primitives.

Broker capability matrix for webhook dispatch Five webhook-specific criteria compared across Amazon SQS, RabbitMQ, Apache Kafka and Redis Streams. Criterion SQS RabbitMQ Kafka Redis Streams Native delay yes, 900s cap TTL plus DLX none none Per-key ordering FIFO groups hash exchange per partition per stream only Dead-letter redrive policy DLX built in build your own second stream Retention model 14 days max until acked replayable log MAXLEN trim Ops burden fully managed medium high low if shared
No column wins outright: SQS is strong on primitives and weak on delay range, Kafka is the inverse, and Redis Streams trades durability for scheduling precision.

The same comparison with the specifics that drive implementation work:

Broker Delayed messages Ordering unit In-flight limit Dead-letter Where the work lands
Amazon SQS (standard) DelaySeconds per message, 0–900s none; best-effort FIFO 120,000 in flight per queue RedrivePolicy with maxReceiveCount Chaining delay queues past 900s
Amazon SQS (FIFO) DelaySeconds per message, 0–900s MessageGroupId 20,000 in flight per queue RedrivePolicy 300 TPS per queue without batching; one slow group blocks its own head
RabbitMQ 3.13 none natively; per-queue TTL + dead-letter exchange, or the delayed-message plugin queue, or routing key via consistent-hash exchange basic.qos prefetch per channel dead-letter exchange, first class One queue per delay value; you run and patch the broker
Apache Kafka 3.7 none; retry topics plus consumer-side pause partition, keyed max.poll.records and max.poll.interval.ms none; write your own topic and producer Keeping slow HTTP calls off the poll loop to avoid rebalances
Redis Streams 7 none; sorted set keyed by due timestamp stream pending-entries list per group second stream plus XAUTOCLAIM Durability: async replication can lose acknowledged writes on failover

Read that table against Step 1’s numbers rather than in the abstract. A 7200-second top rung means SQS needs a chain (900s → 900s → 900s …) or an external scheduler; it means RabbitMQ needs a retry.2h queue with a message TTL and a dead-letter exchange pointing home; it means Kafka needs a consumer that can hold a record without committing for two hours, which in practice means a scheduler outside Kafka. Only Redis gets there with a single ZADD.

Step 3: Prototype the delayed retry path on the shortlist

Cut the list to two and build the same thing on both. The prototype should be exactly one contract — “hand me an envelope and a delay, give it back when the delay elapses, and survive a worker kill” — because that is the operation that every retry depends on and the one that is implemented differently everywhere.

Delayed retry sequence A worker leases an envelope, posts it, receives a 503, schedules it into the delay tier, and the tier re-queues it so a second lease results in a successful delivery. Worker Ready queue Delay tier Endpoint lease envelope signed POST 503 Service Unavailable schedule plus 40s due, re-queue second lease retry POST returns 200
Every broker must implement these six hops; what differs is whether the delay tier is a queue attribute, a TTL queue, a topic with a paused consumer, or a sorted set.

Write the contract as a protocol first, then one adapter per candidate. Keeping the interface this narrow is also what makes Step 5 possible.

"""Delay-tier contract plus SQS and Redis adapters, for a like-for-like prototype."""
import json
import time
from typing import Protocol

import boto3
import redis


class DelayTier(Protocol):
    def schedule(self, envelope: dict, delay_seconds: float) -> None: ...
    def promote_due(self, limit: int = 100) -> list[dict]: ...


class SqsDelayTier:
    """SQS caps DelaySeconds at 900, so long delays hop through the tier repeatedly."""

    MAX_DELAY = 900

    def __init__(self, queue_url: str, client=None):
        self.queue_url = queue_url
        self.sqs = client or boto3.client("sqs")

    def schedule(self, envelope: dict, delay_seconds: float) -> None:
        remaining = max(0.0, delay_seconds)
        hop = min(self.MAX_DELAY, remaining)
        envelope = dict(envelope, remaining_delay=remaining - hop)
        self.sqs.send_message(
            QueueUrl=self.queue_url,
            MessageBody=json.dumps(envelope),
            DelaySeconds=int(hop),
        )

    def promote_due(self, limit: int = 10) -> list[dict]:
        """Messages become visible on their own; re-hop the ones still owed delay."""
        response = self.sqs.receive_message(
            QueueUrl=self.queue_url,
            MaxNumberOfMessages=min(limit, 10),
            WaitTimeSeconds=20,          # long poll: empty receives are billable
        )
        ready = []
        for message in response.get("Messages", []):
            envelope = json.loads(message["Body"])
            self.sqs.delete_message(
                QueueUrl=self.queue_url, ReceiptHandle=message["ReceiptHandle"]
            )
            if envelope.get("remaining_delay", 0) > 0:
                self.schedule(envelope, envelope["remaining_delay"])
            else:
                ready.append(envelope)
        return ready


class RedisDelayTier:
    """A sorted set keyed by due timestamp: exact delays, no ceiling."""

    def __init__(self, client: redis.Redis, key: str = "wh:scheduled"):
        self.r = client
        self.key = key

    def schedule(self, envelope: dict, delay_seconds: float) -> None:
        self.r.zadd(self.key, {json.dumps(envelope): time.time() + delay_seconds})

    def promote_due(self, limit: int = 100) -> list[dict]:
        now = time.time()
        due = self.r.zrangebyscore(self.key, "-inf", now, start=0, num=limit)
        if not due:
            return []
        # Only return entries this process actually removed, so two schedulers
        # racing on the same set cannot both promote the same envelope.
        pipe = self.r.pipeline()
        for raw in due:
            pipe.zrem(self.key, raw)
        removed = pipe.execute()
        return [json.loads(raw) for raw, ok in zip(due, removed) if ok]


def measure_scheduling_error(tier: DelayTier, delay: float, count: int) -> list[float]:
    """How late does each envelope actually arrive? Tail matters more than mean."""
    for index in range(count):
        tier.schedule({"id": index, "due": time.time() + delay}, delay)
    lateness, deadline = [], time.time() + delay + 60
    while len(lateness) < count and time.time() < deadline:
        for envelope in tier.promote_due():
            lateness.append(time.time() - envelope["due"])
        time.sleep(0.25)
    return sorted(lateness)

Run measure_scheduling_error with a few thousand envelopes at a 60-second delay and look at p99, not the mean. Redis lands within the scheduler’s tick interval. SQS is accurate to a second or two at low volume but the re-hop path adds a full extra visibility cycle per hop, so a 2-hour delay implemented as eight 900-second hops accumulates error at every one. RabbitMQ’s TTL queues have a sharper edge: TTL expiry is only evaluated at the head of the queue, so a message with a 10-second TTL sitting behind one with a 30-minute TTL does not dead-letter until the head does. If you build a single delay queue with per-message TTLs, you have built a queue that delivers retries in the wrong order and far too late. One queue per delay value is not an optimisation — it is the only correct way to do it.

Step 4: Price a bad month, not an average one

Every broker looks cheap at the average rate. The bill you should model is the one from the month a major consumer platform is down for six hours and your retry ladder is running at full amplification against 20% of your endpoints. For SQS, remember that polling is billable: an idle worker doing 20-second long polls generates about 130,000 requests a month on its own, and a pool of 200 workers doing 1-second polls generates 500 million.

"""Monthly cost model: request-priced (SQS) versus instance-priced (RabbitMQ, Kafka)."""
SECONDS_PER_MONTH = 30 * 24 * 3600
SQS_PER_MILLION = 0.40          # standard queue list price, us-east-1
SQS_FIFO_PER_MILLION = 0.50


def sqs_monthly_cost(
    messages_per_sec: float,
    worker_count: int,
    poll_wait_seconds: int = 20,
    receive_batch: int = 10,
    fifo: bool = False,
) -> dict:
    messages = messages_per_sec * SECONDS_PER_MONTH
    # One send, one receive per batch, one delete per message.
    work_requests = messages + (messages / receive_batch) + messages
    # Idle long polls still bill, whether or not they return anything.
    poll_requests = worker_count * (SECONDS_PER_MONTH / poll_wait_seconds)
    total = work_requests + poll_requests
    rate = SQS_FIFO_PER_MILLION if fifo else SQS_PER_MILLION
    return {
        "work_requests": work_requests,
        "poll_requests": poll_requests,
        "usd": total / 1_000_000 * rate,
    }


def broker_instance_cost(nodes: int, usd_per_hour: float, engineer_days: float) -> dict:
    infra = nodes * usd_per_hour * 24 * 30
    return {"infra_usd": infra, "toil_usd": engineer_days * 800, "usd": infra + engineer_days * 800}


if __name__ == "__main__":
    steady = sqs_monthly_cost(messages_per_sec=900, worker_count=120)
    outage = sqs_monthly_cost(messages_per_sec=2400, worker_count=300)
    print(f"SQS steady : ${steady['usd']:,.0f}")
    print(f"SQS outage : ${outage['usd']:,.0f}")
    print(f"RabbitMQ   : ${broker_instance_cost(3, 0.30, engineer_days=1.5)['usd']:,.0f}")
    print(f"Kafka      : ${broker_instance_cost(3, 0.55, engineer_days=4.0)['usd']:,.0f}")

The interesting output is not which number is smallest — it is which number moves. SQS cost scales with traffic, so a bad month costs three times a good one and the retry ladder’s amplification shows up directly on the invoice. A self-hosted broker costs the same in a bad month and more in engineering days, which never appear on the invoice at all. Put the engineer-days line in the model explicitly, because the argument otherwise reduces to comparing a real invoice against unpriced labour, and the labour always wins that argument until the on-call rotation starts.

Step 5: Commit to a recommendation and write down the exit cost

The decision collapses to four scenarios, and the honest answer is that most webhook platforms should be on SQS or Redis Streams unless something specific forces otherwise.

Broker decision tree Four sequential questions about log replay, per-tenant ordering, cloud volume and scheduling precision lead to Kafka, RabbitMQ, SQS or Redis Streams. Must replay the whole event log on demand? Strict per-tenant order plus self-hosting? On AWS and under 5k deliveries/minute? Need exact schedule and already run Redis? Kafka plus retry topics RabbitMQ quorum queues SQS plus a delay chain Redis Streams plus ZSET yes yes yes yes no no no
Read the tree top to bottom: the first three questions are about constraints you cannot code around, and only the last one is a preference.

Kafka is right when the event log itself is the product — when you need to rebuild a consumer’s entire state from the beginning of time, or when webhook dispatch is one consumer group among many on an existing Kafka deployment. It is wrong as a dedicated webhook queue: it has no delay primitive, no dead-letter primitive, and a slow HTTP call inside the poll loop triggers consumer-group rebalances that stall every partition. If you use it, the HTTP call belongs on a thread pool with the poll loop committing offsets independently.

RabbitMQ is right when you need per-key ordering, you are not on AWS, and you already have people who operate it. The consistent-hash exchange gives per-tenant ordering with a bounded number of queues — the substrate that partitioning webhook queues by tenant builds on — and dead-lettering is genuinely first-class. Use quorum queues — classic mirrored queues have been deprecated and their failover behaviour is exactly what you do not want holding undelivered customer events.

SQS is right for the majority of webhook platforms on AWS. Its primitives map almost one-to-one onto the topology in the parent guide, the redrive policy gives you a dead-letter sink for free, and there is no broker to operate. Accept the 900-second ceiling and chain the ladder; it is a smaller cost than any of the alternatives here.

Redis Streams is right when scheduling precision matters, volumes are moderate, and Redis is already a hard dependency. Be clear-eyed about durability: with asynchronous replication, a failover can lose recently acknowledged writes, so an envelope confirmed to a producer may not survive. If that is unacceptable, pair it with the transactional outbox described in implementing the transactional outbox pattern for webhooks so the durable record lives in your database and Redis is only the dispatch buffer.

Whichever you pick, keep the adapter from Step 3 as the only place broker types appear. The exit cost is then one adapter, one conformance suite, and a dual-write window — not a rewrite of the dispatcher.

Verification and testing

Write the conformance suite once and run it against every adapter. Three properties matter, and all three are cheap to assert:

"""pytest conformance suite: run against each broker adapter you shortlist."""
import time

import pytest

from delivery.adapters import RedisDelayTier, SqsDelayTier  # your adapters


@pytest.fixture(params=["redis", "sqs"])
def tier(request, redis_client, sqs_queue_url):
    return RedisDelayTier(redis_client) if request.param == "redis" else SqsDelayTier(sqs_queue_url)


def test_envelope_is_not_promoted_early(tier):
    tier.schedule({"id": "a"}, delay_seconds=30)
    assert tier.promote_due() == []          # nothing may surface before it is due


def test_envelope_is_promoted_once_after_the_delay(tier):
    tier.schedule({"id": "b"}, delay_seconds=2)
    time.sleep(3)
    first = tier.promote_due()
    second = tier.promote_due()
    assert [e["id"] for e in first] == ["b"]
    assert second == []                      # no double promotion


def test_concurrent_schedulers_do_not_duplicate(tier):
    for index in range(50):
        tier.schedule({"id": f"c{index}"}, delay_seconds=1)
    time.sleep(2)
    promoted = [e["id"] for e in tier.promote_due(limit=50)] + \
               [e["id"] for e in tier.promote_due(limit=50)]
    assert len(promoted) == len(set(promoted)) == 50

Then verify the operational surface by hand before committing. On SQS, confirm the redrive policy is real rather than assumed:

aws sqs get-queue-attributes --queue-url "$READY_URL" \
  --attribute-names RedrivePolicy VisibilityTimeout DelaySeconds

# Watch delay-tier occupancy while a load test runs against a failing endpoint.
watch -n 5 'aws sqs get-queue-attributes --queue-url "$RETRY_15M_URL" \
  --attribute-names ApproximateNumberOfMessagesDelayed'

A shortlist candidate passes if the conformance suite is green, p99 scheduling error is under 10% of the shortest ladder rung, and a kill -9 on a worker mid-attempt results in redelivery rather than a lost envelope.

Failure modes and gotchas

Frequently Asked Questions

Can the delay tiers live on a different broker from the ready queue?

They can, and it is a common way out of the 900-second ceiling: keep ready traffic on SQS and schedule due times in a Redis sorted set that pushes envelopes back when they mature. The price is a second failure domain in the delivery path, so the scheduler needs its own health checks and its own runbook. Make sure the envelope body stays in exactly one store, otherwise a partial failure leaves you reconciling two half-truths under time pressure.

Does an SQS FIFO queue give per-tenant ordering for free?

Only for first attempts. Setting MessageGroupId to the tenant or subscription ID does order the group, but the moment a delivery fails and takes a trip through a delay tier it re-enters as a new message behind whatever arrived meanwhile. A blocked group also stalls completely until its head message succeeds or dead-letters, which is real isolation but also a real outage for that tenant.

Does managed Kafka change the recommendation?

It removes the operational objection but not the design one. MSK or Confluent Cloud takes broker upgrades and partition rebalancing off your plate, yet the missing delay primitive, the missing dead-letter primitive and the rebalance sensitivity of a slow poll loop are properties of the protocol, not of who runs it. Managed Kafka is a good answer when Kafka is already the backbone; it is still the wrong answer when a queue is all you need.

Can the webhook queue share a broker with our existing background jobs?

Sharing one broker deployment couples two very different load profiles: background jobs are usually steady, while webhook retry traffic can multiply several times over within a minute of a consumer platform going down. At minimum give delivery its own virtual host, connection pool and quota so a retry surge cannot exhaust connections that image processing depends on. If the job system is already close to its limits, a separate cluster is cheaper than the incident where both stop at once.

How long should the shortlist prototypes run before the numbers mean anything?

Long enough to cover a full ladder traversal on your longest rung, with a deliberately failing endpoint in the mix, plus at least one hard worker kill per candidate. A ten-minute soak proves nothing about a two-hour delay, and steady-state accuracy is not what you are buying. Watch the p99 of scheduling error and the redelivery behaviour after the kill; those two numbers decide the winner more often than cost does.

How do we migrate brokers later without losing envelopes that are mid-backoff?

Point the producer at the new broker while both sets of consumers keep running, then let the old delay tiers drain naturally rather than copying scheduled envelopes across. Envelopes in the two-hour rung will take up to two hours to finish, so plan a dual-run window at least as long as your top rung plus a margin. Keep the old queues alive and monitored until their depth and their scheduled set have both been at zero for a full day.