Back to blog
System Designintermediate

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.

LearnixoApril 15, 202610 min read
MicroservicesSystem DesignRESTgRPCMessage QueuesKafkaRabbitMQEvent-Driven
Share:š•

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 parsing

Defining a gRPC service:

PROTOBUF
// 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;
}
C#
// .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
C#
// 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).

C#
// 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 messages

Use 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 problem

Event: 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 subscribe

Rule 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.

C#
// 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 inconsistency

The 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.
C#
// 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.
C#
// 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?

Share:š•

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.