Sync vs Async Webhooks: Architectural Trade-offs & Decision Framework

This comparison sits within Webhook Architecture Fundamentals & Design Patterns, where the choice between synchronous request-response cycles and asynchronous event-driven delivery is one of the earliest and most consequential design decisions. Synchronous webhooks enforce blocking execution where the producer awaits an immediate HTTP status code and response payload before proceeding. Asynchronous webhooks decouple transmission from processing, relying on persistent queues, delivery agents, and eventual consistency. Selecting between them requires strict evaluation of latency tolerance thresholds, payload constraints, and consumer availability SLAs. Before committing to either, weigh both against the transport alternatives compared in webhooks vs polling vs WebSockets, since a push model is only worth its operational cost when the consumer genuinely needs sub-minute delivery. Grounding these delivery paradigms in established architectural expectations ensures baseline reliability and prevents architectural drift during scaling.

Synchronous callback vs asynchronous queued delivery Top lane shows a producer blocking on a direct HTTP call to a consumer; bottom lane shows a producer enqueuing to a broker that a delivery agent drains with retries. Synchronous callback (blocking) Producer thread waits POST + await 2xx (<2s) Consumer responds inline Asynchronous queued (decoupled) Producer returns 202 Broker durable queue Delivery agent backoff + retry Consumer retry on 5xx, then dead-letter
Synchronous callbacks block the producer on a single round trip; asynchronous delivery enqueues to a broker that a delivery agent drains with independent retry and dead-letter handling.

Implementation Pathways

Define explicit SLA boundaries before routing traffic:

# dispatcher-config.yaml
routing_rules:
  - event_type: "payment.authorize"
    mode: sync
    timeout_ms: 1500
    fallback: async_dlq
  - event_type: "user.profile.updated"
    mode: async
    queue: "profile_events_v2"
    retry_matrix: [500, 1000, 2000, 4000, 8000]

Failure Mode Analysis & Troubleshooting

Failure Mode Root Cause Diagnostic Steps
Thread pool exhaustion under load spikes Sync endpoints blocking worker threads during consumer GC/network latency 1. Monitor http_server_active_connections vs thread_pool_max
2. Enable X-Request-Start tracing
3. Implement async offloading for non-critical paths
Queue saturation during consumer outages Async producers outpacing consumer drain rate 1. Check broker lag metrics (consumer_lag)
2. Verify max_inflight_messages limits
3. Enable backpressure signaling to producers
Hybrid state desynchronization Partial sync success followed by async fallback with divergent payloads 1. Audit state transition logs for sync_to_async_fallback events
2. Implement distributed transaction IDs (X-Trace-Id)
3. Run reconciliation jobs against source-of-truth DB
Fallback loops between the two modes Dispatcher demotes a failed sync call to async without recording a terminal attempt 1. Emit dispatch.mode on every attempt span
2. Assert exactly one terminal state per event id
3. Cap fallback depth at a single hop

Security Controls

# nginx.conf snippet
server {
    listen 443 ssl;
    ssl_protocols TLSv1.3;
    client_max_body_size 2M;
    if ($request_method !~ ^(POST)$) { return 405; }
}

Capacity Arithmetic Behind the Mode Choice

The mode debate is usually argued in adjectives — “real-time”, “decoupled” — when it is settled by two pieces of arithmetic. The first is Little’s Law: the number of in-flight requests a producer must hold is the arrival rate multiplied by the time each request is held. At 400 events per second with a consumer whose median response is 60 ms, a synchronous path needs roughly 24 concurrent slots, which sounds free. Recompute it at the consumer’s p99 of 900 ms and the same traffic needs 360 slots. Six pods running 64 worker threads each give you 384, so a perfectly ordinary tail latency puts the fleet at 94% utilisation, and above roughly 85% utilisation queueing delay stops being linear and starts doubling with each small increment of load. Size synchronous pools against the consumer’s p99 and multiply by two for headroom; if arrival_rate × p99 × 2 exceeds your thread budget, the event type cannot be synchronous at that volume no matter how clean the code is.

The asynchronous path changes the unit rather than the amount of work. A producer that writes to a broker and returns 202 holds its thread for the duration of one durable append — 3 to 5 ms on a replicated topic — so the same 400 events per second occupy about two concurrent slots. The concurrency has not vanished; it has moved into a delivery agent pool that can be sized, scaled, and throttled independently of the API that accepted the request. That independence is the actual product of the async design, and it is why the failure of a single consumer stops being an availability event for the producer.

Where the concurrency goes in each mode A five-row matrix comparing arrival rate, thread hold time, concurrent slots, pod count and stall accumulation for synchronous callbacks against asynchronous enqueue at the same traffic level. Capacity question Sync callback Async enqueue Events per second 400 400 Thread held per event 900 ms at p99 4 ms append Concurrent slots needed 360 2 Pods at 64 workers each 6 pods, 94% used 1 pod, 3% used A consumer stall lands in the thread pool queue depth
Both modes carry the same event rate; the difference is whether the consumer's tail latency is charged to your thread pool or to broker storage.

The second piece of arithmetic is recovery. When a consumer is unavailable for T seconds at arrival rate λ, the backlog is λ × T, and it drains at the surplus between delivery capacity μ and ongoing arrivals. A 20-minute outage at 400 events per second leaves 480,000 queued events; a delivery fleet capable of 700 per second clears them at a surplus of 300 per second, so recovery takes 1,600 seconds — 27 minutes, longer than the outage that caused it. Provision delivery capacity at 1.5× to 2× steady-state arrival specifically so that recovery is shorter than the incident, and publish that ratio as a design constraint. Teams that size delivery workers for the average rate discover during their first real outage that the backlog never converges within the business day.

The observable symptom of each saturation mode

Saturation looks completely different depending on the mode, and knowing which graph moves first is half of the triage. A synchronous path that has run out of threads shows itself as latency on unrelated endpoints: because the pool is shared, a partner’s outage raises p99 on your login route and your health check simultaneously. The signature is two latency graphs with no logical relationship moving in lockstep, followed by the orchestrator restarting pods whose liveness probes timed out — which removes capacity from an already saturated fleet and turns a partner incident into a self-inflicted outage. The structural fix is a bulkhead: give every outbound dependency its own bounded connection and thread pool, capped at roughly 25% of the total, so that no single partner can consume the whole budget.

An asynchronous path saturates silently. Producer latency stays flat, error rates stay at zero, and the only moving number is consumer lag, which nobody looks at unless it is on a dashboard with a threshold attached. The failure has not disappeared; it has changed from a visible error into invisible staleness, which is worse for a system whose users make decisions on the data. That is why an async design is only complete once it has an explicit end-to-end freshness objective — the age of the oldest unacknowledged event — rather than a success-rate metric that a stalled queue can keep at 100% indefinitely by not attempting anything.


Synchronous Callback Implementation & Resilience Patterns

Synchronous callbacks execute blocking HTTP POST operations where the producer thread waits for consumer acknowledgment. This model demands aggressive connection pooling, strict timeout enforcement, and circuit breaker integration to prevent cascading failures. When aligning architectural selection with business-critical latency requirements and failure tolerance thresholds, reference When to use synchronous callbacks vs async webhooks to validate operational boundaries.

Implementation Pathways

The timeouts only hold if every hop in the path agrees on the same budget. The sequence below traces one blocking callback and shows where the 2-second wall clock is spent.

Synchronous callback timeout budget A producer thread, an edge proxy and a consumer application exchange one request and one response inside a two second wall-clock budget, after which the circuit breaker opens instead of retrying. Producer thread Edge proxy Consumer app POST /callback connect 500 ms cap 200 OK, read 1500 ms result released Budget: 500 ms connect + 1500 ms read = 2 s wall clock Exceeded: breaker opens, load is shed, nothing is retried
Proxy and application timeouts must be derived from one shared budget, otherwise the proxy returns 504 while the application is still holding a worker thread.
# Python httpx client configuration
import httpx
from circuitbreaker import circuit

@circuit(failure_threshold=5, recovery_timeout=30, expected_exception=httpx.HTTPStatusError)
def dispatch_sync_callback(url: str, payload: dict) -> httpx.Response:
    with httpx.Client(timeout=httpx.Timeout(connect=0.5, read=1.5)) as client:
        return client.post(
            url,
            json=payload,
            headers={"Content-Type": "application/json", "X-Callback-Mode": "sync"},
        )

Deriving the timeout ladder from one budget

Every hop between the caller and the consumer enforces its own deadline, and the only safe arrangement is a strictly decreasing ladder in which the outermost layer waits longest. A workable default for a 2-second budget is: client-facing request deadline 3 s, dispatcher total 2 s, HTTP read 1.5 s, TCP connect 500 ms, DNS resolution 200 ms. Each step leaves 20–50% slack for the layer below to fail cleanly and report a useful error rather than being cut off mid-flight. Inverting any two rungs produces the single most common synchronous incident: the proxy gives up at 1 s while the application is still willing to wait 2 s, the caller sees 504, and the application keeps a worker thread and a database transaction alive for another full second serving a response nobody will read. Under load that orphaned second is the difference between a pool at 60% and a pool at 100%.

The ladder needs to be enforced, not documented. Put the budget in one configuration value, derive every timeout from it in code, and add a startup assertion that fails the process if the effective proxy timeout is not strictly greater than the application timeout — a crash at deploy time is far cheaper than discovering the inversion during a partner’s bad afternoon. Retries deserve the same scepticism. A synchronous endpoint should not retry internally, because a retry inside a blocking call multiplies the thread-hold time by the attempt count and converts a slow consumer into a fast outage. Reject with 503 and a Retry-After header and let the caller decide, or demote the event to the queue where retry scheduling is somebody’s explicit job.

Load shedding belongs at the front of this ladder rather than the back. Once the outbound pool for a given consumer is above 80% utilisation, reject new synchronous work for that consumer immediately with 429 instead of queuing it inside the process. Rejecting in 1 ms preserves the threads that are still serving healthy traffic; queuing internally means every arrival waits the full budget before failing anyway, which is the same amount of failure delivered much more expensively. Exempt circuit breaker probes from this filter, or the breaker can never gather the successful calls it needs to close.

Failure Mode Analysis & Troubleshooting

Failure Mode Root Cause Diagnostic Steps
HTTP 504 Gateway Timeouts masking downstream failures Reverse proxy timeout exceeds application timeout 1. Align proxy proxy_read_timeout with app read_timeout
2. Inject X-Downstream-Latency headers
3. Enable structured proxy error logging
Partial commit states mid-processing Consumer crashes after DB write but before HTTP 200 response 1. Implement two-phase commit or compensating transactions
2. Require X-Idempotency-Key in sync headers
3. Audit consumer crash dumps for uncommitted state
Connection pool starvation under concurrent bursts Pool size < concurrent sync requests 1. Monitor pool_idle_connections and pool_wait_queue
2. Scale pool dynamically via max_connections_per_host
3. Implement request shedding at 80% pool utilization
Breaker never closes after the consumer recovers Half-open probe budget too small to prove health under real concurrency 1. Log every breaker.state transition with a timestamp
2. Raise half_open_max_calls to at least 3
3. Exempt probe requests from the load-shedding filter

Security Controls


Asynchronous Webhook Delivery Architecture & Queue Management

Asynchronous delivery decouples producer availability from consumer processing capacity through persistent event queuing, delivery agent routing, exponential backoff, and cryptographic signature verification. Aligning payload structure with Event Schema Design ensures consistent parsing across distributed retry cycles and versioned consumer endpoints. When a single event must reach many subscribers, the async model is also the foundation for designing webhook fan-out architectures, where one enqueue spawns per-subscriber delivery jobs that each carry their own retry and backpressure state.

Implementation Pathways

Every queued event moves through a small, explicit set of states, and the delivery agent — not the producer — owns the transitions between them. Persisting the current state per event is what makes replay, auditing, and “did subscriber X get this?” answerable after the fact.

Delivery job state machine A queued delivery job moves from queued to in flight, then to delivered on a 2xx, or to retry wait on failure, and finally to dead-letter once the attempt budget is exhausted. Queued durable, unleased In flight HTTP POST open Delivered 2xx, offset committed Retry wait backoff + jitter Dead-letter budget exhausted lease 2xx acknowledged 5xx or timeout delay elapsed attempts exhausted operator replay, same idempotency key
Only the delivery agent advances this machine; replay from the dead-letter queue re-enters at Queued so the consumer sees the original idempotency key twice and deduplicates.
# delivery-agent-config.yaml
broker: kafka
topics: ["webhooks.outbound"]
retry_policy:
  max_attempts: 5
  backoff: exponential
  jitter: true
  base_delay_ms: 1000
dlq:
  enabled: true
  topic: "webhooks.dlq"
  retention_hours: 720

Parallelism, ordering, and the in-flight window

Queued delivery buys throughput by processing events concurrently, and concurrency is exactly what destroys ordering. The two are the same dial turned in opposite directions, so decide per event type which one you are buying. A topic partitioned 32 ways with the partition key set to tenant_id gives you 32-way parallelism across the fleet but strictly one-way parallelism within a tenant, which is usually the right trade: subscribers care that their own events arrive in order and are indifferent to interleaving with other tenants. The consequence to plan for is that a tenant generating 40% of your volume is pinned to a single partition and therefore to a single consumer thread, so their sustained throughput ceiling is one partition’s worth no matter how many workers you add. Chart lag per partition, not per topic, or that ceiling is invisible until the tenant complains. The mechanics of doing this well are covered in per-key ordering with partitioned queues.

The subtler ordering hazard is the in-flight window. A delivery agent configured with, say, five concurrent requests per endpoint will happily start attempt 1 of event B while attempt 2 of event A is still in backoff, and A then lands after B despite being produced first. No amount of partition keying fixes this, because the reordering happens inside the agent rather than in the broker. If ordering matters for an endpoint, the in-flight window per ordering key must be exactly one and a failed attempt must block its key until it resolves — which means a single stuck event stalls that key’s stream, and you need an explicit escape hatch such as parking the event after N attempts and advancing. If ordering does not matter, say so explicitly in the subscription record and let the window widen, because the throughput difference between a window of one and a window of eight is close to linear.

Both settings interact with retries in a way worth stating plainly: at-least-once delivery plus any window greater than one means consumers will see duplicates and out-of-order arrivals in the same incident. That combination is survivable only if the consumer treats the event as a fact with a timestamp rather than an instruction to apply, which is the practical argument for versioned, self-describing payloads over deltas.

Failure Mode Analysis & Troubleshooting

Failure Mode Root Cause Diagnostic Steps
Duplicate delivery due to network partition/ACK timeout Producer retries before consumer ACK commits 1. Verify broker acks=all configuration
2. Implement deduplication windows at consumer
3. Trace X-Message-ID across producer/consumer logs
Out-of-order processing during uneven consumer scaling Partition rebalancing without strict ordering keys 1. Use consistent hashing on tenant_id or entity_id
2. Disable auto-rebalance during peak traffic
3. Implement sequence number validation in consumers
DLQ overflow causing silent event loss DLQ retention policy too short or consumer not draining 1. Set DLQ retention to ≥30 days
2. Alert on dlq_queue_depth > threshold
3. Deploy automated replay workers for DLQ items
Low-volume tenants starved by a hot partition One shared worker pool drains the busiest partition first 1. Chart consumer_lag per tenant, not per topic
2. Partition by tenant_id with a bounded pool per partition
3. Reserve a minimum concurrency slice per tenant

Security Controls

# HMAC verification middleware
import hmac
import hashlib
import time

def verify_signature(
    payload: bytes, signature: str, timestamp: str, secret: bytes
) -> bool:
    if abs(time.time() - int(timestamp)) > 300:
        return False
    expected = hmac.new(secret, payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Operational Workflows, Monitoring & Incident Response

Observability pipelines, delivery success rate tracking, and automated consumer quarantine logic form the operational backbone of webhook infrastructure. Integrate Idempotency in Webhooks to guarantee safe processing during async retry storms and network-induced duplicate deliveries.

Implementation Pathways

Quarantine is a time-boxed decision, and the window matters more than the threshold: pause too early and a two-second blip strands a healthy consumer, pause too late and the queue absorbs minutes of doomed traffic. The window below is the shape most delivery fleets converge on.

Endpoint quarantine window A timeline showing a healthy endpoint entering a 5xx streak, being quarantined, then recovering after a single probe request succeeds. Automatic quarantine window for one consumer endpoint consecutive 5xx observed probe gates recovery no traffic sent, backlog grows healthy 5xx streak quarantined probe, then resume t0 t+30s t+3m t+8m t+9m A failed probe restarts the quarantine window instead of resuming full traffic.
Quarantine converts a failing endpoint from a source of retry load into a bounded backlog, and a single probe — not a timer alone — decides when delivery resumes.
# OpenTelemetry span instrumentation around one delivery attempt
import httpx
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode

tracer = trace.get_tracer("webhook.delivery")

def deliver_with_span(url: str, event: dict, attempt: int, body: bytes) -> int:
    with tracer.start_as_current_span("webhook.delivery") as span:
        span.set_attribute("event.type", event["type"])
        span.set_attribute("event.id", event["id"])
        span.set_attribute("consumer.endpoint", url)
        span.set_attribute("retry.attempt", attempt)
        try:
            resp = httpx.post(url, content=body, timeout=10.0)
            resp.raise_for_status()
        except httpx.HTTPError as exc:
            span.record_exception(exc)
            span.set_status(Status(StatusCode.ERROR, "delivery_failed"))
            raise
        span.set_attribute("http.status_code", resp.status_code)
        span.set_status(Status(StatusCode.OK))
        return resp.status_code

Signals worth waking someone for

Most webhook dashboards measure what is easy to collect rather than what predicts customer harm. The five signals below are the ones that reliably move before an integration breaks, together with thresholds that hold for a mid-volume fleet; treat the numbers as a starting point to be re-derived once you have a month of your own baseline data.

Signal How it is computed Page when Why that threshold
Oldest unacknowledged event age max(now - enqueued_at) over undelivered events, per consumer Above 5 minutes for a tier-1 consumer Denominated in the same unit as the freshness objective, and unaffected by traffic volume
Per-endpoint success rate 2xx responses ÷ attempts in a 5-minute window, grouped by endpoint Below 95% for 10 consecutive minutes The fleet mean stays green while one subscriber is fully broken
Error-budget burn rate Observed error rate ÷ the rate the SLO permits, over 1-hour and 6-hour windows 14.4× on the 1-hour window and 6× on the 6-hour window together Burning 2% of a 30-day budget in an hour is a genuine emergency; the second window suppresses one-off spikes
Outbound pool utilisation In-use connections ÷ pool maximum, per consumer Above 80% for 5 minutes Queueing delay stops being linear near 85%, so this fires before latency does
Dead-letter arrival rate Events entering the dead-letter topic per minute Any non-zero rate sustained for 15 minutes Steady dead-lettering is a schema or authentication break, never transient congestion

Two rules keep this list from becoming noise. First, page on rates and ages, never on counts: “1,000 failures” is meaningless without knowing whether the denominator was 1,200 or 12 million, and count thresholds silently become stricter as the business grows. Second, route by consequence rather than by severity label — a single subscriber below 95% is a ticket for whoever owns that integration, while the burn-rate alert across many subscribers is a page for the on-call engineer, because only the second one indicates the platform itself is the problem. Alerts that cannot be acted on at 03:00 belong in a daily digest, and moving them there is what buys credibility for the ones that remain.

Failure Mode Analysis & Troubleshooting

Failure Mode Root Cause Diagnostic Steps
Metric cardinality explosion High-volume event streams with unbounded label combinations 1. Aggregate labels at ingestion (tenant_idregion)
2. Drop high-cardinality attributes (request_id)
3. Implement metric sampling for >10k EPS
Alert fatigue masking pipeline degradation Thresholds misaligned with baseline traffic patterns 1. Use SLO-based error budget alerting
2. Implement alert grouping by consumer_tier
3. Suppress alerts during scheduled maintenance windows
Reconciliation job deadlocks during schema migrations Concurrent DB locks on event state tables 1. Use advisory locks or SELECT FOR UPDATE SKIP LOCKED
2. Run reconciliation in read-only mode during migrations
3. Implement idempotent upserts with ON CONFLICT DO UPDATE
Fleet-wide success rate hides a single-consumer outage SLI averaged across every endpoint at once 1. Break the SLI down by consumer_tier and region
2. Alert on per-endpoint success rate, not the fleet mean
3. Page on error-budget burn rate rather than raw failure counts

Security Controls

Control Implementation Checklist
TLS 1.3 [ ] Cipher suite hardened
[ ] HSTS headers enforced
mTLS / HMAC [ ] Client certs provisioned
[ ] HMAC rotation automated
Rate Limiting [ ] Token bucket deployed
[ ] Backpressure signaling active
Observability [ ] OTel spans exported
[ ] DLQ alerts configured
Idempotency [ ] X-Idempotency-Key enforced
[ ] Deduplication window validated

Mixed-Mode Rollout Sequence

Run these steps in order when introducing both delivery modes behind one dispatcher. Each step depends on the artefacts produced by the previous one, and skipping the classification step is what produces the hybrid desynchronization failures tabled above.

  1. Classify each event type by acknowledgment need: Decide, per event type, whether the caller must consume the consumer’s result before proceeding. Record the answer in the dispatcher routing table rather than in application code, so the mode is auditable and changeable without a deploy.
  2. Set the sync latency budget and timeout ladder: Fix one wall-clock budget for synchronous events, then derive connect, read, and reverse-proxy timeouts from it. No layer may wait longer than the layer above it, or the proxy returns 504 while the application still holds a worker thread.
  3. Provision the durable queue and delivery agent: Stand up a replicated broker topic for asynchronous events and a delivery agent that owns retry scheduling independently of the producer. The producer’s only obligation is a durable enqueue and a 202.
  4. Wire dead-letter routing and replay: Route events that exhaust their attempt budget to a dead-letter topic with long retention, and build the replay worker at the same time — a dead-letter queue with no drain path is just delayed data loss.
  5. Instrument delivery spans and SLO alerts: Emit spans from enqueue through consumer acknowledgment, then alert on error-budget burn rate broken down per consumer. Fleet averages hide single-tenant outages, as the observability failure table shows.

Rollout Verification Checklist

Moving an Event Type Between Modes Without a Flag Day

Changing an existing event type from a blocking callback to a queued webhook is a semantic change for every integrator, not a refactor, because the caller loses the ability to read the outcome in the same request. Doing it in one deploy means discovering all the callers who silently depended on that outcome during the incident review. The safe sequence is shadow, compare, cut over, retire — and it hinges on the routing entry carrying enough fields to describe an in-between state rather than a boolean.

Anatomy of a dispatcher routing entry during migration A routing entry for one event type is broken out into mode, timeout, shadow target and fallback fields, each annotated with the operational role it plays during a migration. One routing entry, four reversible decisions event_type: payment.authorize mode: sync timeout_ms: 1500 shadow: async_v2 fallback: async_dlq flipped without a code deploy stays under the proxy deadline mirrors traffic, result discarded one hop only, then terminal Rollback is editing one field, so the migration never needs a release train.
Because mode, shadow target and fallback are data rather than code, a migration can be advanced or reversed in seconds and the current state of every event type is auditable in one place.

In the shadow phase the synchronous path stays authoritative while every event is additionally enqueued onto the new topic, with the delivery agent posting to a sink that records the outcome and throws it away. Run it for at least one full weekly traffic cycle: batch jobs, month-end spikes and the partner’s own maintenance window all produce behaviour that a Tuesday afternoon soak test never sees. The comparison you care about is not “did both paths succeed” but “did they produce the same outcome for the same event id”, so join the two outcome streams on the event id and alert on divergence above 0.1%. Divergence almost always turns out to be a real semantic difference — different serialisation, a header the async path forgot, an ordering assumption — and finding it here costs a bug ticket instead of a customer incident.

Cut over one segment at a time, weighted by blast radius rather than by convenience: internal consumers first, then low-volume external subscribers, then the largest partner last. Hold each segment for 24 hours before advancing, because the failure that matters most — a consumer whose nightly reconciliation job now runs before the events have arrived — only appears once a full daily cycle has elapsed. Define the rollback trigger numerically before you start, for example “flip back if per-endpoint success rate stays under 99% for 15 minutes or the oldest unacknowledged event exceeds 10 minutes”, and make sure flipping back is a routing-table edit that any on-call engineer is authorised to perform without a deploy.

Retirement is the step teams skip and regret. Leaving the synchronous path wired up “just in case” means two writers with different retry semantics remain live, and the next incident produces a consumer that received the event twice with different bodies because one path had a newer serialiser. Once a segment has been stable for a week, delete the synchronous branch, keep only the fallback entry, and record the date in the routing table so the next engineer can tell an intentional hybrid from an abandoned migration.

Frequently Asked Questions

How many worker threads does one synchronous callback actually cost?

Multiply the arrival rate by the consumer's p99 response time, not its median. At 400 events per second and a 900 ms p99 you need roughly 360 simultaneously blocked threads before any headroom, which is more than most application pools are configured for.

Size against the tail and double it, and if the result exceeds your thread budget the event type cannot stay synchronous at that volume.

Is returning 202 from the producer a delivery guarantee?

Only if the durable enqueue completed before the status line was written. A 202 emitted from an in-process background task or a fire-and-forget thread is a promise the producer cannot keep across a restart, and the loss is silent because nothing ever retries it.

Write to the broker or an outbox table first, then respond.

Do synchronous callbacks still need idempotency keys?

Yes. A timeout is ambiguous: the consumer may have committed the write and lost the response on the way back. Any caller-side fallback that re-sends, including a human clicking retry, replays work that already happened unless the request carried a stable key the consumer can deduplicate on.

When does queue depth stop being a useful alert signal?

As soon as arrival rate varies. A depth of 50,000 is healthy during a nightly batch and catastrophic at 03:00 on a Sunday, so a fixed threshold either pages constantly or never fires.

Alert on the age of the oldest unacknowledged event instead, because that number is denominated in the same unit as your delivery objective.

What should the dispatcher do when a synchronous call times out?

Demote the event to the asynchronous path exactly once and record a terminal outcome for the synchronous attempt. Unbounded demotion produces fallback loops where the same event oscillates between modes and appears twice in every report.

Cap fallback depth at a single hop and emit the dispatch mode as a span attribute so the hop is visible in traces.

How long should the dead-letter topic retain events?

Long enough to survive a holiday weekend plus the time it takes to ship a consumer fix, which in practice means 30 days rather than the broker default of one to seven days.

Dead-lettered events are a tiny fraction of volume, so the storage cost is negligible next to the cost of discovering a schema break after the evidence expired.

Can one event type use both delivery modes at the same time?

Yes, and it is the safest way to migrate one. Run the asynchronous path in shadow while the synchronous path remains authoritative, compare the two outcome streams by event id, and only then flip the routing entry.

Keeping both paths live permanently is the problem, because two writers with different retry semantics eventually disagree.