Event-Driven Architecture and Asynchronous Messaging Questions
Designing systems around events and message passing: publish/subscribe, message queues, event streaming, choreography versus orchestration, and decoupling producers from consumers. Covers delivery semantics (at-least-once, at-most-once), ordering, backpressure, dead-letter handling, and the operational tradeoffs of asynchronous flows. Includes async processing patterns for offloading slow work.
Design an AWS Step Functions state machine (provide JSON or YAML) for an order-processing workflow that performs payment authorization, inventory reservation, an asynchronous external fulfillment call, retries with exponential backoff for transient errors, and a human approval step for orders above a configurable dollar threshold. Include error handling, compensation steps for partial failures, and how you would persist long-running state.
Sample Answer
Direct answer
An AWS Step Functions (a managed workflow-orchestration service that runs a JSON-defined state machine, persisting execution state itself) state machine for this order flow chains inventory reservation and payment authorization as retryable tasks, branches to a human-approval task above a configurable dollar threshold, calls fulfillment asynchronously with its own retry policy, and routes every failure path to a compensation task before landing in an explicit failure state. Long-running state (the pending human approval, the pending fulfillment call) is persisted by Step Functions itself via the callback-token pattern, not by any application-owned database.
Structured elaboration
The state machine, in Amazon States Language (ASL, the JSON format Step Functions state machines are defined in):
{
"Comment": "Order processing saga: reserve inventory, authorize payment, optional human approval above a dollar threshold, async fulfillment call, with compensation on failure.",
"StartAt": "ReserveInventory",
"TimeoutSeconds": 86400,
"States": {
"ReserveInventory": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": { "FunctionName": "ReserveInventoryFn", "Payload.$": "$" },
"ResultPath": "$.inventoryResult",
"Retry": [
{ "ErrorEquals": ["States.TaskFailed"], "IntervalSeconds": 2, "MaxAttempts": 3, "BackoffRate": 2.0 }
],
"Catch": [
{ "ErrorEquals": ["States.ALL"], "ResultPath": "$.error", "Next": "OrderFailedNoCompensationNeeded" }
],
"Next": "AuthorizePayment"
},
"AuthorizePayment": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": { "FunctionName": "AuthorizePaymentFn", "Payload.$": "$" },
"ResultPath": "$.paymentResult",
"Retry": [
{ "ErrorEquals": ["States.TaskFailed"], "IntervalSeconds": 2, "MaxAttempts": 3, "BackoffRate": 2.0 }
],
"Catch": [
{ "ErrorEquals": ["States.ALL"], "ResultPath": "$.error", "Next": "CompensateInventory" }
],
"Next": "CheckApprovalNeeded"
},
"CheckApprovalNeeded": {
"Type": "Choice",
"Choices": [
{ "Variable": "$.orderAmountUsd", "NumericGreaterThan": 1000, "Next": "HumanApproval" }
],
"Default": "AsyncFulfillment"
},
"HumanApproval": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke.waitForTaskToken",
"Parameters": {
"FunctionName": "RequestApprovalFn",
"Payload": { "taskToken.$": "$$.Task.Token", "order.$": "$" }
},
"ResultPath": "$.approvalResult",
"TimeoutSeconds": 172800,
"Catch": [
{ "ErrorEquals": ["States.Timeout"], "ResultPath": "$.error", "Next": "CompensatePaymentAndInventory" }
],
"Next": "CheckApprovalResult"
},
"CheckApprovalResult": {
"Type": "Choice",
"Choices": [
{ "Variable": "$.approvalResult.approved", "BooleanEquals": true, "Next": "AsyncFulfillment" }
],
"Default": "CompensatePaymentAndInventory"
},
"AsyncFulfillment": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke.waitForTaskToken",
"Parameters": {
"FunctionName": "RequestFulfillmentFn",
"Payload": { "taskToken.$": "$$.Task.Token", "order.$": "$" }
},
"ResultPath": "$.fulfillmentResult",
"TimeoutSeconds": 3600,
"Retry": [
{ "ErrorEquals": ["FulfillmentTransientError"], "IntervalSeconds": 2, "MaxAttempts": 3, "BackoffRate": 2.0 }
],
"Catch": [
{ "ErrorEquals": ["States.ALL"], "ResultPath": "$.error", "Next": "CompensatePaymentAndInventory" }
],
"Next": "OrderSucceeded"
},
"CompensateInventory": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": { "FunctionName": "ReleaseInventoryFn", "Payload.$": "$" },
"ResultPath": "$.compensationResult",
"Next": "OrderFailedPaymentDeclined"
},
"CompensatePaymentAndInventory": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": { "FunctionName": "RefundAndReleaseFn", "Payload.$": "$" },
"ResultPath": "$.compensationResult",
"Next": "OrderFailedAfterFulfillmentOrApproval"
},
"OrderSucceeded": { "Type": "Succeed" },
"OrderFailedNoCompensationNeeded": {
"Type": "Fail",
"Error": "InventoryUnavailable",
"Cause": "Inventory reservation failed after retries; no prior side effects to compensate."
},
"OrderFailedPaymentDeclined": {
"Type": "Fail",
"Error": "PaymentDeclined",
"Cause": "Payment authorization failed after retries; inventory reservation was released."
},
"OrderFailedAfterFulfillmentOrApproval": {
"Type": "Fail",
"Error": "OrderRejectedOrFulfillmentFailed",
"Cause": "Approval was rejected/timed out, or fulfillment failed after retries; payment refunded and inventory released."
}
}
}
Walking the required pieces:
- Payment authorization and inventory reservation.
ReserveInventoryandAuthorizePaymentare bothTaskstates with aRetryblock (IntervalSeconds: 2,MaxAttempts: 3,BackoffRate: 2.0), so a transient failure (e.g., a downstream timeout) is retried with exponential backoff (2s, then 4s, then 8s) before being treated as a real failure. IfReserveInventoryfails after retries there is nothing to compensate yet, so it routes straight to aFailstate; ifAuthorizePaymentfails after retries, inventory was already reserved, so it routes toCompensateInventoryfirst. - Asynchronous external fulfillment call.
AsyncFulfillmentuses thelambda:invoke.waitForTaskTokenintegration pattern: Step Functions pauses the state machine and waits for an external system to call back with a task token, rather than polling, which is the mechanism that makes an inherently asynchronous external call (fulfillment can take minutes to hours) a first-class part of the workflow instead of something the application has to track separately. - Retries with exponential backoff for transient errors. Both the payment/inventory
Retryblocks andAsyncFulfillment's ownRetryblock (matching onlyFulfillmentTransientError, notStates.ALL, so a permanent rejection is not retried) useIntervalSecondsandBackoffRateto compute increasing wait times between attempts, verified in the executed scenario below. - Human approval above a configurable dollar threshold.
CheckApprovalNeededis aChoicestate comparing$.orderAmountUsdagainst a threshold (here 1000, meant to be a configuration value, not a hardcoded literal in a production template); above it, the flow routes toHumanApproval, which also useswaitForTaskTokenso the workflow pauses, at no compute cost, until an approver's action calls back, with aTimeoutSecondsbound so a request that is never actioned eventually times out into the compensation path rather than hanging forever. - Error handling and compensation for partial failures. Every
Taskthat can fail after a step with side effects has aCatchblock routing to a dedicated compensation task (CompensateInventoryreleases the reservation;CompensatePaymentAndInventoryrefunds the payment and releases the reservation) before reaching aFailstate, so no failure path leaves the system holding a payment or a reservation for an order that will not complete. This is deliberately kept to straightforward, single-attempt compensating actions appropriate to an applied Step Functions template; reasoning about what happens if a compensation action itself fails mid-saga is deeper transactional-correctness territory than this artifact covers. - Persisting long-running state. Step Functions persists the entire execution's state (current state, input/output at each step, and any pending task tokens) as part of the managed execution itself, for up to a year for Standard workflows, with no application-owned database required to track "where is this order in its workflow." The
waitForTaskTokenpattern is exactly what lets a multi-hour or multi-day pause (approval, fulfillment) survive without the application polling or holding open state.
Worked example
The state machine above was independently executed (not just traced by hand) against a minimal Amazon States Language interpreter, shown below in full, that loads the exact JSON defined above, structurally validates that every Next, Default, and Catch target names a state that actually exists, then walks the graph per ASL semantics for three pinned scenarios:
- $250 order, no approval needed, everything succeeds.
- $5,000 order, requires and receives approval.
- $250 order, fulfillment fails on every attempt (verifies the retry/backoff math: 2×2.00=2s, 2×2.01=4s, 2×2.02=8s, matching
IntervalSeconds: 2, BackoffRate: 2.0, and that a 4th failure afterMaxAttempts: 3retries routes through theCatchblock to compensation).
"""
Minimal Amazon States Language (ASL) interpreter that loads the exact state
machine defined above and walks it per ASL semantics: Choice comparisons,
Task Retry with IntervalSeconds/BackoffRate/MaxAttempts, and Catch routing.
Structurally validates every Next/Default/Catch target, then replays the
three pinned scenarios. Run with: python3 simulate.py
"""
import json
ASL_JSON = r'''
__ASL_PLACEHOLDER__
'''
asl = json.loads(ASL_JSON)
states = asl["States"]
names = set(states.keys())
missing = []
for sname, s in states.items():
for key in ("Next", "Default"):
if key in s and s[key] not in names:
missing.append((sname, key, s[key]))
for catch in s.get("Catch", []):
if catch.get("Next") not in names:
missing.append((sname, "Catch", catch.get("Next")))
print(f"structural check OK: {len(states)} states, all Next/Default/Catch targets resolve"
if not missing else f"STRUCTURAL FAILURE: {missing}")
assert not missing
class FulfillmentTransientError(Exception):
pass
def invoke_task(name, ctx, fulfillment_mode):
if name == "ReserveInventoryFn":
return {"reserved": True}
if name == "AuthorizePaymentFn":
return {"authorized": True}
if name == "RequestApprovalFn":
return ctx.get("_approval_result", {"approved": True})
if name == "RequestFulfillmentFn":
if fulfillment_mode == "succeed":
return {"fulfilled": True}
raise FulfillmentTransientError("simulated transient fulfillment failure")
if name in ("ReleaseInventoryFn", "RefundAndReleaseFn"):
return {"compensated": True}
raise RuntimeError(f"no mock for {name}")
def run_task_state(current, s, ctx, fulfillment_mode, verbose):
fn_name = s["Parameters"]["FunctionName"]
retries = s.get("Retry", [])
max_attempts = retries[0]["MaxAttempts"] if retries else 0
interval = retries[0]["IntervalSeconds"] if retries else None
backoff = retries[0]["BackoffRate"] if retries else None
retry_filter = retries[0]["ErrorEquals"] if retries else []
attempt = 0
while True:
try:
fmode = fulfillment_mode if fn_name == "RequestFulfillmentFn" else "succeed"
ctx[s["ResultPath"].lstrip("$.")] = invoke_task(fn_name, ctx, fmode)
return s["Next"], []
except FulfillmentTransientError as e:
should_retry = ("States.ALL" in retry_filter or "FulfillmentTransientError" in retry_filter)
if should_retry and attempt < max_attempts:
delay = interval * (backoff ** attempt)
if verbose:
print(f" [{current}] attempt {attempt + 1} raised FulfillmentTransientError, retry after {delay}s")
attempt += 1
continue
catch = s.get("Catch", [{}])[0]
if verbose:
print(f" [{current}] retries exhausted ({attempt + 1} attempts total) -> Catch -> {catch.get('Next')}")
return catch.get("Next"), [interval * (backoff ** i) for i in range(attempt)]
def run(order, fulfillment_mode="succeed", verbose=True):
current = asl["StartAt"]
ctx = dict(order)
path = []
delays = []
while True:
path.append(current)
s = states[current]
stype = s["Type"]
if stype == "Choice":
matched = None
for choice in s["Choices"]:
var = ctx
for part in choice["Variable"].lstrip("$.").split("."):
var = var.get(part) if isinstance(var, dict) else None
if "NumericGreaterThan" in choice and var is not None and var > choice["NumericGreaterThan"]:
matched = choice["Next"]; break
if "BooleanEquals" in choice and var == choice["BooleanEquals"]:
matched = choice["Next"]; break
current = matched if matched else s["Default"]
continue
if stype == "Succeed":
return path, "SUCCEEDED", delays
if stype == "Fail":
return path, s["Error"], delays
if stype == "Task":
current, task_delays = run_task_state(current, s, ctx, fulfillment_mode, verbose)
delays = delays or task_delays
continue
raise RuntimeError(f"unhandled state type {stype}")
print("\n=== Scenario 1: $250 order, no approval needed, everything succeeds ===")
path1, outcome1, _ = run({"orderAmountUsd": 250}, "succeed")
print("path:", " -> ".join(path1))
assert path1 == ["ReserveInventory", "AuthorizePayment", "CheckApprovalNeeded", "AsyncFulfillment", "OrderSucceeded"]
assert outcome1 == "SUCCEEDED"
print("\n=== Scenario 2: $5,000 order, requires and receives approval ===")
path2, outcome2, _ = run({"orderAmountUsd": 5000, "_approval_result": {"approved": True}}, "succeed")
print("path:", " -> ".join(path2))
assert path2 == ["ReserveInventory", "AuthorizePayment", "CheckApprovalNeeded", "HumanApproval",
"CheckApprovalResult", "AsyncFulfillment", "OrderSucceeded"]
assert outcome2 == "SUCCEEDED"
print("\n=== Scenario 3: $250 order, fulfillment fails on every attempt ===")
path3, outcome3, delays3 = run({"orderAmountUsd": 250}, "always_transient_error")
print("path:", " -> ".join(path3))
print("computed backoff intervals:", delays3)
expected = [2 * (2.0 ** i) for i in range(3)]
assert delays3 == expected, (delays3, expected)
assert outcome3 == "OrderRejectedOrFulfillmentFailed"
assert "CompensatePaymentAndInventory" in path3
print("\nALL SCENARIOS PASSED")
Actual output from running the interpreter above (with ASL_JSON populated from the state machine defined earlier in this answer):
structural check OK: 12 states, all Next/Default/Catch targets resolve
=== Scenario 1: $250 order, no approval needed, everything succeeds ===
path: ReserveInventory -> AuthorizePayment -> CheckApprovalNeeded -> AsyncFulfillment -> OrderSucceeded
=== Scenario 2: $5,000 order, requires and receives approval ===
path: ReserveInventory -> AuthorizePayment -> CheckApprovalNeeded -> HumanApproval -> CheckApprovalResult -> AsyncFulfillment -> OrderSucceeded
=== Scenario 3: $250 order, fulfillment fails on every attempt ===
[AsyncFulfillment] attempt 1 raised FulfillmentTransientError, retry after 2.0s
[AsyncFulfillment] attempt 2 raised FulfillmentTransientError, retry after 4.0s
[AsyncFulfillment] attempt 3 raised FulfillmentTransientError, retry after 8.0s
[AsyncFulfillment] retries exhausted (4 attempts total) -> Catch -> CompensatePaymentAndInventory
path: ReserveInventory -> AuthorizePayment -> CheckApprovalNeeded -> AsyncFulfillment -> CompensatePaymentAndInventory -> OrderFailedAfterFulfillmentOrApproval
computed backoff intervals: [2.0, 4.0, 8.0]
ALL SCENARIOS PASSED
All three scenarios pass, confirming: scenario 1 and 2 both reach OrderSucceeded via the correct path (with scenario 2 correctly detouring through HumanApproval since $5,000 > the $1,000 threshold, while scenario 1's $250 does not); scenario 3 retries exactly 3 times after the initial attempt (4 attempts total, matching MaxAttempts: 3) with the exact backoff intervals the state machine declares, then correctly routes to CompensatePaymentAndInventory before landing in the OrderFailedAfterFulfillmentOrApproval fail state.
Trade-offs and pitfalls
The main pitfall is hardcoding the approval threshold as a literal inside the state machine definition (done here for a runnable, self-contained example) rather than injecting it as execution input or a parameter resolved at deploy time; a real template should pass orderAmountUsd and the threshold both as input so the threshold can change without redeploying the state machine. A second pitfall is using States.ALL in a Retry block for the fulfillment call, which would retry even a permanent rejection (e.g., "item discontinued") as if it were transient; this state machine deliberately retries only a named FulfillmentTransientError, so a permanent error skips retries and goes straight to compensation. A third, easy-to-miss pitfall is forgetting a TimeoutSeconds on a waitForTaskToken task: without one, an approval that nobody ever actions leaves the execution (and the reserved inventory and authorized payment behind it) pending indefinitely; the HumanApproval state's TimeoutSeconds: 172800 (48 hours) with a Catch on States.Timeout closes that gap.
Explain the difference between publish-subscribe and point-to-point (producer-consumer) messaging patterns. Provide concrete scenarios where pub/sub is a better fit (e.g., notifications, analytics) and where queues are preferable (e.g., work queues, task processing), particularly in multi-tenant SaaS and event-driven microservice architectures.
Sample Answer
Direct answer
Publish-subscribe (pub/sub) delivers each message to every interested subscriber, so it fits situations where multiple independent parties need to know the same fact happened, such as notifications or analytics. Point-to-point (producer-consumer, work-queue style) delivers each message to exactly one consumer among a pool, so it fits situations where a unit of work must be done exactly once by whichever worker picks it up, such as background task processing. The distinction is about fan-out (one-to-many awareness) versus load distribution (one-of-many execution), and both patterns are commonly implemented on the same underlying broker.
Structured elaboration
Publish-subscribe. A publisher emits an event to a topic; every subscriber with an active subscription receives its own copy. Subscribers are typically unaware of each other, can be added or removed without changing the publisher, and each one processes the event for its own purpose. This is the right model whenever "N different systems need to react to the same fact" is the actual requirement: a "UserSignedUp" event might be consumed by an email-welcome service, an analytics pipeline, and a fraud-scoring service simultaneously, with none of them competing for the message.
Point-to-point (work queues). A producer places a task on a queue; a pool of competing consumers pulls from the same queue, and each task is handled by exactly one consumer. This is the right model for "this unit of work needs to happen once, by whichever worker is free," such as resizing an uploaded image or sending a single transactional email: you do not want three workers all resizing the same image.
Where pub/sub is the better fit. Notifications is the clearest case: a single "OrderShipped" event needs to reach a push-notification service, an SMS service, and an in-app activity feed, each independently, and adding a fourth channel later should not require touching the producer. Analytics is the same shape: every business event (page view, purchase, signup) typically needs to reach an analytics pipeline in addition to whatever else consumes it, without competing with those other consumers for the message.
Where queues are preferable. Work queues and task processing are the clear case: a video-transcoding job, a report-generation job, or an outbound-email send should be picked up and completed by exactly one worker, with the queue's competing-consumers model providing natural load balancing and horizontal scaling (add more workers, they compete for the same backlog) without any risk of duplicate execution beyond what at-least-once delivery already requires the consumer to handle idempotently.
Multi-tenant SaaS (software as a service) and event-driven microservices. In a multi-tenant SaaS system, pub/sub is what lets independently-owned services (billing, usage-metering, audit logging) all react to the same tenant-level event, such as "SubscriptionUpgraded," without the team that owns the upgrade flow needing to know or coordinate with every downstream consumer; new consumers subscribe without any change to the publisher. Point-to-point queues, by contrast, are what those same microservices use internally for their own background work, such as a billing service's queue of pending invoice-generation tasks, where exactly-once-effective execution by one worker in the pool is the requirement, not fan-out to observers.
Worked example
A multi-tenant SaaS platform publishes a "TenantUpgraded" event when a customer moves from a free to a paid plan. Three independent subscribers exist on this topic: a billing service that starts metered invoicing, a feature-flag service that unlocks paid features, and a customer-success service that triggers an onboarding email sequence. All three receive their own copy of the same event; the team that owns the upgrade flow never had to know these three consumers existed. Separately, the feature-flag service's own onboarding-email trigger enqueues an actual "send welcome email" task onto a point-to-point work queue consumed by a pool of 5 worker processes; only one of those 5 workers ends up sending that specific email, because the queue hands each task to a single competing consumer, not to all 5.
Trade-offs and pitfalls
The common mistake is using a work queue where pub/sub was needed: if a "TenantUpgraded" task were placed on a single point-to-point queue instead of published to a topic, only one of billing, feature-flags, or customer-success would ever see it, and the other two would silently never fire, which is a subtle and easy-to-miss integration bug. The opposite mistake is using pub/sub where a work queue was needed for a task that must be done exactly once: if "resize this uploaded image" were published to a topic with multiple subscribed workers, every worker would independently resize the same image, wasting resources and, if the workers write to the same output path, potentially racing each other. A senior answer names this fan-out-versus-load-distribution distinction explicitly, rather than treating "pub/sub" and "queue" as interchangeable synonyms for "asynchronous messaging."
As a Solutions Architect, detail the decision criteria you use to choose synchronous (HTTP/REST, gRPC) versus asynchronous (message queues, event streams) service-to-service communication. Discuss trade-offs around latency, reliability, coupling, operational complexity, developer productivity, and how each choice affects deployment independence.
Sample Answer
Direct answer
Choose synchronous communication (HTTP/REST, or gRPC, a high-performance remote-call framework built on protocol buffers) when the caller needs an immediate answer to proceed and strong end-to-end reliability guarantees matter more than decoupling; choose asynchronous communication (message queues, event streams) when the work can be deferred, the caller does not need the result to continue, or you need producers and consumers to fail, scale, and deploy independently. The decision criteria are latency requirements, reliability/failure-isolation needs, coupling tolerance, operational complexity budget, developer productivity, and how much deployment independence the teams involved actually need.
Structured elaboration
Walk each axis explicitly rather than picking a style by habit:
- Latency. Synchronous calls give the caller a result (or an error) within the request's timeout window, which is required whenever a human or an immediately-dependent step is waiting (e.g., "is this card valid"). Asynchronous messaging trades immediate response for throughput and resilience: the producer gets an acknowledgment that the message was accepted, not that the work finished.
- Reliability and failure isolation. A synchronous call couples the caller's availability to the callee's: if the downstream service is slow or down, the caller blocks or fails too, and a chain of synchronous calls compounds failure probability (if three downstream services each have 99.9% availability, the chain that calls all three synchronously is bounded by roughly 0.9993≈0.997, i.e. about 3x the failure rate of any single hop). Asynchronous messaging inserts a durable buffer (the queue or log) between producer and consumer, so a consumer outage delays processing but does not fail the producer's request.
- Coupling. Synchronous calls create temporal coupling (both sides must be up at the same instant) and often contract coupling (the caller depends on the callee's exact response shape and latency profile). Asynchronous messaging only requires agreement on the event/message schema; producer and consumer do not need to be online simultaneously.
- Operational complexity. Synchronous systems are simpler to trace (a single call stack, straightforward distributed tracing) and debug. Asynchronous systems add a broker to run and monitor, delivery-semantics decisions (at-least-once handling, ordering), dead-letter queues (DLQs, queues that hold messages a consumer could not process after retries) for poison messages, and eventual-consistency reasoning that developers have to learn.
- Developer productivity. Synchronous request/response is the default mental model most engineers already have; it is faster to build and test for simple CRUD-style interactions. Asynchronous flows require additional skills (idempotent consumers, correlation IDs for tracing a request across services, compensating logic) and slower local iteration (you cannot just curl an endpoint and see the final state).
- Deployment independence. This is the axis most often underweighted. A synchronous caller that depends on a callee's API contract must coordinate deploys carefully around breaking changes (versioned endpoints, backward-compatible fields). An asynchronous consumer reading from a durable topic can be redeployed, scaled, or even paused independently of the producer, because the event log absorbs the gap; this is what lets teams ship on independent cadences, which is usually the real reason "event-driven" gets proposed for a set of otherwise unrelated services.
A simple framework: ask "does the caller need the result to proceed, right now, in this request?" If yes, go synchronous. Then ask "if the callee is degraded, should the caller degrade too, or should the caller succeed and the work catch up later?" If the caller should still succeed, go asynchronous even if latency were not a constraint, because the failure-isolation property is what you are actually buying.
Worked example
A checkout service calling three downstream services synchronously (inventory check, tax calculation, fraud score) with each at 99.9% availability has a combined dependency availability of 0.9993≈0.997, meaning roughly 3 requests in 1,000 fail purely from the chaining, even though each service individually is healthy 999 times in 1,000. If two of those three calls (tax calculation and fraud score) can tolerate a few hundred milliseconds of extra latency and their results are not needed to authorize the immediate step, moving them to asynchronous event handlers that publish a "checkout.enriched" event removes two of the three synchronous dependencies from the critical path, leaving only inventory check (which genuinely must block, since you cannot confirm an order for stock you do not have) synchronous. The chain's availability floor improves to roughly 0.9991=0.999, and the tax/fraud services can now be redeployed or scaled without coordinating a maintenance window with checkout.
Trade-offs and pitfalls
A common wrong turn is defaulting to asynchronous "for scalability" on a step the caller genuinely needs the result of right now; that just relocates the wait (the caller polls or blocks on a callback) while adding a broker, a correlation mechanism, and eventual-consistency bugs, with no user-facing benefit. The opposite pitfall is defaulting to synchronous everywhere because it is simpler to write, then discovering that one flaky downstream service now takes the whole call chain down with it. A senior answer treats each interaction independently on these six axes rather than applying one style architecture-wide, and explicitly names deployment independence as a first-class criterion, not an afterthought, since it is usually the criterion that determines whether decoupling was worth the added operational complexity.
Define at-most-once, at-least-once, and exactly-once delivery semantics in messaging systems. Provide a concrete example scenario where each semantics would be acceptable, and briefly outline typical techniques used to achieve each in practice.
Sample Answer
Direct answer
At-most-once means a message is delivered zero or one times (no retries, so it can be lost but never duplicated); at-least-once means it is delivered one or more times (retries until acknowledged, so it can be duplicated but not lost); exactly-once means it is delivered, and its effect applied, exactly one time, neither lost nor duplicated. Each is acceptable in a different kind of scenario, and each is achieved with a different, specific technique rather than by simply picking a label.
Structured elaboration
At-most-once
- Definition: the sender makes one delivery attempt and does not retry if it fails; duplicates cannot happen, loss can.
- Acceptable scenario: non-critical telemetry feeding a live dashboard, for example a fleet of sensors publishing temperature readings every second, where an occasional dropped reading is invisible in an aggregate view but a duplicated reading would visibly distort a running average.
- Typical technique to achieve it: a best-effort, non-persistent send with no retry logic and no durable queue backing it, often a fire-and-forget UDP-style transport or a message queue configured with no redelivery policy.
At-least-once
- Definition: the sender or broker persists the message and retries until it receives an acknowledgment; duplicates can happen, loss (in the steady state, once the network and consumer recover) cannot.
- Acceptable scenario: a background job queue processing "send this welcome email" tasks, where the job can safely run twice (the second run is a harmless no-op if the handler is idempotent) but a lost task means a real user never gets their email.
- Typical technique to achieve it: durable, persistent queues with acknowledgment-based retry, plus an idempotent consumer that deduplicates by a unique message identifier (a dedup store keyed by that identifier, or a naturally idempotent write like an upsert).
Exactly-once
- Definition: the message's effect is applied exactly one time, with neither loss nor duplication, end to end.
- Acceptable scenario: applying a financial ledger entry or decrementing inventory for a single order, where either a lost update or a duplicated update produces an incorrect, customer-visible, or legally significant result.
- Typical technique to achieve it: either transactional coordination provided by the messaging platform (atomically tying together "consume this" and "produce/commit that" so a crash cannot leave a partially-applied state), or an application-level idempotency key combined with at-least-once delivery, which is the more portable and more commonly used approach in practice since it does not depend on every hop in the pipeline supporting native transactions.
Worked example
A ticket-booking system needs to decrement available seat count by 1 when a booking event is processed.
- At-most-once booking events: if a booking event is silently dropped, the seat count is never decremented for that booking, and the system can oversell (or, worse, hold seats no one actually booked, if the decrement direction were reversed). Not acceptable here; this is exactly the wrong semantic for inventory movement.
- At-least-once with a naive handler (
decrement seat_count by 1on every delivery, no idempotency): a redelivered booking event decrements the seat count twice for one real booking, silently under-reporting availability. This is a duplication bug caused by picking the right base semantic (at-least-once, so no bookings are lost) but skipping the required application-side idempotency. - At-least-once with an idempotent handler (
if booking_id not already applied, decrement seat_count by 1 and record booking_id as applied, keyed by the booking's own unique identifier): redelivery is a no-op, seat count is decremented exactly once per real booking, achieving the exactly-once business outcome from at-least-once transport plus an idempotency key, without needing the broker to support native transactions.
Trade-offs and pitfalls
- Common wrong turn: picking at-most-once for something that clearly needs a durability guarantee, purely because it requires the least code; the missing-event failure mode does not show up until an audit or a customer complaint.
- Common wrong turn: treating "we use at-least-once delivery" as equivalent to "we have exactly-once correctness," when the idempotency work on the application side is what actually closes that gap, not the delivery semantic alone.
- Common wrong turn: reaching for full transactional exactly-once machinery when a simple idempotency key and an at-least-once queue would have solved the same business problem with far less operational overhead.
- Senior signal: matching the technique to the semantic explicitly (non-persistent send for at-most-once, durable-retry-plus-dedup for at-least-once, transactions-or-idempotency-key for exactly-once), rather than describing the three semantics only in the abstract without saying how each is actually implemented.
Propose a backpressure/flow-control design when a fast producer floods a slow consumer connected via a queue system. Include mechanisms on both producer and broker sides (bounded queues, rate-limiting, pause/resume, token buckets), and describe how to implement graceful degradation while preserving important messages.
Sample Answer
Direct answer
When a fast producer floods a slow consumer behind a queue, the fix is layered: constrain the producer's effective send rate so it can never overwhelm the broker in the first place, cap what the broker will hold so an unconstrained burst fails fast instead of growing without limit, and give the consumer an explicit signal to slow the producer down. On top of that, degrade by shedding low-value messages first, never by silently dropping everything.
Structured elaboration
Producer-side mechanisms
- Token bucket rate limiter in front of the publish call, sized to the consumer's sustained throughput plus a small burst allowance, not to the producer's natural output rate.
- Pause/resume (credit-based flow control): the broker or consumer publishes a credit or high/low watermark signal; the producer pauses publishing when credits are exhausted and resumes when the consumer signals capacity again. This is the mechanism that actually closes the loop. A token bucket alone just smooths a rate, it does not react to real backlog.
- Client-side buffering with its own bounded size, so a paused producer does not itself become an unbounded memory sink. Once that local buffer is full, callers see backpressure (a blocking call, or a rejected/deferred write) instead of the process growing without limit.
Broker-side mechanisms
- A bounded queue with an explicit capacity ceiling. Once full, the broker rejects new publishes (or blocks the producer, depending on the client contract) rather than growing memory without bound. This turns "the consumer is a little slow" into a visible, actionable signal instead of a silent memory leak.
- Priority lanes or multiple queues by message class, so that once the bound is reached, the broker can shed low-priority traffic first while still admitting high-priority messages.
- Consumer-side autoscaling triggered off queue depth or lag, so backpressure is a symptom you fix by adding capacity over time, not just a state you tolerate forever.
Multi-stage and hierarchical topologies. In a pipeline with more than one hop (producer, broker, an intermediate aggregator, final consumer), apply backpressure independently at each hop rather than only at the outermost edge. A hierarchical fan-out (broker to regional relays to final consumers) needs the same bounded-queue-plus-token-bucket pattern at each stage, otherwise one stage's overflow just moves the flood one hop downstream instead of resolving it. On the signaling side, a producer that receives an explicit "too busy" response (an HTTP 429 status code from an API-fronted queue, or a broker-specific backpressure response) should treat repeated instances as its own local circuit breaker: stop sending for a cooldown window rather than retrying immediately into a system that just told you it is full.
Graceful degradation, preserving important messages
- Classify messages into priority tiers at publish time (a header or routing key), not after the fact.
- When the bounded queue approaches capacity, drop or defer the lowest tier first (an analytics ping before a checkout confirmation), and reject new low-priority publishes at the producer's client library so the rejection happens as close to the source as possible.
- Keep the shed decision observable: emit a metric and a structured log entry for every dropped or throttled message, so shedding is a visible design choice, not silent data loss.
Worked example
Say the producer bursts at 5,000 messages/sec, and the consumer side is a pool of 8 workers, each sustaining about 100 messages/sec, for a total of 800 messages/sec.
net fill rate=5000−800=4200 msg/sIf the broker's bounded queue is capped at 50,000 messages, an unmitigated burst fills it in:
time to fill=420050000≈11.9 sUnder 12 seconds from full-speed burst to a broker that starts rejecting or blocking. That is the argument for a producer-side token bucket sized to consumer capacity (800/sec) with a small burst allowance (say 960/sec, 20% headroom) rather than relying only on the broker's cap: throttling at the source means the queue never gets anywhere near 50,000 in the first place, and the 12-second figure above becomes the worst case only if the token bucket is missing or misconfigured.
Trade-offs and pitfalls
- A token bucket sized to the producer's natural rate instead of the consumer's sustained rate just moves the flood downstream. Size it to what the consumer can actually drain.
- Bounding the queue without pause/resume just converts "slow consumer" into "producer errors," which is safer than unbounded memory growth but is still an outage if nothing tells the producer to slow down instead of retry-storming.
- Dropping messages under pressure without a priority scheme treats a checkout confirmation the same as a page-view ping. Senior answers always tier the shed.
- Consumer autoscaling has ramp-up lag (new workers take time to become ready), so it complements but does not replace bounded queues and rate limiting. It is the medium-term fix, not the instantaneous one.
Unlock Full Question Bank
Get access to all 28 Event-Driven Architecture and Asynchronous Messaging interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.