Skip to content
CodeWithNaman Blog

Microservice Communication Patterns

Comparing synchronous vs asynchronous communication patterns for microservices.

Architecture , System Design 3 min read

The most consequential architectural decision in a microservice system isn’t which language to use or how to deploy — it’s how services communicate. Get this wrong, and you’ll spend months untangling cascading failures and debugging timeout chains.

After running 30+ services in production for two years, here’s our decision framework for choosing between synchronous and asynchronous communication.

Synchronous ←────────────────────────────────→ Asynchronous
REST gRPC Message Queue Event Streaming
(HTTP/JSON) (Protobuf) (RabbitMQ/SQS) (Kafka/PubSub)
High latency Low latency Decoupled Fully decoupled
Tight coupling Tight coupling Loose coupling No coupling

Use synchronous communication when the caller needs a response to continue.

REST is the default choice for external-facing APIs and service-to-service calls where developer experience matters more than raw performance.

# Service A calls Service B via REST
async def get_user_orders(user_id: str) -> list[Order]:
async with httpx.AsyncClient() as client:
response = await client.get(
f"{ORDER_SERVICE_URL}/api/v1/users/{user_id}/orders",
timeout=5.0,
)
response.raise_for_status()
return [Order.from_dict(o) for o in response.json()]

When to use REST:

  • External APIs consumed by third parties
  • Services with different technology stacks
  • When human-readable debugging matters
  • CRUD-style operations

When to avoid REST:

  • High-throughput internal communication (overhead is significant)
  • Strict latency requirements (JSON parsing adds 1-5ms)
  • Strongly-typed contracts (JSON Schema is no substitute for Protobuf)

gRPC is our default for internal service-to-service communication. The Protobuf contract enforces type safety, and HTTP/2 multiplexing handles high concurrency efficiently.

order.proto
service OrderService {
rpc GetUserOrders(GetUserOrdersRequest) returns (GetUserOrdersResponse);
rpc StreamOrderUpdates(StreamOrderUpdatesRequest)
returns (stream OrderUpdate);
}
message GetUserOrdersRequest {
string user_id = 1;
int32 page_size = 2;
}
message Order {
string id = 1;
OrderStatus status = 2;
int64 total_cents = 3;
}

gRPC advantages in production:

  • 5-10x lower latency than REST for internal calls
  • Automatic code generation in 12+ languages
  • Built-in streaming (unary, server, client, bidirectional)
  • Protocol buffers are 3-10x smaller than JSON

gRPC gotchas:

  • Browser support requires grpc-web (adds complexity)
  • Debugging requires special tools (not curl-friendly)
  • Load balancing needs L7-aware proxies (Envoy, Linkerd)

Use asynchronous communication when the caller doesn’t need an immediate response or when you need to decouple service lifecycles.

Message queues are ideal for task distribution — one producer, one consumer per message.

import pika
def publish_payment_request(payment: PaymentRequest):
connection = pika.BlockingConnection(
pika.ConnectionParameters('rabbitmq')
)
channel = connection.channel()
channel.queue_declare(queue='payment.processing', durable=True)
channel.basic_publish(
exchange='',
routing_key='payment.processing',
body=payment.to_json(),
properties=pika.BasicProperties(
delivery_mode=2, # Persistent
content_type='application/json',
)
)
connection.close()

When to use message queues:

  • Background job processing (email sending, report generation)
  • Work distribution with guaranteed delivery
  • Rate limiting (queue acts as a buffer)
  • When exactly-once processing matters

Event streaming is for event distribution — one producer, many consumers, with replay capability.

from kafka import KafkaProducer
import json
producer = KafkaProducer(
bootstrap_servers=['kafka-1:9092', 'kafka-2:9092'],
value_serializer=lambda v: json.dumps(v).encode('utf-8'),
key_serializer=lambda k: k.encode('utf-8'),
)
# Publish order event — all interested services can consume
producer.send(
'orders.events',
key='ord_123', # Same key = same partition = ordering
value={
'event_type': 'order.created',
'order_id': 'ord_123',
'customer_id': 'cust_456',
'total_cents': 9999,
'timestamp': '2026-04-15T10:30:00Z',
}
)
producer.flush()

When to use event streaming:

  • Multiple services need to react to the same event
  • Event replay is valuable (new service onboarding, debugging)
  • You need ordered processing per entity
  • Building an event-driven architecture

ScenarioPatternReasoning
External APIRESTUniversal, human-readable
Internal service call (needs response)gRPCLow latency, type safety
Background jobMessage QueueGuaranteed delivery, work distribution
Multi-service event notificationEvent StreamingFan-out, replay, ordering
Real-time data feedEvent StreamingHigh throughput, partitioning
Simple notificationMessage QueueFire-and-forget, no replay needed

In production, you’ll use all of these. Our order processing flow looks like:

Client → REST → API Gateway → gRPC → Order Service → Kafka → [
→ Notification Service (email/SMS)
→ Inventory Service (stock reservation)
→ Analytics Service (event tracking)
→ Fraud Service (risk scoring)
]

The key insight: synchronous for the critical path, asynchronous for everything else. The order creation itself is synchronous (client needs confirmation), but downstream effects are asynchronous (notifications, analytics, fraud checks).

  1. Default to async — if the caller doesn’t need the response immediately, don’t make them wait
  2. Add timeouts everywhere — synchronous calls without timeouts will cascade failures
  3. Idempotency is mandatory — both sync retries and async redelivery will cause duplicates
  4. Monitor queue depth — growing queues are the canary in the coal mine for downstream failures
  5. Don’t mix patterns casually — a service that accepts both REST and Kafka messages for the same operation will confuse everyone

Questions about microservice communication? Find me on GitHub or Twitter.

Related Posts