Skip to content
Techsense Developers
TrustLet's Talk
Insights
Software & Platform7 min readAug 31, 2026

What are the Core Patterns for Inter-Service Communication in a Node.js Microservices Architecture?

When you split a monolith into services, the hardest problem is rarely the business logic. It is node.js microservices communication: deciding how services talk to each other without creating a…

When you split a monolith into services, the hardest problem is rarely the business logic. It is node.js microservices communication: deciding how services talk to each other without creating a distributed monolith that fails in unpredictable ways. The core patterns come down to three choices. Use synchronous request/response (REST or gRPC) when a caller needs an immediate answer, use asynchronous messaging (events and message queues) when you want services to stay decoupled and resilient, and use a hybrid of both for most real systems. This post explains each pattern, when to reach for it, and how to implement it in Node.js.

Why the communication pattern matters more than the framework

I have seen teams spend weeks arguing about Express versus Fastify while the real fragility lived in how services coordinated. The framework handles a single request. The communication pattern determines what happens when a downstream service is slow, returns an error, or disappears entirely.

Two failure modes dominate poorly designed systems:

  • Tight temporal coupling. Service A cannot complete its work unless Service B, C, and D are all healthy at the same instant. One slow dependency cascades into timeouts everywhere.
  • Hidden data coupling. Services share database rows or assume internal schemas, so a change in one breaks three others.

Choosing the right pattern is how you avoid both. The rest of this post is organized around the two families of patterns and the trade-offs inside each.

Synchronous communication patterns for node.js microservices communication

Synchronous communication means the caller sends a request and blocks (logically) until it receives a response. This is the most intuitive model and the right default when the client genuinely needs data back before it can proceed.

REST over HTTP

REST remains the workhorse. It is human-readable, cache-friendly, and universally supported. In Node.js a typical service-to-service call looks like this:

// order-service calling inventory-service
import { request } from 'undici';

async function checkStock(sku, quantity) {
  const { statusCode, body } = await request(
    `http://inventory-service/stock/${sku}`,
    {
      method: 'GET',
      headersTimeout: 2000,
      bodyTimeout: 2000,
    }
  );

  if (statusCode !== 200) {
    throw new Error(`inventory-service returned ${statusCode}`);
  }

  const { available } = await body.json();
  return available >= quantity;
}

Note the explicit timeouts. Never make a service call without them. A missing timeout is the single most common cause of thread-pool starvation and cascading outages.

Use REST when:

  • You expose public or partner-facing APIs.
  • Payloads are moderate and human-debuggability matters.
  • You want to lean on HTTP caching, proxies, and standard tooling.

gRPC for internal, high-throughput calls

gRPC uses HTTP/2 and Protocol Buffers, giving you a binary wire format, strongly typed contracts, and streaming. For internal service-to-service traffic where latency and payload size matter, it is often the better choice.

// inventory.proto
syntax = "proto3";

service Inventory {
  rpc CheckStock (StockRequest) returns (StockReply);
}

message StockRequest {
  string sku = 1;
  int32 quantity = 2;
}

message StockReply {
  bool available = 1;
}
// client side
import { credentials } from '@grpc/grpc-js';
import { InventoryClient } from './generated/inventory_grpc_pb.js';

const client = new InventoryClient(
  'inventory-service:50051',
  credentials.createInsecure()
);

gRPC vs REST for microservices

This is one of the most searched questions, and the honest answer is that they solve overlapping but different problems.

Concern REST gRPC
Wire format JSON (text) Protobuf (binary)
Contract OpenAPI (optional) .proto (enforced)
Browser support Native Needs a proxy (grpc-web)
Streaming Limited (SSE, chunked) First-class bidirectional
Debuggability High Lower without tooling

My practical guidance: REST at the edge, gRPC in the core. Expose REST to clients and third parties where readability and compatibility win. Use gRPC between internal services where you control both ends and care about performance and type safety.

The critical caveat for both: synchronous calls create runtime coupling. If you chain five services synchronously, your availability is the product of their individual availabilities. You need circuit breakers, retries with backoff, and timeouts on every hop.

import CircuitBreaker from 'opossum';

const breaker = new CircuitBreaker(checkStock, {
  timeout: 2000,
  errorThresholdPercentage: 50,
  resetTimeout: 10000,
});

breaker.fallback(() => ({ available: false, degraded: true }));

Asynchronous communication patterns

Asynchronous communication decouples the sender from the receiver in time. The producer emits a message and moves on. Consumers process it when they are ready. This is how you build systems that stay up even when parts of them are down.

Message queues (point-to-point)

A queue delivers each message to exactly one consumer. This is ideal for work distribution: order processing, image resizing, sending email. Producers do not wait for the work to finish.

// producer using amqplib (RabbitMQ)
import amqp from 'amqplib';

const conn = await amqp.connect('amqp://rabbitmq');
const channel = await conn.createChannel();
await channel.assertQueue('order.processing', { durable: true });

channel.sendToQueue(
  'order.processing',
  Buffer.from(JSON.stringify({ orderId: 'A123' })),
  { persistent: true }
);

The durable queue and persistent message flags matter. Without them, a broker restart loses in-flight work.

Event-driven microservices in Node.js (publish/subscribe)

In pub/sub, a producer publishes an event and any number of consumers react independently. The producer does not know or care who is listening. This is the foundation of event-driven microservices in Node.js, and it is the pattern that best eliminates coupling.

Consider an order placement. Instead of the order service calling inventory, billing, and notifications directly, it publishes one event:

// publishing a domain event to a Kafka topic via kafkajs
import { Kafka } from 'kafkajs';

const kafka = new Kafka({ brokers: ['kafka:9092'] });
const producer = kafka.producer();
await producer.connect();

await producer.send({
  topic: 'order.placed',
  messages: [
    {
      key: 'A123',
      value: JSON.stringify({
        orderId: 'A123',
        customerId: 'C55',
        total: 4999,
        occurredAt: new Date().toISOString(),
      }),
    },
  ],
});

Inventory, billing, and notification services each subscribe and act on their own schedule. Adding a new consumer (say, analytics) requires zero changes to the order service.

Use event-driven patterns when:

  • The producer does not need a response.
  • Multiple services care about the same fact.
  • You want independent scaling and failure isolation.

The trade-offs of going async

Asynchronous systems are more resilient but harder to reason about. Be deliberate about these concerns:

  1. Eventual consistency. Data across services converges over time, not instantly. Your product and UX must tolerate this.
  2. Idempotency. At-least-once delivery means consumers will occasionally see duplicates. Design handlers to be safe on replay, usually with a deduplication key.
  3. Ordering. Most brokers guarantee order only within a partition or queue. Do not assume global ordering.
  4. The dual-write problem. Writing to your database and publishing an event are two operations that can fail independently. Use the Transactional Outbox pattern: write the event to an outbox table in the same transaction, then relay it to the broker.
-- outbox row written in the same DB transaction as the state change
INSERT INTO outbox (id, aggregate_id, type, payload, published)
VALUES (gen_random_uuid(), 'A123', 'order.placed', '{...}', false);

A separate relay process reads unpublished rows, sends them to the broker, and marks them published. This guarantees the event is emitted if and only if the state change committed.

Choosing and combining patterns

Real architectures blend all of these. A practical decision guide:

  • Need an answer now, external caller? REST.
  • Need an answer now, internal high-throughput? gRPC.
  • Fire-and-forget work for one consumer? Message queue.
  • A fact many services care about? Pub/sub events.

Getting this blend right depends heavily on your domain. Latency budgets in trading differ from consistency needs in logistics. Our team applies these patterns differently across the industries we work in, and we help teams design the messaging and API layers as part of our platform engineering capabilities.

A final principle: prefer async by default, use sync where you must. Synchronous calls are easier to build but couple your services at runtime. Asynchronous messaging costs more upfront in tooling and mental model but pays off in resilience as the system grows.

FAQ

When should I use gRPC instead of REST for microservices?

Use gRPC for internal, service-to-service calls where you control both ends and need low latency, small payloads, strong typed contracts, or streaming. Use REST at the edge for public APIs, browser clients, and anywhere human debuggability and broad compatibility matter. Many teams run both: REST at the boundary, gRPC internally.

How do I prevent cascading failures with synchronous calls?

Apply timeouts on every call, add retries with exponential backoff and jitter, and wrap dependencies in a circuit breaker so a failing downstream fails fast instead of exhausting resources. Provide fallbacks or degraded responses where the business allows. Libraries like opossum make circuit breaking straightforward in Node.js.

What is the transactional outbox pattern and why do I need it?

It solves the dual-write problem: updating your database and publishing an event are separate operations that can fail independently, leaving state and events out of sync. With an outbox, you write the event into a table in the same transaction as the state change, then a relay process publishes it to the broker. This guarantees the event fires exactly when the data commits.

Is event-driven architecture always better than synchronous calls?

No. Event-driven designs maximize decoupling and resilience but introduce eventual consistency, duplicate handling, and harder debugging. If a caller genuinely needs an immediate answer, a synchronous request/response is simpler and correct. Choose per interaction, not once for the whole system.

How do I handle duplicate messages in Node.js consumers?

Assume at-least-once delivery and make handlers idempotent. Attach a unique message or event ID, and track processed IDs (in a database or cache) so replays are safely ignored. For state updates, prefer operations that are naturally idempotent, such as setting a value rather than incrementing it.