Synchronous vs asynchronous communication

REST and gRPC calls versus events and commands, temporal coupling, and the interaction that should have been a message.

16 min read🧩 Microservices Architecture

Two services can talk in two ways: one asks and waits for the answer, or one tells and moves on. The first is a function call across a network and inherits everything a function call assumes, above all that the other side is there right now. The second trades that assumption for a queue and a delay. Choosing wrong in either direction produces a system that is either fragile or confusing, and most systems need both, decided per interaction. This lesson is the coupling each creates, what an event is and is not, how to choose, and what eventual consistency asks of the code that reads.

Temporal coupling

A synchronous call couples the caller to the callee in time: both must be up, reachable and responsive at the same moment, and the caller's latency includes the callee's. Chain three services and the availability of the request is the product of three availabilities, and the latency is the sum. That is fine for a query that needs the answer to proceed. It is a design flaw for anything that does not:

Placing an order, synchronously, with four dependenciesjava
@Transactional
public Order place(PlaceOrder cmd) {
    Order order = orders.save(Order.from(cmd));
    inventory.reserve(order.lines());            // must succeed: without stock there is no order
    email.sendConfirmation(order);               // does the order fail if the mail server is slow?
    loyalty.award(order.customerId(), points);   // does the order fail if loyalty is down?
    analytics.track("order.placed", order);      // does the order fail if analytics is down?
    return order;
}

Three of these four calls should not be there. The order exists once stock is reserved and the row is committed; the confirmation email, the loyalty points and the analytics event are consequences that can happen a second later and must not decide whether the order succeeds. As written, an analytics outage is a checkout outage, and the mail server's latency is on the critical path of every purchase. Those three are the interactions that should have been messages.

Request and response

Synchronous is right when the caller needs the result to continue: authorising a card before confirming an order, checking stock before showing "add to cart", reading a profile to render a page. The mechanics are REST over HTTP (JSON, wide tooling, cacheable, the default between teams) or gRPC (binary, typed contracts, streaming, lower latency, the default inside a team that controls both ends). Either way the call carries a timeout, a deadline that shrinks as it passes through hops, and a retry policy that respects idempotency, all of which the Resilience module covers. A synchronous call without a timeout is a promise to hang forever.

Events and commands

Asynchronous messages come in two kinds, and confusing them is the common design error.

An event is a fact about the past, named in the past tense: OrderPlaced, PaymentCaptured, CustomerMoved. The producer publishes it without knowing or caring who listens; zero, one or ten consumers may react, and adding a consumer does not change the producer. Events are how the three calls above become messages: the order service publishes OrderPlaced, and notifications, loyalty and analytics each subscribe.

A command is a request to do something, addressed to one handler: ReserveStock, SendInvoice. It has one recipient and usually an expectation of an outcome. A command over a queue decouples in time (the handler can be down for a minute) but not in intent: the sender knows who does the work. Commands are right for work that must happen exactly once by a specific service and can be deferred.

The tell is the name. SendEmail on a topic that three services consume is an event pretending to be a command, and each consumer will send an email. EmailRequested with one consumer is a command pretending to be an event, and when a second team subscribes "to know when emails go out", the intent is lost.

Choosing, per interaction

The interactionChooseBecause
The caller needs the answer to proceedsynchronousthere is nothing to do until it arrives
The caller would ignore the answerasynchronous eventit is a consequence, not a step
Several parties care about what happenedeventproducers should not know their consumers
One party must do the work, later is finecommand on a queuedecoupled in time, explicit in intent
The work must complete before the user sees successsynchronous, or a saga with a pending statethe user is waiting; be honest about what "success" means
A downstream is slow or unreliableasynchronousits problems become a lag, not an outage

The question to ask of every arrow on the diagram: if this call failed right now, should the caller fail? If yes, it is synchronous and gets a timeout and a fallback. If no, it is a message, and the caller commits without it.

Asynchronous has its own costs, which the honest design names: the producer never learns whether the consumer succeeded (so the consumer owns its retries and its dead letters, as the Kafka course covers); ordering is per key, not global; and a message published inside a database transaction can be lost or duplicated, which is why events go through an outbox in the same transaction as the state change.

Eventual consistency

Once a fact travels as an event, the reader of one service and the reader of another can disagree for a while: the order is placed but the loyalty balance has not yet moved; the customer changed their address but the shipment still shows the old one. That interval is usually milliseconds and occasionally, during an outage of a consumer, hours. Code and interfaces that read across the boundary have to be written for it:

  • Show the state you have and name it. "Points pending" is honest; a balance that silently lags is a support ticket.
  • Read your own writes from the owner. After changing the address, show it from the customer service, not from the shipment service's copy.
  • Design idempotent consumers, because the delay is also the window in which a retry delivers the same event twice.
  • Reconcile. A nightly job that compares the copies and logs disagreement is how you learn whether "eventually" is actually happening.

The alternative is a distributed transaction, and the Distributed data course explains why that is a saga with compensations, not a two-phase commit, in every system you are likely to build.

Request-reply over messaging

Sometimes a caller needs an answer and the callee is only reachable by queue. Request-reply over messaging sends a command with a correlation id and a reply-to address and waits on the reply topic. It works, and it has the worst of both: the caller blocks, and the path has a broker in it. Use it when the callee genuinely cannot be reached synchronously; otherwise the interaction is synchronous and should be an HTTP or gRPC call with a timeout.

Under the hood: what a synchronous hop costs, and what an event actually is

A synchronous call ties three resources together for its duration: a thread (or a virtual thread's carrier time) in the caller, a connection from the caller's pool, and a request slot in the callee. If the callee's p99 is 200 ms and the caller makes 500 calls a second, Little's law says 100 of those are in flight at any moment, which is 100 threads and 100 connections held for someone else's latency. Chain three services and the client's request holds a slot in each simultaneously; the availability is the product because a failure anywhere in the chain fails the request, and the latency is the sum because the hops are serial. Fan the calls out in parallel and the latency becomes the max instead of the sum, but the availability is still the product, and the caller now needs as many threads or futures as it has dependencies. That arithmetic is the whole case for making a hop asynchronous when the answer is not needed.

An event on a topic is a record: a key, a payload, headers, an offset, and a timestamp, and nothing else. It carries no reply address, no expectation, and no knowledge of consumers; the broker holds it for the retention period whether or not anyone reads it, and each consumer group reads at its own pace with its own committed offset. That is what makes a producer indifferent to its consumers: adding a group is a broker-side change the producer never sees. A command on a queue is the same record with an implied contract that exactly one handler acts on it; over Kafka that means a single consumer group, and the "exactly one" is the group's assignment, not a property of the message. Request-reply on top of either adds a correlation id header and a reply topic the requester subscribes to, and it reintroduces the thread-and-timeout coupling that the topic was supposed to remove.

synchronous: five hops on the checkout path clientordersinventoryemailloyaltyanalytics availability = 0.999⁵ ≈ 99.5% · latency = sum · an analytics outage is a checkout outage asynchronous: two hops, then an event clientordersinventory OrderPlaced (topic) outbox, same transaction emailloyaltyanalytics own group,own pace,own retries availability = 0.999² ≈ 99.8% · latency = two hops · a consumer outage is lag, not an error
The same five parties, rearranged. Only the two whose answers the order needs stay on the path; the rest learn about it afterwards.

The gap between "the order committed" and "the event exists" is where the outbox lives. A send() inside the database transaction can succeed while the commit later rolls back (an event about an order that does not exist), and a send() after the commit can fail while the commit stands (an order nobody hears about). Writing the event to an outbox table in the same transaction, and having a relay (a poller, or Debezium reading the database's change log) publish it and mark it sent, makes the event exactly as durable as the state change and delivers it at least once, which is the property every consumer is written for anyway.

Walkthrough: the checkout that failed when analytics did

A checkout endpoint made five synchronous calls, as in the code at the top of the lesson, and had 99.95% availability on paper.

What the trace showed for one slow checkoutjava
orders.place            2,340 ms
  inventory.reserve        38 ms
  email.sendConfirmation  190 ms
  loyalty.award         2,050 ms    ← loyalty's database was failing over
  analytics.track          31 ms
  1. The loyalty service's database failed over for ninety seconds. Its calls slowed to two seconds and then timed out at five. Every checkout waited for it inside the @Transactional method, holding an orders database connection for the duration.
  2. The orders connection pool (ten) drained in seconds; checkouts that never reached the loyalty call queued for a connection and timed out at the pool; the readiness probe, which included the datasource, failed; the platform pulled the pods. Ninety seconds of loyalty trouble became eleven minutes of no checkout.
  3. When the email call was slow on another day, the same shape repeated, and the team noticed the pattern: four of the five calls were consequences. The user needed stock reserved and an order id; the rest could arrive a second later.
  4. The rewrite kept inventory.reserve synchronous and inside the transaction (no stock, no order), wrote an OrderPlaced row to the outbox in the same transaction, and returned. A relay published it; email, loyalty and analytics each got a consumer group with their own retries and dead-letter topics. The @Transactional method now held a connection for about 40 ms.
  5. The next loyalty failover produced ninety seconds of consumer lag on one group and no user-visible effect. The team's rule for every new call on a request path: "if this fails, should the user's request fail?", answered in the pull request.

Temporal coupling is a choice made per call. Each synchronous consequence on a request path is an outage waiting for its dependency to have a bad minute.

Try it yourself

Availability arithmetic

A request path calls A, B, C in series, each 99.9% available with 50 ms p50. Give the path's availability and p50. Then B and C are called in parallel. Then C becomes an event consumer. Give both numbers for each shape.

Answer

Series: availability 0.999³ ≈ 99.7%, latency 150 ms. Parallel B and C: availability still ≈ 99.7% (all three must succeed), latency 100 ms (A, then max of B and C). C as an event consumer: availability 0.999² ≈ 99.8%, latency 100 ms, and C's failures become lag on its consumer group instead of errors. Parallelism fixes latency; only removing a party from the path fixes availability.

Event or command?

Name and classify each message, and say whether the design is right: (a) orders publishes SendInvoiceEmail to a topic consumed by email and audit; (b) payments publishes PaymentCaptured consumed by orders, ledger and notifications; (c) orders puts ReserveStock on a queue consumed only by inventory, then polls the order's status until it flips.

Answer

(a) A command named imperatively with two consumers: audit will read an instruction it should not act on, and if audit ever "helps" by sending, two emails. Rename to InvoiceIssued (a fact) and let email decide to send. (b) A correct event: past tense, a fact, several interested parties, the producer indifferent. (c) A command with a synchronous expectation bolted on by polling: the caller needs the answer, so it should be a synchronous call with a timeout; if inventory genuinely cannot answer now, the order should be accepted in a PENDING state and the stock outcome should arrive as an event that flips it, without polling.

Where did the event go?

An order service calls kafkaTemplate.send("order-placed", event) inside its @Transactional method, then the database commit fails on a constraint. Separately, on another day, the commit succeeds and the send fails after delivery.timeout.ms. What does each consumer see, and what is the one fix for both?

Answer

Day one: the event is published (the send does not participate in the JPA transaction), consumers process an OrderPlaced for an order that does not exist, and the notification, loyalty points and analytics all refer to a phantom. Day two: the order exists and no consumer ever hears; the customer gets no email and the ledger is wrong. One fix: an outbox row written in the same transaction as the order, published by a relay after the commit, so the event exists if and only if the order does, delivered at least once to idempotent consumers.

Misconceptions

  • "Asynchronous means faster." It means the caller does not wait. The work still happens, later, and the caller's request is faster only because a consequence left its path.
  • "Parallel calls fix the availability problem." They fix latency (max instead of sum). Availability stays the product until a party is removed from the path.
  • "An event is a message with a fancy name." It is a past-tense fact with no reply address and no known consumers; a command has one handler and an expected outcome. Naming one as the other produces duplicated or ignored work.
  • "Publishing inside the transaction is safe because it usually works." It produces phantom events on rollback and lost events on send failure. The outbox is the only shape that ties the two together.
  • "Request-reply over a queue is asynchronous." The caller still waits; it is synchronous with a broker in the path and should usually be an HTTP call.

Going deeper

  • Sam Newman, Building Microservices (2nd ed.), chapter 4, "Microservice Communication Styles".
  • Gregor Hohpe and Bobby Woolf, Enterprise Integration Patterns: Event Message, Command Message, Request-Reply, Correlation Identifier.
  • Chris Richardson, microservices.io: "Transactional outbox", "Polling publisher", "Transaction log tailing"; Debezium's outbox event router documentation.
  • Spring Modulith, "Working with Application Events", for the in-process outbox and @ApplicationModuleListener.
  • Martin Kleppmann, Designing Data-Intensive Applications, chapter 11, "Stream Processing", on events as the unit of integration.
Progress is saved on this device and to your account when signed in.