Microservice Communication Styles: Sync Request/Response, Async Events and Temporal Coupling
If the caller must wait for the answer, communication is synchronous however much await the code has, and it binds two services through temporal coupling; if the answer isn't needed now, prefer an event or a local copy. A comparison of REST, gRPC (including deadlines reaching the server) and messages, a checkout simulation while Inventory is being deployed, the command versus event split, and a decision table.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
Bookstore's first checkout design after the split into services looks like this: Ordering's checkout endpoint asks, in order, Catalog for the price, Inventory for a stock reservation, Payment for the payment and Shipping for a shipment record, and if all succeed it tells the customer "your order is complete". All over HTTP (Hypertext Transfer Protocol), all with await.
During the back-to-school campaign, 2 checkouts arrive per second. Right then the Fulfillment team deploys a new version of Inventory, something goes wrong, and Inventory doesn't answer for 20 seconds. In a one-minute campaign window, how many of 120 checkouts fail? If the same flow were built with events, how many errors would there be, and what would that cost? One more question: since every call in this code uses await, is this communication synchronous or asynchronous?
Two Axes: Who Waits, Who Knows?
The last question is a trap, because the word "asynchronous" is used for two different things. async/await in C# is an implementation detail about releasing the thread while waiting. What matters architecturally is whether the caller has to wait for the answer. If checkout can't return to the customer without Inventory's answer, the communication is architecturally synchronous, however much await the code contains.
In the second edition of Building Microservices (O'Reilly, 2021), Sam Newman separates communication styles along two axes. The first axis is time: with synchronous blocking, the caller can't continue until the answer arrives; with asynchronous non-blocking, the caller drops off the request and goes back to its own work. The second axis is the interaction pattern: with request-response, one service asks another for something and waits for an answer; with event-driven, a service announces that something happened and doesn't know who is listening; with common data, services communicate through a shared data source (a shared folder, a data lake). Request-response can be asynchronous too: sending a "reserve stock" request to Inventory through a queue and waiting for the answer on another queue, for example.
The practical meaning of this split is two questions. Must the caller wait for the answer? If so, both services must be up at the same time. Must the caller know who will do the work? If so, adding a new consumer changes the caller's code. Event-driven communication is the only style that can say "no" to both; we'll see its price shortly.
Technology: REST, gRPC and Messages
After the style comes the technology. For request-response, the .NET world has two common options: HTTP APIs (Application Programming Interfaces) carrying JSON (JavaScript Object Notation), that is REST (Representational State Transfer) style endpoints, and gRPC. The comparison in Microsoft's ASP.NET Core documentation separates them like this: with gRPC the contract (a .proto file) is required, it runs on HTTP/2, and the payload is small, binary Protobuf; with JSON APIs the contract (OpenAPI) is optional and the payload is human-readable. gRPC can't be called directly from a browser (it needs gRPC-Web or JSON transcoding); in return it generates client code from the contract and brings streaming and deadlines as part of the protocol. The documentation recommends gRPC for communication between microservices that needs low latency and high throughput. dotnet/eShop has a live example of this split: the Basket API is a gRPC service whose contract is defined in basket.proto, while Catalog and Ordering expose HTTP APIs.
Let's write a query in which Ordering asks Inventory "how many copies of this book are there?" with gRPC. The contract comes first, and both sides' code is generated from it:
syntax = "proto3";
option csharp_namespace = "Bookstore.Inventory.Grpc";
package bookstore.inventory.v1;
// The contract Inventory exposes: Ordering generates its client from this file
service StockQueries {
rpc GetAvailability (AvailabilityRequest) returns (AvailabilityReply);
}
message AvailabilityRequest {
string isbn = 1;
}
message AvailabilityReply {
string isbn = 1;
int32 available = 2;
}
using Bookstore.Inventory.Grpc;
using Grpc.Core;
namespace Bookstore.Inventory;
public sealed class StockQueriesService : StockQueries.StockQueriesBase
{
public override async Task<AvailabilityReply> GetAvailability(AvailabilityRequest request, ServerCallContext context)
{
// A query that slows down on campaign day: context.CancellationToken fires when the deadline passes
if (request.Isbn == "978-0141439518")
{
try
{
await Task.Delay(TimeSpan.FromSeconds(2), context.CancellationToken);
}
catch (OperationCanceledException)
{
Console.WriteLine($"Inventory: stopped work for {request.Isbn}, deadline was {context.Deadline:HH:mm:ss.fff} UTC");
throw;
}
}
return new AvailabilityReply { Isbn = request.Isbn, Available = 17 };
}
}
using Bookstore.Inventory;
using Bookstore.Inventory.Grpc;
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.AspNetCore.Server.Kestrel.Core;
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.Logging.ClearProviders();
builder.Services.AddGrpc();
builder.WebHost.ConfigureKestrel(k => k.ListenLocalhost(5089, o => o.Protocols = HttpProtocols.Http2));
WebApplication inventory = builder.Build();
inventory.MapGrpcService<StockQueriesService>();
await inventory.StartAsync();
// Ordering's side: a strongly typed client generated from the .proto
using GrpcChannel channel = GrpcChannel.ForAddress("http://localhost:5089");
StockQueries.StockQueriesClient stock = new(channel);
AvailabilityReply dune = await stock.GetAvailabilityAsync(
new AvailabilityRequest { Isbn = "978-0441013593" }, deadline: DateTime.UtcNow.AddMilliseconds(500));
string json = Google.Protobuf.JsonFormatter.Default.Format(dune);
Console.WriteLine($"Dune available: {dune.Available}");
Console.WriteLine($"Payload: protobuf {dune.CalculateSize()} bytes, same message as JSON {json.Length} bytes: {json}");
try
{
// The deadline travels to the server too: with no answer in 500 ms, both sides give up
await stock.GetAvailabilityAsync(
new AvailabilityRequest { Isbn = "978-0141439518" }, deadline: DateTime.UtcNow.AddMilliseconds(500));
}
catch (RpcException ex)
{
Console.WriteLine($"Emma: {ex.StatusCode}");
}
await inventory.StopAsync();
Dune available: 17
Payload: protobuf 18 bytes, same message as JSON 45 bytes: { "isbn": "978-0441013593", "available": 17 }
Inventory: stopped work for 978-0141439518, deadline was 00:11:34.053 UTC
Emma: DeadlineExceeded
Two things stand out. The same answer is 18 bytes as Protobuf and 45 bytes as JSON; on a small message the difference looks negligible, at thousands of calls per second it matters. More important is the deadline: the client gave a 500-millisecond budget, the budget was passed to the server too, and Inventory stopped the work itself when it ran out. Instead of every link in a synchronous chain inventing its own timeout, the request's remaining time can travel along the chain. With HTTP APIs the same discipline is built by hand with CancellationToken and HttpClient timeouts; the ASP.NET Core Web API series post "Async/Await and CancellationToken in ASP.NET Core: Carrying Cancellation All the Way to the Database" covers the in-service version.
On the message side, the technology is a broker: RabbitMQ, Azure Service Bus, Kafka or similar. Here the contract is the message schema, and transport is the broker's guarantees (delivery, ordering, redelivery). What each broker guarantees is the subject of the Messaging and Event-Driven Architecture series; this post's question is when you need messages.
| Property | HTTP + JSON | gRPC | Message (broker) |
|---|---|---|---|
| Does the caller wait for the answer? | Yes | Yes (except streaming) | No |
| Must the receiver be up right now? | Yes | Yes | No, the message waits |
| Contract | Optional (OpenAPI) | Required (.proto) | Message schema |
| Callable from a browser? | Yes | With gRPC-Web or transcoding | No, an API is needed in between |
| Typical use | Public APIs, simple queries | Heavy, low-latency calls between services | Something-happened notifications, long-running work |
Temporal Coupling and the Price of the Chain
The architectural price of synchronous request-response is temporal coupling: caller and callee must be up at the same time. In the Introduction to Architecture series post "Understanding the Monolith: Single-Process, Modular, and Distributed Monoliths" we simulated with a million requests how the availability of a five-service synchronous chain multiplies; the Messaging series post "Why Messaging? From Synchronous Call Chains to Asynchronous Communication" showed the same chain with the email provider going down. Here let's look from an angle specific to microservices: not a failure, but a deployment.
The whole idea of independent deployability is that the Inventory team can deploy whenever it wants. But if Inventory sits in the middle of a synchronous chain, every deployment window becomes a checkout outage window, and "independent deployment" turns in practice into "deployment visible to customers". Let's work out the opening scenario: one minute, 2 checkouts per second, Inventory gone between seconds 20 and 40, 100 copies of the campaign book.
// A campaign minute: 2 checkouts per second for 60 seconds; Inventory is being deployed between seconds 20 and 40
const int seconds = 60, perSecond = 2, initialStock = 100;
static bool InventoryUp(int t) => t is < 20 or >= 40;
// 1) Sync: checkout waits for Inventory's answer
int stock = initialStock, syncOk = 0, syncErrors = 0;
for (int t = 0; t < seconds; t++)
for (int i = 0; i < perSecond; i++)
{
if (!InventoryUp(t)) { syncErrors++; continue; } // the user sees a 503
if (stock > 0) { stock--; syncOk++; }
}
Console.WriteLine($"Sync : confirmed {syncOk}, errors shown to customers {syncErrors}, unsold copies {stock}");
// 2) Async: checkout publishes OrderPlaced and returns; Inventory drains the queue while it is up
stock = initialStock;
Queue<int> pending = new(); // order time (second) for each message
int reserved = 0, outOfStock = 0, maxDelay = 0;
for (int t = 0; t < seconds; t++)
{
for (int i = 0; i < perSecond; i++) pending.Enqueue(t); // every checkout gets 202 Accepted
if (!InventoryUp(t)) continue;
while (pending.TryDequeue(out int placedAt))
{
maxDelay = Math.Max(maxDelay, t - placedAt);
if (stock > 0) { stock--; reserved++; } else outOfStock++; // cancelled later + apology email
}
}
Console.WriteLine($"Async: accepted {seconds * perSecond}, reserved {reserved}, cancelled later (OutOfStock) {outOfStock}, " +
$"max wait for confirmation {maxDelay} s");
Sync : confirmed 80, errors shown to customers 40, unsold copies 20
Async: accepted 120, reserved 100, cancelled later (OutOfStock) 20, max wait for confirmation 20 s

In the synchronous design, 40 of 120 checkouts failed. Those are 503s the customer sees; some retry, some give up. In the event-driven design no checkout failed, but the other half of the picture is real too: orders that arrived during the deployment waited up to 20 seconds unconfirmed, and because stock ran out, the last 20 orders will be cancelled after having been told "accepted". Those customers will get an apology email. In the synchronous design, meanwhile, 20 copies went unsold, because the people who wanted them saw an error and left.
The simulation is kept simple (no retries, no user behavior), but the decision it shows is real: event-driven communication doesn't eliminate failure, it changes its time and place. The failure shows up not on the customer's screen at checkout but a few seconds later as a compensation and a notification. This is the communication-layer face of the eventual consistency we discussed in the previous post. Which is better is the business's call; for Bookstore, the decision "checkout must never fail during a campaign, a rare cancellation is acceptable" selects the event-driven design.
Command or Event?
On the asynchronous side there are also two different kinds of message, and mixing them up blurs boundaries:
namespace Bookstore.Contracts;
// Command: tells a single receiver "do this"; the receiver can refuse and owns the schema
public sealed record ReserveStock(Guid OrderId, string Isbn, int Quantity);
// Event: says "this happened"; the publisher doesn't know who listens and owns the schema
public sealed record OrderPlaced(Guid OrderId, Guid CustomerId, string Isbn, int Quantity, decimal Total);
ReserveStock is a command: the sender knows the receiver, there is one receiver, the receiver can refuse ("out of stock"), and the receiver owns the schema, because it answers the question "what can you ask of me". OrderPlaced is an event: a fact in the past tense, the publisher doesn't know who listens, there may be zero listeners or ten, and the publisher owns the schema. Adding a new consumer (a recommendation engine, say) for OrderPlaced doesn't require touching Ordering; adding a new receiver to a command like ReserveStock makes no sense. The naming split comes from this too: commands are imperative (ReserveStock), events are past tense (OrderPlaced, StockReserved). The details are in the Messaging series post "Message Types: Commands, Events, and Queries; Event Notification and Event-Carried State Transfer".
For service boundaries the choice means this: a command sets up domain coupling between sender and receiver and states it openly; an event leaves the coupling one-way, from the publisher toward the consumer. The choreography saga in the previous post was built entirely with events; orchestration naturally works with commands. Both are legitimate; what matters is that a message's name matches its behavior. Messages published as "events" that expect a single receiver to do a specific thing (like SendOrderConfirmationEmailEvent) are really commands in disguise.
Transforming Checkout
Let's take the opening chain apart step by step. For each call we ask one question: do I really need this information before answering the customer?
The price from Catalog: no. As in the fourth post, Ordering reads it from its own copy fed by BookPriceChanged. The price may be a few seconds old; that's why the price written to the order is the one the customer saw and confirmed in the cart.
The reservation from Inventory: it looks like a yes, to tell the customer "in stock", but the campaign scenario showed that it's usually a no. Ordering records the order as Placed and publishes OrderPlaced; Inventory processes the event and publishes StockReserved or OutOfStock. To show "in stock" on the cart page, Ordering can keep an approximate view fed by the stock events Inventory publishes; the final decision is made at reservation.
The payment from Payment: here there is a business decision. When a card is declined, it's valuable for the customer to learn that on the checkout screen, at the moment they can switch cards. That's why Bookstore's goal is a hybrid: payment authorization is attempted synchronously but with a 5-second limit; if the provider doesn't answer in that time, the order becomes PaymentPending and the process continues asynchronously. The synchronous call here is a choice, not a necessity, and it is time-boxed.
The shipment record from Shipping and the email from Notification: definitely not. Both listen to PaymentCaptured and run in their own time. The customer shouldn't stand on the checkout screen waiting for a shipping label to be created.
In the end, checkout's only remaining synchronous dependency is the payment provider, time-boxed. Deployments of Inventory, Shipping and Notification are no longer visible to customers. Their costs were the subject of the previous post: intermediate states, compensation, the outbox and the "awaiting confirmation" screen shown to the user. Building this transformation end to end with a real broker and outbox is the job of the planned Backend series ".NET Microservices in Practice" posts.
Which One When?
| Situation | Choice | Reason |
|---|---|---|
| The user must see the answer now and it lives in another service | Sync request/response, short timeout | Waiting is unavoidable; bound the time, read from a copy if possible |
| Something happened, and who cares may change | Event | The publisher doesn't know consumers, adding a consumer is cheap |
| Work is requested from a specific service, the result may come later | Command (over a queue) | The receiver is known, but it needn't be up |
| Another service's data is read often | Local copy through events | Removes the network and temporal coupling from the read path |
| Heavy, low-latency queries between services | gRPC | Required contract, small payload, deadline propagation |
| Access from a browser or the outside world | HTTP + JSON | Universal support, readability, tooling ecosystem |
The table isn't a rulebook but a starting point. Most real systems are hybrids: at Bookstore, an HTTP API to the outside, a few synchronous queries between services, and the rest of the workflow on events.
Common Pitfalls
Mistaking await for an asynchronous architecture. Writing a synchronous chain with async methods frees threads; it doesn't free you from temporal coupling.
Turning everything into events. Trying to produce an answer the user must see right now through events pushes the client into polling and the interface into uncertainty. If the answer is really needed now, a synchronous call is an honest choice.
Commands in disguise. Messages named as events but expecting a single consumer to do a specific job lose both the looseness of an event and the explicitness of a command.
Synchronous calls without timeouts. The default HttpClient timeout is 100 seconds. In a checkout chain that means one slow service can exhaust the whole thread pool and the user's patience. Every synchronous call needs a time limit, and the chain needs a total budget.
Chained fan-out. Deep chains where a request calls A, A calls B and B calls C both add up latency and make the weakest link the availability of the whole system. As depth grows, either copy the data or split the flow into events.
A Small Experiment
★☆☆ In the simulation, reduce Inventory's outage to 5 seconds, then raise it to 60. How do the synchronous error count and the asynchronous maximum wait change? Beyond what duration does even an "awaiting confirmation" screen become unacceptable to a customer?
★★☆ In the gRPC example, raise the deadline from 500 milliseconds to 3 seconds and see the Emma query succeed. Then think of this query as the third link in a checkout chain: if the total budget is 1 second and the first two calls took 400 milliseconds, what deadline should you give this call?
★★★ Pick a flow with synchronous calls from your own system. For each call, ask "do I really need this information before answering the customer?", and redesign the ones where the answer is "no" with an event or a local copy. Write down the new intermediate state and compensation need each change creates.
We've chosen how services talk; the next question is changing the contracts of that communication over time without breaking them. The next post in the series covers the split between breaking and non-breaking changes, expand-contract, the Tolerant Reader, event schema evolution and consumer-driven contract tests.