Sync vs Async Communication ā REST, gRPC & Message Queues
How microservices talk to each other: synchronous (REST, gRPC) vs asynchronous (message queues, events). When to use each, choreography vs orchestration, the Outbox pattern for reliable publishing, and correlation IDs across services.
How your services communicate is one of the most consequential architectural decisions you'll make. Get it wrong and you'll have cascading failures, data inconsistency, and a system that's hard to reason about.
This article gives you a framework for choosing the right communication pattern for each service boundary.
Synchronous vs Asynchronous ā The Core Trade-off
Synchronous (Request-Response):
Service A āāāā request āāāāā Service B
Service A āāāā response āāā Service B
Service A blocks until B responds
Asynchronous (Fire and Forget):
Service A āāāā message āāāāā Message Broker
Service A continues (doesn't wait)
Message Broker āāā Service B (eventually)| | Synchronous | Asynchronous | |---|---|---| | Coupling | Temporal coupling (A needs B to be up) | Decoupled (A just needs the broker) | | Complexity | Simple request/response | Publisher, broker, consumer, at-least-once delivery | | Latency | Immediate response | Eventual processing | | Resilience | A fails if B is down | A succeeds even if B is down | | Best for | Need a result right now | Fire-and-forget, workflows, events |
REST ā The Default for Synchronous
REST over HTTP/HTTPS is the default for service-to-service communication. It's well-understood, tooled, and language-agnostic.
When REST works well:
- CRUD operations on resources
- Public-facing APIs
- When the caller needs an immediate response
- When teams use different tech stacks (REST is language-agnostic)
REST best practices for microservices:
GET /orders/{id} ā get order by ID
POST /orders ā create order
PUT /orders/{id}/status ā update order status
DELETE /orders/{id} ā cancel order
Versioning:
/api/v1/orders ā version in URL (simple, visible)
Accept: application/vnd.myapi.v1+json ā version in header (clean URLs)REST limitations:
- HTTP overhead for high-frequency internal calls
- No schema enforcement at compile time
- No built-in streaming
- Over-fetching (response includes fields you don't need)
gRPC ā For High-Performance Internal Calls
gRPC uses HTTP/2 + Protocol Buffers (binary serialization). It's faster than REST, has schema enforcement, and supports streaming.
gRPC vs REST performance:
JSON over HTTP/1.1:
Request: ~200 bytes (text, headers)
Parse: ~0.5ms (JSON deserialization)
Protobuf over HTTP/2:
Request: ~50 bytes (binary, compressed)
Parse: ~0.05ms (binary deserialization)
10Ć smaller messages, 10Ć faster parsingDefining a gRPC service:
// order.proto
syntax = "proto3";
service OrderService {
rpc GetOrder (GetOrderRequest) returns (Order);
rpc ListOrders (ListOrdersRequest) returns (stream Order); // server streaming
rpc CreateOrder (CreateOrderRequest) returns (CreateOrderResponse);
}
message Order {
string id = 1;
string user_id = 2;
OrderStatus status = 3;
repeated OrderItem items = 4;
google.protobuf.Timestamp created_at = 5;
}
enum OrderStatus {
PENDING = 0;
CONFIRMED = 1;
SHIPPED = 2;
DELIVERED = 3;
}// .NET gRPC service implementation
public class OrderGrpcService : OrderService.OrderServiceBase
{
private readonly IOrderRepository _orders;
public override async Task<Order> GetOrder(
GetOrderRequest request, ServerCallContext context)
{
var order = await _orders.GetByIdAsync(request.Id);
if (order == null)
throw new RpcException(new Status(StatusCode.NotFound, "Order not found"));
return MapToProto(order);
}
// Server-side streaming: push multiple responses for one request
public override async Task ListOrders(
ListOrdersRequest request,
IServerStreamWriter<Order> responseStream,
ServerCallContext context)
{
await foreach (var order in _orders.StreamByUserAsync(request.UserId))
{
await responseStream.WriteAsync(MapToProto(order));
}
}
}When to use gRPC:
- High-frequency internal service-to-service calls
- When you need streaming (real-time data, large result sets)
- When schema enforcement matters (breaking change detection at compile time)
- Mobile clients on poor network connections (binary is smaller)
When NOT to use gRPC:
- Browser clients (limited gRPC-web support ā REST is easier)
- Public APIs (REST is more accessible)
- Simple one-off queries (overhead of proto definition isn't worth it)
Message Queues ā For Asynchronous Decoupling
A message queue sits between services. The publisher sends a message and moves on. The consumer processes it when it's ready.
RabbitMQ ā Task Queues and Routing
RabbitMQ uses an exchange/queue model. Exchanges route messages to queues based on routing keys.
Topology:
Publisher ā Exchange ā (routing rule) ā Queue ā Consumer
Exchange types:
Direct: exact routing key match
Topic: routing key pattern (order.created, order.*)
Fanout: broadcast to all bound queues
Headers: route by message headers// Publishing to RabbitMQ
public class OrderEventPublisher
{
private readonly IModel _channel;
public void PublishOrderCreated(OrderCreatedEvent @event)
{
var body = JsonSerializer.SerializeToUtf8Bytes(@event);
_channel.BasicPublish(
exchange: "order-events",
routingKey: "order.created",
basicProperties: null,
body: body
);
}
}
// Consuming from RabbitMQ
public class NotificationConsumer : BackgroundService
{
protected override Task ExecuteAsync(CancellationToken ct)
{
_channel.QueueDeclare("notification-queue", durable: true);
_channel.QueueBind("notification-queue", "order-events", "order.created");
var consumer = new EventingBasicConsumer(_channel);
consumer.Received += async (_, ea) =>
{
var @event = JsonSerializer.Deserialize<OrderCreatedEvent>(ea.Body.ToArray());
await _notificationService.SendOrderConfirmationAsync(@event);
_channel.BasicAck(ea.DeliveryTag, multiple: false); // acknowledge
};
_channel.BasicConsume("notification-queue", autoAck: false, consumer);
return Task.CompletedTask;
}
}Azure Service Bus ā Enterprise Messaging
Azure Service Bus is the managed equivalent for Azure workloads. Supports queues (point-to-point) and topics (publish-subscribe).
// Azure Service Bus sender
var client = new ServiceBusClient(connectionString);
var sender = client.CreateSender("order-events");
var message = new ServiceBusMessage(
JsonSerializer.SerializeToUtf8Bytes(orderCreatedEvent))
{
ContentType = "application/json",
CorrelationId = correlationId,
SessionId = order.UserId, // session-based ordering per user
};
await sender.SendMessageAsync(message);Kafka ā Event Streaming at Scale
Kafka is a distributed log. Messages are persisted and consumers can replay history.
Kafka key concepts:
Topic: a named log (e.g., "order-events")
Partition: topics split into partitions for parallelism
Offset: position of a message in a partition
Consumer Group: multiple consumers sharing topic consumption
Partitioning by key:
Kafka routes messages with the same key to the same partition
This guarantees ordering per key (e.g., per user_id or order_id)
Partition 0: user_1 messages
Topic āāāāāāāāāā Partition 1: user_2 messages
Partition 2: user_3 messagesUse Kafka when: Event replay is needed, high throughput (millions of messages/sec), event sourcing, stream processing (Kafka Streams, ksqlDB).
Use RabbitMQ/Azure Service Bus when: You need routing flexibility, priority queues, dead-letter queues with easy management, or you're on Azure.
Events vs Commands
These are conceptually different message types:
Command: A request to do something. One sender, one receiver.
Message: "ProcessPayment" for order #123 ā Payment Service
Semantics: "Please do this"
If Payment Service is down, this is a problemEvent: A notification that something happened. One publisher, many subscribers.
Message: "OrderPlaced" for order #123
Semantics: "This happened. Do what you need."
Publisher doesn't know or care who reacts
Notification Service, Inventory Service, Analytics Service all subscribeRule of thumb:
- Use commands when you need a result or acknowledgment
- Use events when you want to notify interested parties without coupling
Choreography vs Orchestration
When coordinating a multi-step workflow across services, you have two options:
Choreography ā Services React to Events
No central coordinator. Each service knows what to do when it receives an event.
OrderPlaced event published
ā Inventory Service reacts: reserve stock ā InventoryReserved event
ā InventoryReserved event published
ā Payment Service reacts: charge card ā PaymentProcessed event
ā PaymentProcessed event published
ā Notification Service reacts: send email
No one is "in charge". Services are decoupled.Pros: Loose coupling. Services can be added/removed without changing others.
Cons: Workflow logic is distributed ā hard to understand the full picture. Hard to track what step a business process is at. Debugging requires tracing events across multiple services.
Orchestration ā Central Coordinator
A dedicated orchestrator service drives the workflow.
// Order Saga Orchestrator
public class CreateOrderSaga
{
public async Task ExecuteAsync(CreateOrderCommand command)
{
// Step 1: Reserve inventory
var reservation = await _inventoryService.ReserveAsync(command.Items);
if (!reservation.Success)
{
await _orderService.MarkFailedAsync(command.OrderId, "Inventory unavailable");
return;
}
// Step 2: Process payment
var payment = await _paymentService.ChargeAsync(command.UserId, command.Total);
if (!payment.Success)
{
// Compensate: release inventory
await _inventoryService.ReleaseAsync(reservation.Id);
await _orderService.MarkFailedAsync(command.OrderId, "Payment failed");
return;
}
// Step 3: Confirm order
await _orderService.ConfirmAsync(command.OrderId, payment.TransactionId);
// Step 4: Notify (fire and forget ā not business-critical)
await _notificationService.SendConfirmationAsync(command.UserId, command.OrderId);
}
}Pros: Workflow is visible in one place. Easy to track state and handle failures.
Cons: Orchestrator is coupled to all participants. Can become a bottleneck.
Recommendation: Use choreography for simple event fans-out. Use orchestration (Saga) for complex, multi-step transactions where you need explicit compensating transactions.
The Outbox Pattern ā Reliable Event Publishing
The hardest problem in event-driven microservices: publishing an event and updating the database atomically.
The problem:
1. Update order status in DB ā
2. Publish "OrderShipped" event to Kafka ā (Kafka is down)
ā DB updated, event never published
ā Notification never sent, inventory never updated
ā Data inconsistencyThe Outbox pattern uses the database as a reliable relay:
1. In the SAME database transaction:
- Update order status
- INSERT into outbox table: {event: "OrderShipped", payload: {...}, published: false}
2. Separate background worker (Outbox Processor):
- Polls outbox table for unpublished events
- Publishes each event to Kafka/Service Bus
- Marks event as published in outbox table
Result: Either both the order update and the outbox entry commit,
or neither does. The outbox processor handles eventual publishing.// Outbox table
CREATE TABLE outbox_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
published_at TIMESTAMP NULL,
retry_count INT DEFAULT 0
);
// Service layer ā atomic: DB update + outbox insert
public async Task ShipOrderAsync(Guid orderId)
{
using var tx = await _db.BeginTransactionAsync();
await _db.ExecuteAsync(
"UPDATE orders SET status = 'Shipped', shipped_at = NOW() WHERE id = @id",
new { id = orderId });
await _db.ExecuteAsync(
"INSERT INTO outbox_events (event_type, payload) VALUES (@type, @payload::jsonb)",
new { type = "OrderShipped", payload = JsonSerializer.Serialize(new { OrderId = orderId }) });
await tx.CommitAsync();
// Even if Kafka is down, the event is safely stored in the DB
}Correlation IDs ā Tracing Across Services
When a single user request touches 5 services, you need to trace it across all of them.
Incoming request:
POST /checkout ā X-Correlation-ID: req-abc123
Service A logs: [req-abc123] Created order
Service B logs: [req-abc123] Reserved inventory
Service C logs: [req-abc123] Charged payment
Service D logs: [req-abc123] Sent confirmation email
Without correlation IDs:
When checkout fails, you search logs across 4 services with no link.
With correlation IDs: grep for "req-abc123" and see the full trace.// ASP.NET Core middleware ā propagate correlation ID
public class CorrelationIdMiddleware
{
public async Task InvokeAsync(HttpContext context)
{
var correlationId = context.Request.Headers["X-Correlation-ID"].FirstOrDefault()
?? Guid.NewGuid().ToString();
context.Items["CorrelationId"] = correlationId;
context.Response.Headers["X-Correlation-ID"] = correlationId;
// Add to outgoing HTTP client requests
using var scope = _logger.BeginScope(new { CorrelationId = correlationId });
await _next(context);
}
}
// HttpClient factory ā inject correlation ID into downstream calls
services.AddHttpClient("order-service")
.AddHttpMessageHandler<CorrelationIdDelegatingHandler>();Choosing the Right Protocol for Each Boundary
Boundary Protocol
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
API Gateway ā Services REST (public-facing, tooling)
Service ā Service (sync needed) gRPC (internal, typed, fast)
Service ā Service (async ok) Message Queue (decoupled, resilient)
Order placed ā Notification Event (async, publish-subscribe)
Checkout ā Inventory gRPC or REST (sync, need immediate confirmation)
Order shipped ā Notify/Analytics Event via Kafka (fanout, replay needed)Key Takeaways
- REST for CRUD and public APIs. gRPC for high-frequency internal calls (faster, typed, streaming).
- Message queues decouple services: publisher doesn't need receiver to be up.
- RabbitMQ/Azure Service Bus for routing and task queues. Kafka for event streams at scale with replay.
- Events broadcast what happened. Commands request an action with a specific handler.
- Choreography for simple event fan-outs. Orchestration (Saga) for complex multi-step transactions.
- Outbox pattern is the correct way to reliably publish events alongside a database write.
- Correlation IDs are mandatory for tracing requests across microservices.
gRPC Knowledge Check
5 questions Ā· Test what you just learned Ā· Instant explanations
Enjoyed this article?
Explore the System Design learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.