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.
Implementation Pathways
Define explicit SLA boundaries before routing traffic:
- Sync Threshold:
<2send-to-end blocking. Suitable for real-time authorization, payment confirmation, or synchronous validation gates. - Async Threshold:
>2sor eventual consistency. Required for bulk data synchronization, background job triggers, or cross-region replication. - Mixed-Mode Routing: Implement a dispatcher layer that evaluates event metadata to route to sync or async pipelines dynamically.
# 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_max2. Enable X-Request-Start tracing3. 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 limits3. 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 events2. 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 span2. Assert exactly one terminal state per event id 3. Cap fallback depth at a single hop |
Security Controls
- Strict TLS 1.3 Enforcement: Disable legacy cipher suites at the ingress layer.
- Consumer Endpoint Validation: Cross-reference DNSSEC records and maintain IP allowlists for known consumer CIDRs.
- Request Size Capping: Enforce
Content-Lengthlimits at the reverse proxy to mitigate payload-based DoS.
# 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.
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
- Timeout Configuration: Enforce connect
500msand read1500mslimits to prevent thread starvation. - Circuit Breakers: Deploy stateful breakers with
closed → open → half-opentransitions. Allow a limited number of probe requests in half-open state before full recovery. - Retry Avoidance: Disable automatic retries on sync endpoints. Implement explicit
429 Too Many Requestsor503 Service Unavailableresponses instead of thundering herd amplification.
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.
# 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_timeout2. Inject X-Downstream-Latency headers3. 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 headers3. 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_queue2. Scale pool dynamically via max_connections_per_host3. 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 timestamp2. Raise half_open_max_calls to at least 33. Exempt probe requests from the load-shedding filter |
Security Controls
- Mutual TLS (mTLS): Require client certificates for endpoint authentication.
- Strict Content-Type Validation: Reject non-
application/jsonpayloads at the edge. - Token Bucket Rate Limiting: Enforce per-consumer IP quotas to prevent resource exhaustion.
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
- Durable Message Brokers: Deploy SQS, Kafka, or Redis Streams with replication factors ≥3.
- Delivery Agents: Implement configurable retry matrices with jitter to prevent synchronized backoff storms.
- Dead-Letter Queue (DLQ) Routing: Route unprocessable events after
max_retriesto isolated queues for forensic analysis.
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-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 configuration2. 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_id2. 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 > threshold3. 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 topic2. Partition by tenant_id with a bounded pool per partition3. Reserve a minimum concurrency slice per tenant |
Security Controls
- HMAC-SHA256 Payload Signing: Rotate signing secrets quarterly; include
X-SignatureandX-Timestampheaders. - Timestamp Validation Windows: Reject payloads with
abs(current_time - payload_timestamp) > 5 minutes. - JWKS-Based Verification: For multi-tenant routing, fetch public keys dynamically via JWKS endpoints.
# 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
- OpenTelemetry Instrumentation: Emit spans covering producer enqueue → delivery agent dispatch → consumer ACK. Track
webhook.delivery.latencyandwebhook.retry.count. - Reconciliation Dashboards: Visualize success, retry, and DLQ rates with SLO burn rate alerts.
- Progressive Backoff Pausing: Automatically quarantine endpoints returning
5xxfor >3 consecutive minutes; resume with a probe request after health check passes.
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.
# 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_id → region)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_tier3. 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 LOCKED2. 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 region2. Alert on per-endpoint success rate, not the fleet mean 3. Page on error-budget burn rate rather than raw failure counts |
Security Controls
- Audit Logging: Record all delivery state transitions (
queued → dispatched → acked → dlq) with immutable storage. - RBAC for Webhook Configuration: Restrict endpoint registration and secret rotation to
webhook-adminroles. - Automated Secret Rotation: Implement zero-downtime rotation using dual-secret validation windows (old + new active for 24h).
| 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.
- 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.
- 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
504while the application still holds a worker thread. - 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. - 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.
- 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
- Every registered event type resolves to exactly one delivery mode in the routing table
- Proxy
proxy_read_timeoutis strictly greater than the application read timeout - Broker replication factor is ≥3 and
acks=allis set on the producer - A replay of one dead-lettered event reaches the consumer with its original idempotency key
- Delivery spans join producer, agent, and consumer under one trace id
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.
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.