Communication Paradigms
| 8 min read
tl;dr: Understanding synchronous vs asynchronous communication, request-response vs pub-sub vs event streaming, service-to-service patterns, and choosing the right paradigm for your use case.
How services communicate determines your system’s scalability, resilience, and complexity. Choose wrong, and you’ll fight your architecture. Choose right, and it scales gracefully.
Here’s how to think about communication paradigms and when to use each.
The Three Main Paradigms
There are three fundamental ways services communicate:
Communication Paradigms Comparison
Request-Response: Synchronous, simple, but blocking and tight coupling
Pub-Sub: Decoupled, scalable, but eventual consistency
Event Streaming: Durable, replayable, but more complex
1. Request-Response
The simplest pattern: client sends a request, waits for a response.
Characteristics:
- Synchronous (client blocks waiting)
- Tight coupling (client knows about server)
- Strong consistency (immediate response)
- Low latency (direct communication)
Use when:
- Need immediate response
- Strong consistency required
- Simple query/command operations
- Low latency critical
Example:
// HTTP request-response
const response = await fetch('/api/users/123');
const user = await response.json();
2. Publish-Subscribe (Pub-Sub)
Publisher sends messages to a topic, subscribers receive them.
Characteristics:
- Asynchronous (fire and forget)
- Loose coupling (publisher doesn’t know subscribers)
- Eventual consistency (subscribers process independently)
- Scalable (multiple subscribers)
Use when:
- Multiple consumers needed
- Decoupling important
- Event-driven architecture
- Can accept eventual consistency
Example:
// Publisher
await pubsub.publish('user.created', {
userId: 123,
email: 'user@example.com'
});
// Subscriber
pubsub.subscribe('user.created', async (event) => {
await sendWelcomeEmail(event.email);
});
3. Event Streaming
Messages stored in a durable log, consumers read at their own pace.
Characteristics:
- Asynchronous (durable storage)
- Loose coupling (producers don’t know consumers)
- Eventual consistency
- Replayable (can reprocess events)
Use when:
- Need event replay
- Audit trail required
- Data pipeline/ETL
- High throughput needed
Example:
// Producer
await kafka.producer.send({
topic: 'user-events',
messages: [{ value: JSON.stringify(event) }]
});
// Consumer
await kafka.consumer.subscribe({ topic: 'user-events' });
await kafka.consumer.run({
eachMessage: async ({ message }) => {
await processEvent(JSON.parse(message.value));
}
});
Synchronous vs Asynchronous
The fundamental choice: should the caller wait?
Synchronous vs Asynchronous Communication
Synchronous:
- Client sends request → waits → receives response
- Pros: Immediate response, strong consistency, simple
- Cons: Blocking, tight coupling
- Use: Need immediate response, strong consistency required
Asynchronous:
- Client sends message → returns immediately → server processes later
- Pros: Non-blocking, decoupled, scalable
- Cons: Eventual consistency, more complex
- Use: Can accept delay, high throughput needed
Synchronous Communication
How it works:
- Client sends request
- Waits (blocks) for response
- Receives response
- Continues processing
Pros:
- Simple to reason about
- Immediate feedback
- Strong consistency
- Easy error handling
Cons:
- Blocking (ties up resources)
- Tight coupling
- Cascading failures
- Limited scalability
When to use:
- Need immediate response
- Strong consistency required
- Simple operations
- Low latency critical
Asynchronous Communication
How it works:
- Client sends message
- Returns immediately (doesn’t wait)
- Server processes later
- May send response via callback/webhook
Pros:
- Non-blocking
- Decoupled
- Better scalability
- Fault tolerant
Cons:
- More complex
- Eventual consistency
- Harder debugging
- Need error handling strategy
When to use:
- Can accept delay
- High throughput needed
- Decoupling important
- Event-driven architecture
Service-to-Service Patterns
How services communicate in a distributed system:
Service-to-Service Communication Patterns
Direct Communication: Services call each other directly (HTTP, gRPC)
- Pros: Simple, fast, low latency
- Cons: Tight coupling, no resilience
API Gateway: Single entry point that routes to multiple services
- Pros: Single entry point, centralized concerns (routing, auth, rate limiting)
- Cons: Potential bottleneck
Service Mesh: Sidecar proxies handle communication concerns
- Pros: Observability, resilience, security
- Cons: More complex, overhead
Direct Communication
Services call each other directly (HTTP, gRPC).
// Service A calls Service B directly
const response = await fetch('http://service-b/api/data');
Pros: Simple, fast, low latency Cons: Tight coupling, no resilience, cascading failures
Use for: Internal APIs, simple architectures
API Gateway
Single entry point that routes to multiple services.
// Client → Gateway → Services
// Gateway handles: routing, auth, rate limiting, etc.
Pros: Single entry point, centralized concerns Cons: Potential bottleneck, single point of failure
Use for: External APIs, microservices frontend
Service Mesh
Sidecar proxies handle communication concerns.
// Services communicate via sidecars
// Sidecars handle: retry, circuit breaker, observability
Pros: Observability, resilience, security Cons: More complex, overhead
Use for: Microservices, complex deployments
Resilience Patterns
Distributed systems fail. These patterns help:
Circuit Breaker Pattern
stateDiagram-v2
CLOSED: Requests pass through
OPEN: Fail fast
[*] --> CLOSED: Normal
CLOSED --> OPEN: Failure Threshold
OPEN --> HALF_OPEN: Timeout
HALF_OPEN --> CLOSED: Success
HALF_OPEN --> OPEN: Failure
States:
- CLOSED: Normal operation, requests pass through
- OPEN: Service failing, requests fail fast
- HALF_OPEN: Testing if service recovered
Circuit Breaker
Prevents cascading failures by “opening” when a service is failing.
States:
- CLOSED: Normal operation, requests pass through
- OPEN: Service failing, requests fail fast
- HALF_OPEN: Testing if service recovered
class CircuitBreaker {
constructor(threshold = 5, timeout = 60000) {
this.failures = 0;
this.threshold = threshold;
this.timeout = timeout;
this.state = 'CLOSED';
this.nextAttempt = Date.now();
}
async call(fn) {
if (this.state === 'OPEN') {
if (Date.now() < this.nextAttempt) {
throw new Error('Circuit breaker is OPEN');
}
this.state = 'HALF_OPEN';
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
onSuccess() {
this.failures = 0;
this.state = 'CLOSED';
}
onFailure() {
this.failures++;
if (this.failures >= this.threshold) {
this.state = 'OPEN';
this.nextAttempt = Date.now() + this.timeout;
}
}
}
Benefits:
- Prevents cascade failures
- Fast failure (no waiting)
- Auto recovery
Retry with Backoff
Retry failed requests with increasing delays.
Strategies:
Fixed Backoff: Same delay each time (1s, 1s, 1s) - Simple but predictable load
Exponential Backoff: Delay doubles (1s, 2s, 4s, 8s) - Reduces load, gives time to recover
Exponential + Jitter: Exponential with random variation - Prevents thundering herd, better distribution
Strategies:
Fixed Backoff:
// Same delay each time
await retry(fn, { delay: 1000, maxAttempts: 3 });
// Attempts: 0s, 1s, 2s
Exponential Backoff:
// Delay doubles each time
await retry(fn, {
delay: 1000,
backoff: 'exponential',
maxAttempts: 4
});
// Attempts: 0s, 1s, 2s, 4s
Exponential + Jitter:
// Exponential with random variation
const delay = baseDelay * Math.pow(2, attempt) +
Math.random() * jitter;
// Prevents thundering herd
Implementation:
async function retryWithBackoff(fn, options = {}) {
const {
maxAttempts = 3,
baseDelay = 1000,
maxDelay = 30000,
jitter = true
} = options;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxAttempts - 1) throw error;
const delay = Math.min(
baseDelay * Math.pow(2, attempt),
maxDelay
);
const jitteredDelay = jitter
? delay + Math.random() * delay * 0.1
: delay;
await sleep(jitteredDelay);
}
}
}
Benefits:
- Handles transient failures
- Reduces load spikes
- Better success rate
Timeout
Fail fast if service doesn’t respond in time.
async function callWithTimeout(fn, timeout) {
return Promise.race([
fn(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), timeout)
)
]);
}
Types:
- Connection timeout: Time to establish connection
- Read timeout: Time to read response
- Total timeout: Total time for entire operation
Benefits:
- Fail fast
- Resource cleanup
- Predictable behavior
Choosing the Right Paradigm
The choice depends on your requirements:
| Requirement | Paradigm | Why |
|---|---|---|
| Immediate response | Request-Response | Synchronous, low latency |
| Strong consistency | Request-Response | Immediate feedback |
| Multiple consumers | Pub-Sub / Streaming | Decoupled, scalable |
| Event replay | Event Streaming | Durable log |
| High throughput | Async (Pub-Sub/Streaming) | Non-blocking |
| Simple operations | Request-Response | Easiest to implement |
| Decoupling | Pub-Sub / Streaming | Loose coupling |
| Audit trail | Event Streaming | Durable, replayable |
Hybrid Approach:
Most systems use multiple paradigms:
// Synchronous for user-facing APIs
app.get('/api/users/:id', async (req, res) => {
const user = await db.getUser(req.params.id);
res.json(user);
});
// Asynchronous for background processing
app.post('/api/users', async (req, res) => {
const user = await db.createUser(req.body);
// Fire and forget - async processing
await eventBus.publish('user.created', user);
res.json(user);
});
// Event streaming for analytics
kafka.consumer.subscribe('user.events', async (event) => {
await analytics.track(event);
});
Trade-offs Summary
Request-Response:
- Pros: Simple, immediate, consistent
- Cons: Blocking, tight coupling, limited scale
Pub-Sub:
- Pros: Decoupled, scalable, flexible
- Cons: Eventual consistency, more complex
Event Streaming:
- Pros: Durable, replayable, scalable
- Cons: More complex, eventual consistency
Key Takeaways
- Choose based on requirements: Latency, consistency, coupling needs
- Use multiple paradigms: Different use cases need different approaches
- Add resilience: Circuit breakers, retries, timeouts
- Monitor everything: Know what’s happening in your system
- Start simple: Request-response, add async where needed
The best communication pattern is the simplest one that meets your requirements. Don’t over-engineer.
Most systems start with request-response and add async patterns as they scale. That’s a good approach.
Designing service communication? I provide architecture reviews, communication pattern design, and production-ready patterns for distributed systems. Let's discuss your approach.
P.S. Follow me on Twitter where I share engineering insights, system design patterns, and technical leadership perspectives.
Enjoyed this? Support my work
Buy me a coffee