Designing webhook fan-out architectures

A single domain event — an order placed, a payment settled — often needs to reach many subscribers at once. This guide builds a fan-out architecture where one inbound event is delivered to every interested endpoint through per-subscriber queues and isolated workers, so one slow or failing subscriber cannot stall delivery to the others. It builds on sync vs async webhooks and complements when to use synchronous callbacks vs async webhooks, which establishes why fan-out delivery must be asynchronous in the first place.

The defining hazard of fan-out is head-of-line blocking: if all subscribers share one queue and one endpoint hangs for 30 seconds per request, every other subscriber waits behind it. Isolating each subscriber onto its own queue with its own workers removes that coupling — a dead endpoint backs up only its own lane.

Per-subscriber queue fan-out An ingest service stores one event and enqueues a job onto each subscriber's dedicated queue and worker. Ingest + store event queue A queue B queue C worker for sub A worker for sub B worker for sub C
One stored event fans out to a dedicated queue and worker per subscriber, so a stalled endpoint only backs up its own lane.

Prerequisites

Step 1: Persist the event once and return immediately

The ingest endpoint does the minimum: validate, store the event durably, and respond 202 Accepted. It must never block on delivery, because delivery to N subscribers can take arbitrarily long.

import json
import uuid
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import redis

app = FastAPI()
r = redis.Redis(decode_responses=True)

@app.post("/events")
async def ingest(request: Request):
    payload = await request.json()
    event_id = str(uuid.uuid4())
    event = {"id": event_id, "type": payload["type"], "body": payload}
    # Store the canonical event once; deliveries reference it by id.
    r.set(f"event:{event_id}", json.dumps(event))
    fan_out(event)  # enqueue jobs; does not perform HTTP delivery
    return JSONResponse({"event_id": event_id}, status_code=202)

Step 2: Fan out to per-subscriber queues

Look up every subscriber for the event type and enqueue one delivery job per subscriber onto that subscriber’s own queue. The queue name is keyed by subscriber id, which is what gives each one an isolated lane.

from rq import Queue

def subscribers_for(event_type: str) -> list[dict]:
    # In production this reads your subscriptions store.
    raw = r.smembers(f"subs:{event_type}")
    return [json.loads(s) for s in raw]

def fan_out(event: dict) -> None:
    for sub in subscribers_for(event["type"]):
        # One queue per subscriber => no shared head-of-line.
        q = Queue(f"deliver:{sub['id']}", connection=r)
        q.enqueue(
            "delivery.deliver",          # worker function path
            event_id=event["id"],
            subscriber=sub,
            job_timeout=30,              # cap a single attempt
            retry=None,                  # retries handled explicitly below
        )

The queue name is the whole isolation mechanism, so it is worth being explicit about what each part of it does and what the job actually carries. The job holds an event id rather than the event body: the payload stays in one place, and a queued job that is retried tomorrow still signs exactly the bytes that were stored today.

Anatomy of a fan-out queue name and job The queue name splits into a fixed prefix and a subscriber id, while the enqueued job carries an event id, the subscriber record, a job timeout and an explicit retry policy. What the queue name and the job each carry queue name enqueued job deliver: sub_42 fixed prefix names the worker role subscriber id creates the isolated lane event_id, not the body subscriber record job_timeout = 30 s retry handled explicitly Referencing the event by id keeps the job small and the signed bytes stable across retries.
The subscriber id in the queue name is what buys isolation; everything else in the job is deliberately small so a backlog costs queue memory rather than payload copies.

Step 3: Deliver from isolated workers

The delivery function loads the stored event, signs it, and POSTs to the subscriber. Each subscriber’s queue is drained by its own worker process, so a hung endpoint consumes only its lane’s worker, never another subscriber’s.

# delivery.py
import hashlib
import hmac
import json
import httpx
import redis

r = redis.Redis(decode_responses=True)

def sign(secret: str, body: bytes) -> str:
    return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()

def deliver(event_id: str, subscriber: dict) -> str:
    event = json.loads(r.get(f"event:{event_id}"))
    body = json.dumps(event["body"]).encode()
    headers = {
        "Content-Type": "application/json",
        "X-Event-Id": event_id,                 # stable id => consumer can dedupe
        "X-Signature": sign(subscriber["secret"], body),
    }
    state_key = f"delivery:{event_id}:{subscriber['id']}"
    r.hincrby(state_key, "attempts", 1)
    try:
        resp = httpx.post(subscriber["url"], content=body, headers=headers, timeout=10)
        resp.raise_for_status()
    except httpx.HTTPError as exc:
        r.hset(state_key, "status", "failed")
        raise  # re-raise so the queue's retry/DLQ policy can act
    r.hset(state_key, "status", "delivered")
    return "delivered"

Run one worker pool per subscriber lane. With RQ you point a worker at the specific queues it should drain:

# A worker dedicated to subscriber sub_42's lane.
rq worker deliver:sub_42 --url redis://localhost:6379

Two settings decide whether that worker behaves under stress. The first is the relationship between job_timeout and the HTTP timeout: the job deadline must be comfortably larger, because it has to cover DNS resolution, the TLS handshake, the request itself, and the bookkeeping around it. A sensible default is roughly three times the HTTP timeout — timeout=10 with job_timeout=30. Invert them and the worker is killed mid-request, which leaves the delivery record stuck in sending forever with no exception ever reaching the except branch. The observable symptom is a lane whose attempt counter climbs while its status field never changes, and it is easy to misread as a consumer problem when it is entirely self-inflicted.

The second is connection reuse. Constructing httpx.post(...) per delivery opens a fresh TCP connection and repeats the TLS handshake every time, which adds 80–150 ms to every attempt and accumulates sockets in TIME_WAIT — at a few hundred deliveries per second per host you will exhaust the ephemeral port range and start seeing connection failures that look like the subscriber refusing traffic. Build one httpx.Client per worker process at startup, give it a keep-alive pool sized to the lane’s concurrency, and reuse it for every job. On a warm connection the same delivery often completes in a third of the time, which directly reduces how long a slow subscriber occupies its lane.

The payoff shows up on a time axis. With job_timeout=30 on a subscriber whose endpoint hangs, that lane burns its full timeout and then retries, while the other lanes are already finished — the same event, three completely different completion times.

Lane isolation over time Three subscriber lanes for the same event: lane A hangs until its timeout and then retries, while lanes B and C finish within a second. One event, three lanes, measured from enqueue sub A attempt hangs until job_timeout retry queued sub B delivered sub C delivered 0 s 10 s 20 s 30 s 40 s Sub A owns the delay entirely; on a shared queue every lane would end at 30 s.
The stalled subscriber consumes only its own worker for the full timeout, so the healthy subscribers see delivery latency measured in milliseconds rather than in timeouts.

Step 4: Track per-subscriber delivery state

Because each subscriber is independent, delivery state is per (event_id, subscriber_id): attempts, last status, and whether it has been dead-lettered. This lets you retry or replay a single subscriber without touching the others, and lets you answer “who got event X?” precisely. Route a subscriber to a dead-letter queue only after its own attempts are exhausted; the other subscribers’ deliveries are unaffected.

Because that record is per subscriber rather than per event, each subscriber runs its own small state machine over the same event. Subscriber A can be sitting in failed awaiting a retry while subscriber B is already delivered, and dead-lettering A leaves B’s record untouched.

Per-subscriber delivery state A delivery record keyed by event id and subscriber id moves from pending to sending, then to delivered on success or failed on error, retrying until the attempt budget is spent and it is dead-lettered. One record per (event_id, subscriber_id) pending attempts = 0 sending attempt in flight delivered status persisted failed attempt recorded dead-lettered this lane only leased 2xx 5xx or timeout backoff elapsed attempts exhausted
Each subscriber advances this machine independently, so dead-lettering one endpoint never changes the recorded outcome for any other subscriber of the same event.

Sizing lanes: dedicated queues versus hashed shards

A queue per subscriber is the cleanest form of isolation and the least scalable. Redis itself is untroubled by tens of thousands of list keys, but each lane needs a worker to drain it, and an idle RQ worker still costs 30–80 MB of resident memory plus a Redis connection. At 5,000 subscribers that is 5,000 processes and roughly 250 GB of memory doing almost nothing — the design collapses long before Redis does. The break-even point in practice sits in the low hundreds of subscribers: below it, dedicated lanes are simple and worth it; above it, hash subscribers into a fixed number of shard lanes and accept isolation that is approximate rather than absolute.

Dedicated lanes versus hashed shard lanes A five-row matrix comparing isolation granularity, queue count at five thousand subscribers, blast radius, worker processes and the subscriber count each strategy suits. Property Queue per subscriber Hashed shard lanes Isolation granularity one subscriber one hash bucket Lanes at 5,000 subs 5,000 64 One hang delays that subscriber only its bucket peers Worker processes one per subscriber one per bucket Fits a fleet of tens to hundreds thousands and up
Sharding trades exact isolation for a bounded worker count: a hang now delays the handful of subscribers sharing that bucket instead of the entire fleet.

Sharding is a one-line change to the lane function, but the hash choice matters more than it looks. Python’s built-in hash() is salted per process unless PYTHONHASHSEED is pinned, so the same subscriber lands in different buckets on different workers and after every restart — which silently destroys both per-lane ordering and any monitoring keyed on the lane. Use an explicit, stable digest:

import hashlib

SHARD_COUNT = 64

def lane_for(subscriber_id: str) -> str:
    """Stable across processes, restarts and interpreter versions."""
    digest = hashlib.blake2b(subscriber_id.encode(), digest_size=8).digest()
    bucket = int.from_bytes(digest, "big") % SHARD_COUNT
    return f"deliver:shard{bucket:02d}"

Pick SHARD_COUNT from your worker budget rather than your subscriber count, and set it once: a change re-maps every subscriber, so jobs already queued under the old scheme keep their old lane while new jobs go elsewhere, and any ordering guarantee is void for the duration. If you expect to grow, start with a count comfortably above what you need (64 or 128 lanes is cheap) rather than resharding later. The remaining exposure is a noisy bucket: if a large subscriber and a chronically slow one hash together, the slow one delays the large one. Give known-slow endpoints an explicit override to their own dedicated lane and let the hash handle everyone else — the override list stays short precisely because slow endpoints are rare.

What to watch per lane

Fan-out monitoring fails in a specific way: fleet-wide aggregates look perfect while an individual lane is entirely stuck, because a stalled lane produces no failures — it produces nothing at all. Every signal below is therefore computed per lane and alerted per lane.

Signal Computed as Investigate when What it usually means
Lane backlog age Now minus the enqueue time of the oldest job in the lane Above 5 minutes The endpoint is hanging rather than failing; nothing has errored yet
Lane depth trend Jobs added minus jobs completed over 5 minutes Positive for 15 minutes Delivery is slower than production for that subscriber; the lane will never catch up on its own
Attempts per delivery Total attempts ÷ delivered records, per subscriber Above 1.5 The endpoint is flapping, usually rate limiting or an overloaded database behind it
Records stuck in sending Count of delivery records in sending older than the job timeout Any non-zero count Workers are being killed mid-attempt, or job_timeout is shorter than the HTTP timeout
Fan-out ratio Jobs enqueued ÷ events ingested Drops below the subscriber count Fan-out crashed partway through and some subscribers were skipped

The last row is the one worth building first. It is the only signal that catches a partially completed fan-out, and a partial fan-out is invisible from every other angle: the event is stored, the deliveries that were enqueued all succeed, and the dashboard is green while a subscriber never learns the event happened.

Verification

A unit test should confirm that one ingested event produces exactly one job per matching subscriber, on distinct queues.

from rq import Queue
import fakeredis, json

def test_fan_out_enqueues_one_job_per_subscriber():
    fake = fakeredis.FakeStrictRedis(decode_responses=True)
    fake.sadd("subs:order.created",
              json.dumps({"id": "a", "url": "http://a", "secret": "s"}),
              json.dumps({"id": "b", "url": "http://b", "secret": "s"}))
    # ... wire fan_out to `fake`, then:
    event = {"id": "e1", "type": "order.created", "body": {}}
    fan_out(event)
    assert Queue("deliver:a", connection=fake).count == 1
    assert Queue("deliver:b", connection=fake).count == 1

To prove isolation, point one subscriber at a sink that sleeps and confirm the other still receives promptly:

# Slow endpoint for sub A; healthy endpoint for sub B.
curl -fsS -X POST localhost:8000/events \
  -H 'content-type: application/json' \
  --data '{"type":"order.created","order_id":"ord_1"}'
# sub B's worker should mark "delivered" while sub A is still retrying.

Failure modes and gotchas

Frequently Asked Questions

How many subscriber queues is too many?

The limit is worker processes rather than queues. Redis holds tens of thousands of list keys without noticing, but a dedicated worker per lane costs 30 to 80 MB of resident memory and a connection.

A few hundred lanes is where a machine starts to feel it; past that, map subscribers onto a bounded set of shard lanes and settle for isolation that is approximate rather than absolute.

Should the job carry the payload or just the event id?

Carry the id. A short reference keeps a 500,000-job backlog in the tens of megabytes instead of gigabytes, and it guarantees that a retry three hours later signs exactly the bytes that were stored at ingest.

The one cost is a read per attempt, which is cheap next to an HTTP round trip.

Does one worker per subscriber guarantee ordered delivery to that subscriber?

Only if the lane runs a single worker with a concurrency of one and a failed attempt blocks the lane. Two workers on the same queue, or one worker with more than one job in flight, will reorder events whenever the first attempt is retried.

Ordering therefore costs throughput, so enable it per subscriber rather than globally.

How do we stop a huge fan-out from starving small subscribers?

Bound the enqueue burst and reserve capacity. Chunking the enqueue keeps ingest responsive during a broadcast, and reserving a minimum worker slice per shard keeps a low-volume subscriber from waiting behind a mass broadcast that happens to hash into the same lane.

Where should the retry policy live: the queue or the delivery code?

On the delivery record, as data. Queue-level retry defaults are invisible during an incident and differ between brokers, whereas an explicit attempt count and next-attempt timestamp can be inspected, paused for one subscriber, and replayed by an operator without redeploying anything.

Can two events for the same subscriber be delivered concurrently?

Yes, and by default they are — running two workers on one lane doubles throughput for a busy subscriber. Do it deliberately: concurrency within a lane is what makes a large subscriber keep up, and it is also what removes any ordering guarantee for that subscriber.

Record the choice on the subscription so the delivery worker, not the operator, enforces it.