home /
writings /
thoughts /
courses /
research /
projects /
Back to writings

Communication Paradigms

Dec 10, 2025 | 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:

  1. Client sends request
  2. Waits (blocks) for response
  3. Receives response
  4. 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:

  1. Client sends message
  2. Returns immediately (doesn’t wait)
  3. Server processes later
  4. 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:

RequirementParadigmWhy
Immediate responseRequest-ResponseSynchronous, low latency
Strong consistencyRequest-ResponseImmediate feedback
Multiple consumersPub-Sub / StreamingDecoupled, scalable
Event replayEvent StreamingDurable log
High throughputAsync (Pub-Sub/Streaming)Non-blocking
Simple operationsRequest-ResponseEasiest to implement
DecouplingPub-Sub / StreamingLoose coupling
Audit trailEvent StreamingDurable, 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

  1. Choose based on requirements: Latency, consistency, coupling needs
  2. Use multiple paradigms: Different use cases need different approaches
  3. Add resilience: Circuit breakers, retries, timeouts
  4. Monitor everything: Know what’s happening in your system
  5. 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