Inter-service communication comes down to one choice: should the caller wait for a response, or hand off the work and move on?
Synchronous calls use request-response. Service A calls service B and waits. Asynchronous communication uses messages or events. Service A publishes something and continues while downstream services process it later.
This choice affects latency, availability, coupling, and debugging.
Loading simulation...
This chapter covers the trade-offs of both styles and why real systems usually mix them.
Synchronous communication is often the first model people use, because it mirrors a function call. Service A sends a request to service B and waits, holding the connection open, until B sends back a response or the call times out.
The two services are talking in real time, and A cannot finish its own work until B has finished its part.
The common implementations are HTTP/REST and gRPC. REST is the lingua franca of service APIs: predictable verbs, JSON over HTTP, easy to call from anything. gRPC trades that universality for speed and a strict contract, using binary Protocol Buffers over HTTP/2.
Both are request-response. The caller blocks, the callee responds.
In Java, this shows up as a direct method call against a downstream client, where the calling thread is parked until the response comes back. An Order service checking stock before it confirms an order looks like this.
The whole method depends on that one call returning. Nothing after checkStock executes until Inventory answers, and the thread running placeOrder is held for the entire wait. That is the defining trait of a synchronous call: the caller cannot make progress while it waits.
Because the caller is parked on that wait, the timeout is the most important setting on the call. Without one, a slow or hung Inventory service holds the Order thread indefinitely. A short, explicit timeout caps the damage.
The connect-timeout bounds how long to wait for a TCP connection, and read-timeout bounds how long to wait for the response once connected. When either is exceeded, the client throws instead of blocking forever, so the Order thread is freed even when Inventory is unhealthy.
The appeal is that it is easy to reason about. The flow reads top to bottom like ordinary code: call payment, get a result, then create the order.
When something goes wrong, the error comes straight back to the caller in the same request, so a single stack trace and a single trace span tell you most of the story. By the time the call returns, the caller knows whether the work happened.
What you pay for that clarity is coupling in time. The caller and callee both have to be up and reachable at the same moment, and the caller is only as fast and as available as everything it waits on.
That cost stays small for a single call, but it grows sharply once calls start chaining, which is what the next two sections are about.
Asynchronous communication breaks the real-time link. Instead of calling another service and waiting, service A writes a message to a queue or publishes an event to a broker, and then immediately continues with its own work.
Some other service consumes that message later, on its own schedule. A never finds out directly whether the downstream work succeeded, and for many interactions it does not need to.
The infrastructure in the middle is a message broker such as RabbitMQ or Kafka. The producer hands its message to the broker and is done. The broker holds the message durably until a consumer is ready to process it. If the consumer is slow, restarting, or briefly offline, the message simply waits in the broker rather than failing the producer.
In Java, the publish is a single call to the broker client, and the method returns as soon as the broker accepts the message. The same Order service, written to publish instead of block, looks like this.
Once publish returns, placeOrder is done. The Order service does not call Inventory or Email at all. It announces that an order was placed and lets whatever cares about that fact react on its own time. The thread is held only for the local write and the handoff to the broker, not for any downstream processing.
This buys decoupling in both time and availability. The producer does not wait, so its latency no longer depends on how long the consumer takes. The consumer can be down for a minute without the producer noticing, because the broker absorbs the gap.
One published event can fan out to several consumers that the producer never even knows about, which makes it easy to add new reactions to an event without touching the code that emits it.
The cost is that the end-to-end flow becomes harder to follow. The logic is no longer one readable sequence. It is spread across producers, a broker, and consumers that run at different times.
When an order confirmation email never arrives, the failure is not waiting in the caller's response. You have to trace the message through the broker and into a consumer that ran seconds or minutes later.
You also inherit a new set of concerns: messages can arrive more than once, arrive out of order, or accumulate faster than consumers can drain them. Async is more powerful and more forgiving of failure, but it asks more of you in operability.
Synchronous calls carry a cost that is pure arithmetic.
When service A must synchronously call service B to do its job, A cannot succeed unless B succeeds. So A's availability is its own availability multiplied by B's. Add more synchronous dependencies in the same request and you keep multiplying. Each one is another factor below 1.0, and the product only ever shrinks.
Take three downstream services, each a respectable 99.9% available. If A has to call all three synchronously to serve a request, A's effective availability is 0.999 x 0.999 x 0.999, which is about 99.7%. Three reliable services combined into something noticeably less reliable than any of them alone.
| Synchronous dependencies (each 99.9%) | Combined availability | Downtime per month |
|---|---|---|
| 1 | 99.9% | ~43 minutes |
| 3 | ~99.7% | ~2.2 hours |
| 5 | ~99.5% | ~3.6 hours |
| 10 | ~99.0% | ~7.2 hours |
The numbers degrade gradually. A service rarely starts out at 99% available by design. It drops one synchronous dependency at a time, each addition looking harmless on its own.
A request path that fans out to ten services in a blocking chain has multiplied ten availability figures together, and the result is worse than any single one of them.
Asynchronous communication sidesteps the multiplication entirely. If A publishes an event instead of calling B and waiting, A's success no longer depends on B being up at that instant. The broker holds the message, and B processes it when it can.
The dependency moves from "must be up right now" to "will catch up eventually," which takes that factor out of A's availability equation. This is the core reason to push non-essential work off the synchronous path: every blocking call you remove is a multiplier you stop paying.
Availability is not the only thing that compounds along a synchronous chain. Latency does too, and just as mechanically.
Every synchronous hop is a network round trip. The request has to travel to the next service and the response has to travel back, and that takes real wall-clock time before the receiving service has done a single useful thing.
Inside one data center a round trip might be a handful of milliseconds. The moment calls are chained in sequence, those milliseconds stack.
Consider a request that passes through five services, each one synchronously calling the next. At 10 milliseconds per hop, the chain spends 50 milliseconds purely moving bytes across the network, before any service has executed a line of business logic. Add the actual processing time at each stop and the user is waiting on the sum of the whole chain.
Tail latency makes it worse than the average suggests. If any single hop occasionally takes 200 milliseconds instead of 10, the whole chain is slow whenever any one link is slow.
The more links you have, the more likely it is that at least one of them is slow at any given time, so the slow requests get both slower and more frequent as the chain grows.
There are two ways to fight this. The first is to fan out in parallel instead of in sequence: if service A needs data from B, C, and D, and none of them depend on each other, call all three at once so the cost is the slowest single call rather than the sum of all three.
The second is to take work off the synchronous path altogether. If a step does not have to finish before the user gets a response, make it asynchronous, and the user stops waiting on it entirely.
A deep serial chain of blocking calls is the shape to watch for, because it is slow purely from hop count, regardless of how fast each service is.
Underneath the latency and availability math is a more general idea that gives you the vocabulary to reason about these trade-offs precisely. When two services talk, they can be coupled in three distinct ways.
| Coupling dimension | Synchronous (direct call) | Asynchronous (via broker) |
|---|---|---|
| Temporal (both up at once) | Required | Not required |
| Location (know the address) | Required | Not required |
| Interface (agree on contract) | Required | Required |
Async loosens the first two and leaves the third in place. This is why it improves availability and makes systems easier to extend: removing temporal coupling stops downstream outages from failing the caller, and removing location coupling lets you add a new consumer without the producer knowing.
The interface coupling that remains is the one you cannot avoid, because two services that exchange data have to agree on what that data means. Describing a flow in these terms replaces "async is more decoupled" with a precise statement about which couplings you are removing and which you are keeping.
Neither style is the default. The answer to "sync or async?" depends on the interaction, and the deciding question is almost always the same: does the caller need the result before it can continue, and does the user need to know right now that it worked?
Synchronous communication is the right choice when the answer is yes.
Asynchronous communication is the right choice when the caller can move on without the result.
| Use synchronous when | Use asynchronous when |
|---|---|
| The user is waiting on the result | The user does not need the result now |
| The next step needs the value | The work is a side effect |
| The flow is short and low fan-out | The work is slow or long-running |
| You need strong, read-your-write consistency | Eventual consistency is acceptable |
Each choice can be defended by tying it to the user. "This call is synchronous because the user is waiting on the answer."
"This one is asynchronous because the user does not need to know it succeeded before getting a response." Reasoning from what the user expects, rather than from a preference for one technology, is what makes the decision defensible.
Put those rules together and you do not get an all-sync or an all-async system. You get a hybrid, which is what nearly every production system actually is. The pattern that emerges again and again is synchronous at the user-facing edge, asynchronous for everything downstream that the user does not need to wait on.
A checkout flow shows it clearly. The parts the user is waiting on run synchronously: validate the cart, charge the card, create the order, return a confirmation. Those have to finish before the user sees "order placed," and the next step genuinely depends on the previous one.
Everything that happens because the order was placed runs asynchronously: emailing the receipt, decrementing inventory, kicking off fulfillment, updating analytics. The user does not need any of those to complete before seeing their confirmation, and none of them should be able to fail the purchase.
The diagram shows the division in one picture. The synchronous spine, drawn left to right, is short and direct, so its availability and latency stay cheap. The order service charges payment, gets its answer, and returns to the user.
Then it publishes a single OrderPlaced event and is finished. The broker fans that event out to email, inventory, and analytics, each running on its own schedule. If the analytics consumer is down, the customer still gets their confirmation and their email, and analytics catches up from the broker when it recovers.
This is why the sync-versus-async question is rarely answered once for a whole system. It is answered per interaction, and the strongest designs keep the synchronous path as thin as the user's actual needs require, then move everything else off it.
Get that division right and you keep the simplicity of request-response where it matters while gaining the resilience of events everywhere it does not.
Take a single checkout and run it two ways, using the two placeOrder methods from earlier. The order touches Inventory, Payment, and Shipping. In both versions the customer taps "Place Order" and waits for the page to respond. The question is what they wait on, and what happens when Payment slows down and starts taking eight seconds to answer instead of its usual two hundred milliseconds.
In the synchronous version, the Order service blocks through each downstream call in turn. It calls Inventory, waits for the stock answer, then calls Payment, waits for the charge to clear, then calls Shipping to reserve a slot, and only then returns a confirmation. The customer's request is parked on the sum of all three, just like the blocking checkStock call held the thread until Inventory replied.
When Payment slows to eight seconds, the customer waits that whole eight seconds. The page spinner keeps turning because the Order thread is stuck inside paymentClient.charge, holding the request open. If Payment crosses the read-timeout, the call throws and the entire checkout fails, even though Inventory and Shipping were perfectly healthy. The user's experience is tied to the slowest and least reliable link in the chain.
The asynchronous version cuts the chain at the first reliable step. The Order service does the one thing that genuinely must happen before it can confirm, which is to record the order, then publishes an OrderPlaced event and returns immediately, exactly like the publishing placeOrder from before. Payment, Inventory, and Shipping each consume that event and do their part on their own schedule.
Now the Payment slowdown never reaches the customer. The confirmation comes back as soon as the order is written and the event is handed to the broker. When Payment is taking eight seconds, that delay plays out inside the Payment consumer, not inside the customer's request. The order moves to a "confirmed" state once the charge clears, and the customer is notified then, but they never sat watching a spinner for it.
The diagram contrasts the two paths from the same starting point. The dotted synchronous path drags the customer through Payment's eight seconds and out to Shipping before it can answer, so a slow Payment stalls or fails the whole checkout. The solid asynchronous path stops at the broker: the customer is answered the moment OrderPlaced is published, and Payment's slowdown is absorbed by its consumer where no one is waiting on it.
The two designs make the trade-off concrete. The synchronous design gives an immediate, definite answer, including a definite payment result, at the price of binding the customer to the slowest downstream call. The asynchronous design keeps the customer fast and shields them from a Payment hiccup, at the price of confirming the charge slightly later and accepting that the order is "accepted" before it is fully "confirmed." Which one is right depends on whether the business needs the charge to clear before the page returns, which is the same per-interaction question the chapter keeps coming back to.
10 quizzes