Skip to content
Node.js nd microservices 5 min read

REST-Based Inter-Service Communication

In a microservices system, no single service holds all the data. To fulfil a request, one service often has to ask another for information — an order service might call a customer service to validate a user, or a payment service to charge a card. The simplest and most ubiquitous way to do this in Node.js is a synchronous REST call over HTTP. It is easy to reason about, works across any language, and needs no extra infrastructure. But synchronous coupling has real costs, and getting timeouts, retries, and partial failures right is what separates a toy from a production system.

How synchronous REST works

With REST-based communication, the caller (the client service) issues an HTTP request to the callee (the upstream service) and blocks until a response comes back. The request carries a verb, a path, headers, and usually a JSON body; the response carries a status code and a body. Node’s native fetch (stable since Node 18, and the default in 20/22 LTS) makes the call ergonomic without any third-party library.

// order-service: calling the customer service
async function getCustomer(customerId) {
  const res = await fetch(`http://customer-service:8081/customers/${customerId}`, {
    headers: { accept: "application/json" },
  });

  if (!res.ok) {
    throw new Error(`customer-service responded ${res.status}`);
  }
  return res.json();
}

const customer = await getCustomer("c-4821");
console.log(`Placing order for ${customer.name}`);

Output:

Placing order for Asha Patel

The hostname customer-service is not DNS magic — it is resolved by your platform’s service discovery (Kubernetes Services, Consul, a load balancer). See the service discovery page for how names map to instances.

Defining a contract

The contract is the agreement between services about paths, payloads, and status codes. Because the two services deploy independently, the contract must be explicit and versioned — a breaking change in the upstream can take down every caller at once. Pin the version in the URL path (or a header) so old and new shapes can coexist during a rollout.

ConcernRecommendation
VersioningPrefix routes, e.g. /v1/customers/:id
Payload shapeValidate with a schema (Zod, JSON Schema) on both ends
Error semanticsUse real HTTP codes: 404 not found, 409 conflict, 503 unavailable
DocumentationPublish an OpenAPI spec per service

Validating the response defends the caller against an upstream that drifts from the contract:

import { z } from "zod";

const Customer = z.object({
  id: z.string(),
  name: z.string(),
  tier: z.enum(["free", "pro", "enterprise"]),
});

async function getCustomer(id) {
  const res = await fetch(`http://customer-service:8081/v1/customers/${id}`);
  if (!res.ok) throw new Error(`status ${res.status}`);
  return Customer.parse(await res.json()); // throws if shape is wrong
}

Timeouts and retries

A call with no timeout is a latent outage: if the upstream hangs, your request handler hangs with it, threads of work pile up, and the failure cascades. Always bound the call with AbortSignal.timeout(). Layer a small, bounded retry on top for transient errors (network blips, 503s), but only for idempotent operations — retrying a non-idempotent POST can double-charge a customer.

import { setTimeout as sleep } from "node:timers/promises";

async function fetchWithRetry(url, opts = {}, { retries = 2, timeoutMs = 800 } = {}) {
  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      const res = await fetch(url, {
        ...opts,
        signal: AbortSignal.timeout(timeoutMs),
      });
      // Retry only on transient server errors.
      if (res.status >= 500 && attempt < retries) {
        throw new Error(`transient ${res.status}`);
      }
      return res;
    } catch (err) {
      if (attempt === retries) throw err;
      const backoff = 2 ** attempt * 100 + Math.random() * 50; // jittered
      console.warn(`attempt ${attempt + 1} failed (${err.message}); retrying in ${Math.round(backoff)}ms`);
      await sleep(backoff);
    }
  }
}

Output:

attempt 1 failed (The operation was aborted due to timeout); retrying in 113ms
attempt 2 failed (transient 503); retrying in 287ms

Use exponential backoff with jitter. Fixed-interval retries from many clients synchronise into a “thundering herd” that hammers a recovering service back into the ground.

Handling partial failures

In a distributed system, the upstream will sometimes be unavailable, slow, or only partially correct. Design every call site for that reality. Decide per call whether the dependency is critical (fail the whole request) or optional (degrade gracefully — return a cached value, a default, or omit an enrichment field).

async function buildOrderView(orderId) {
  const order = await getOrder(orderId); // critical: must succeed

  // Optional enrichment — never block the order on the recommendations service.
  let recommendations = [];
  try {
    recommendations = await fetchWithRetry(
      `http://reco-service:8090/v1/recommendations?order=${orderId}`,
      {},
      { retries: 1, timeoutMs: 300 },
    ).then((r) => r.json());
  } catch {
    console.warn("reco-service unavailable; serving order without recommendations");
  }

  return { ...order, recommendations };
}

For repeated failures, wrap the client in a circuit breaker so a struggling upstream stops receiving traffic instead of being pounded by retries from every caller. That pattern has its own page.

The downsides of synchronous coupling

REST is the right default, but it ties services together at runtime:

  • Temporal coupling — both services must be up at the same time for the call to succeed.
  • Latency stacking — a request fanning out to 4 services is as slow as the slowest, and their latencies add up across a chain.
  • Cascading failure — without timeouts and breakers, one slow service can exhaust every caller’s resources.
  • Tighter contracts — callers depend on the upstream’s exact API and availability.

When the operation can be fire-and-forget, or you need to decouple producers from consumers in time, prefer asynchronous messaging or event streaming instead of a blocking call.

Best practices

  • Always set a per-call timeout with AbortSignal.timeout(); never issue an unbounded request.
  • Retry only idempotent operations, with a small bounded count and jittered exponential backoff.
  • Validate every response against a schema — treat upstreams as untrusted contracts.
  • Version your APIs so producers and consumers can deploy independently.
  • Classify each dependency as critical or optional and degrade gracefully for the optional ones.
  • Propagate a correlation/trace ID header on every hop for end-to-end observability.
  • Reach for a circuit breaker and async messaging once synchronous chains grow deep.
Last updated June 14, 2026
Was this helpful?