NodeJSInter-Service Communication

Service Communication Patterns

In a microservices system, services communicate in two fundamental modes: synchronous (the caller waits for a response) and asynchronous (the caller publishes an event and continues). Neither is universally better — they're tools with different trade-offs, and most real systems use both. This page maps out the decision: when to use REST, gRPC, message queues, or event streaming, and how the choice cascades into system behavior around failure, latency, and coupling.

Synchronous vs asynchronous

Text
SYNCHRONOUS (request/response)          ASYNCHRONOUS (event-driven)

  Service A ──► Service B                 Service A ──► [Queue/Stream] ──► Service B
       waits for reply                         returns immediately

  A's latency = sum of all downstream     A's latency is independent of B
  A fails if B is down                    A succeeds even if B is down
  Tight coupling (A knows B's address)    Loose coupling (A knows the event, not who handles it)
  Simple to reason about for one call     Harder to trace end-to-end
  Needs circuit breakers + timeouts       Needs DLQs + idempotency + eventual consistency
Synchronous is simpler to reason about; async is more resilient — choose based on whether the caller needs an immediate answer
The fundamental question is: **does the caller need an answer before it can proceed?** If Service A creates an order and needs the order ID to return to the user, it must call the orders service synchronously. If Service A creates an order and needs to send a confirmation email, it doesn't need to wait — it can publish an `order.created` event and let the email service handle it asynchronously. The rule of thumb: synchronous for **queries** and **commands that need confirmation**, asynchronous for **side effects** and **notifications**. Using synchronous calls for everything creates a fragile web of dependencies where any slow service drags down the entire call chain.
Communication pattern decision guide

Pattern

Protocol

When to use

REST/HTTP

HTTP/1.1 + JSON

Public APIs, browser clients, external integrations, when simplicity beats performance

gRPC

HTTP/2 + Protobuf

Internal service calls needing speed/type safety, streaming, polyglot services

Message queue

AMQP (RabbitMQ)

Task distribution, async jobs, retry on failure, rate leveling

Event stream

Kafka

High throughput, audit logs, multiple consumers, event sourcing, replay

WebSocket

TCP upgrade

Real-time push to browser clients (notifications, live data)

The synchronous call chain problem

Text
API Gateway ──► Orders ──► Inventory ──► Payments ──► Notifications
    100ms       + 80ms      + 200ms       + 150ms       + 50ms
                                                          = 580ms total

If Payments is down:       API Gateway returns 500 to the user
If Inventory is slow:      Every request to Orders is slow
If Notifications is slow:  Payments blocks waiting for it

FIX: Make side-effect calls async:
API Gateway ──► Orders ──► Inventory ──► Payments
                               │              │
                               └──►[queue]    └──►[queue]
                                     │               │
                                 Notifications   Receipt email
    Response time: 430ms (not 580ms)
    Notifications down → payment still succeeds; event is redelivered when it comes back up
Long synchronous call chains multiply latency and failure probability — async-ify side effects to break the chain and isolate failures
Each synchronous hop adds its latency AND its failure probability to every upstream call. With 5 services in a chain, each at 99.9% availability, the chain's availability is 0.999^5 = 99.5%. With 10 services at 99.9% each: 99%. Async side-effect calls break the chain: the payment service publishes `payment.completed` and returns immediately; the notification service picks it up when it's ready. The caller's latency is now independent of the notification service's health. The cost is that you can't confirm notification delivery synchronously — you need a DLQ and alerting to know if notifications are failing. Design systems so that **the primary happy-path response** doesn't depend on side-effect services.
Resilience patterns for synchronous calls

TS
import axios from 'axios'

// 1. Always set a timeout — without it, a slow service hangs your request forever:
const inventoryClient = axios.create({
  baseURL: process.env.INVENTORY_SERVICE_URL,
  timeout: 3000,   // fail fast after 3s
})

// 2. Retry with exponential backoff — for transient errors only:
async function callWithRetry<T>(fn: () => Promise<T>, maxRetries = 3): Promise<T> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn()
    } catch (err: any) {
      const isTransient = err.response?.status >= 500 || err.code === 'ECONNRESET'
      if (!isTransient || attempt === maxRetries) throw err
      const delay = 100 * 2 ** attempt + Math.random() * 100   // jitter prevents thundering herd
      await new Promise(r => setTimeout(r, delay))
    }
  }
  throw new Error('unreachable')
}

// 3. Circuit breaker — stop calling a failing service to let it recover:
// Use a library like 'opossum' for production:
// import CircuitBreaker from 'opossum'
// const breaker = new CircuitBreaker(callInventory, { timeout: 3000, errorThresholdPercentage: 50 })
Timeout + retry + circuit breaker are the three mandatory resilience layers for any synchronous service call — omit any one and you're one slow downstream from an outage
**Timeouts** prevent a slow service from holding your thread forever. **Retries** handle transient network failures and brief service hiccups — but only retry **idempotent** operations; retrying a payment or order creation without idempotency keys creates duplicates. **Jitter** in backoff (random delay component) prevents the thundering herd where every retrying client hits the recovering service at the same instant. A **circuit breaker** opens (stops calling) when the downstream error rate exceeds a threshold, giving the failing service time to recover without being hammered; it periodically allows a probe request through to test recovery. These patterns are table stakes for any service that calls another service — not optional optimizations.
Idempotency keys for safe retries

TS
import { randomUUID } from 'node:crypto'

// Producer: generate a stable key for each logical operation
const idempotencyKey = randomUUID()   // or derive from request ID

await axios.post('/payments', { amount: 100, currency: 'USD' }, {
  headers: { 'Idempotency-Key': idempotencyKey },
})

// Receiver (payments service): store the key and result; return cached result on duplicate:
async function processPayment(req: Request, res: Response) {
  const key = req.headers['idempotency-key'] as string
  if (key) {
    const cached = await redis.get(`idem:${key}`)
    if (cached) return res.json(JSON.parse(cached))   // return same result as first call
  }

  const result = await chargeCard(req.body)

  if (key) await redis.setex(`idem:${key}`, 86400, JSON.stringify(result))
  res.json(result)
}
Choosing the right pattern — a decision tree
  • Does the caller need a response before it can proceed? → synchronous (REST or gRPC).

  • Is this a side effect the caller doesn't need to wait for? → async message queue or event.

  • Is this internal, performance-critical, and typed between two services you control? → gRPC over REST.

  • Will multiple independent services need to react to this event? → event stream (Kafka fanout) or pub/sub exchange (RabbitMQ fanout).

  • Does order of events for a given entity matter? → Kafka with a partition key (not a work queue).

  • Do you need replay or audit? → Kafka (retained log) over RabbitMQ (messages deleted on ack).

  • Is this a browser or mobile client? → REST; gRPC requires a proxy.

The saga pattern for distributed transactions

Text
Problem: creating an order requires atomically updating Orders + Inventory + Payments.
         No single ACID transaction spans three services.

Choreography-based saga (event-driven):
  Orders publishes  "order.created"
    → Inventory consumes, reserves stock, publishes "inventory.reserved"
      → Payments consumes, charges card, publishes "payment.completed"
        → Orders consumes, marks order CONFIRMED

  If Payments fails → publishes "payment.failed"
    → Inventory consumes, releases reserved stock  (compensating transaction)
      → Orders consumes, marks order CANCELLED

Orchestration-based saga (central coordinator):
  Saga orchestrator calls each service in sequence,
  tracks state, and issues compensating calls on failure.
Sagas replace distributed ACID transactions with a sequence of local transactions and compensating actions — eventual consistency, not atomicity
A **saga** is the standard pattern for multi-service data consistency. In **choreography**, each service listens for events and publishes its own — simpler, but harder to trace end-to-end as the system grows. In **orchestration**, a saga coordinator (a dedicated service or workflow engine like Temporal) calls each step and handles failures centrally — easier to trace and debug, but adds a new service. Both patterns rely on **compensating transactions**: reverse-engineering the effect of a previous step when a later step fails. Compensating transactions must be idempotent and must be implemented for every step. The key mental model: sagas give you **eventual consistency** (all steps will eventually complete or be compensated), not the all-or-nothing guarantee of ACID.
Next
Centralize external access and cross-cutting concerns with an [API Gateway](/nodejs/api-gateway).