Back to Communication: Sync, Async, gRPC
Q1Question 1 of 8

How microservices communicate: synchronous versus asynchronous

Key Concepts
  • Synchronous communication uses REST, Feign, or gRPC where the caller blocks until it receives a response from the target service.
  • Asynchronous communication uses a broker like Kafka or RabbitMQ where the producer publishes and continues without waiting.
  • Choose synchronous when the caller genuinely needs the answer to proceed, such as a funds check before a transfer.
  • Choose asynchronous when the work can happen eventually and the caller should stay responsive, such as sending notifications.
  • gRPC uses HTTP/2 and binary protocol buffers for fast, strongly typed internal calls with streaming support.
  • A strong design uses both styles: synchronous on the critical path and asynchronous for side effects and fan-out.
  • Every synchronous call must be wrapped in a timeout, retry, and circuit breaker to avoid cascading failures.

Definition

Synchronous communication (REST, Feign, gRPC) blocks the caller until the response arrives; asynchronous communication (Kafka, RabbitMQ) lets the producer publish and move on, so you choose synchronous when the answer is needed now and asynchronous when the work can happen later.
  • Synchronous communication – the caller sends a request and blocks waiting for the reply, implemented with REST over HTTP, a declarative client like Feign, or gRPC over HTTP/2 with protocol buffers.
  • Asynchronous communication – the sender publishes a message to a broker and continues without waiting, implemented with Kafka or RabbitMQ, and consumers process messages at their own pace.
  • Coupling trade-off – synchronous calls couple the caller to the callee's availability and latency, while asynchronous messaging decouples them in time and space.
  • Decision rule – use synchronous when the caller cannot proceed without the answer, and asynchronous when the work can happen eventually.
  • Hybrid pattern – most production systems use both: synchronous on the critical request path and asynchronous for events and side effects.

At National Bank of India, synchronous communication is like Rajesh calling the teller on the phone and waiting on the line until he hears his balance, because he cannot proceed without it. Asynchronous communication is like the Payment department dropping a note in the internal mailbox saying "transfer done" and walking away immediately, so the Notification department picks up the note whenever it is ready and sends Priya an SMS. The phone call is direct but useless if the other person does not pick up; the mailbox is slower but the sender is never stuck waiting.

Understanding the Concepts

What is synchronous communication between microservices?

Synchronous communication follows the request-response model. The calling service sends a request to the target service and blocks its thread until the response comes back. The caller cannot continue its work until it receives the answer. This is the simplest model to understand and debug because you get an immediate result or an immediate error.

In the banking domain, when Rajesh initiates a fund transfer, the Payment Service must check whether account SAV2001 has sufficient balance. It calls the Account Service synchronously and waits for the answer before proceeding.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.math.BigDecimal;

@Service
public class PaymentService {

    private final RestClient restClient;

    public PaymentService(RestClient.Builder builder) {
        this.restClient = builder.baseUrl("http://account-service").build();
    }

    public boolean hasSufficientFunds(String accountId, BigDecimal amount) {
        // Synchronous call: blocks until Account Service responds
        BigDecimal balance = restClient.get()
                .uri("/api/v1/accounts/{id}/balance", accountId)
                .retrieve()
                .body(BigDecimal.class);
        return balance != null && balance.compareTo(amount) >= 0;
    }
}
DIAGRAM
// When Account Service is up:
balance = 75000.00, amount = 50000.00 → returns true

// When Account Service is down:
throws ResourceAccessException → caller must handle the failure

What is asynchronous communication between microservices?

Asynchronous communication uses a message broker. The producer publishes a message to a topic or queue and returns immediately without waiting for any consumer to process it. Consumers read messages at their own pace. This decouples the producer from the consumer in both time and availability.

After Rajesh's transfer succeeds, the Payment Service publishes a PaymentCompleted event to Kafka. The Notification Service, the Statement Service, and the Fraud Service each consume it independently.

JAVA
package com.bank.service;

import com.bank.event.PaymentCompletedEvent;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.Instant;

@Service
public class PaymentEventPublisher {

    private final KafkaTemplate<String, PaymentCompletedEvent> kafkaTemplate;

    public PaymentEventPublisher(KafkaTemplate<String, PaymentCompletedEvent> kafkaTemplate) {
        this.kafkaTemplate = kafkaTemplate;
    }

    public void publishPaymentCompleted(String paymentId, String accountId,
                                         BigDecimal amount) {
        PaymentCompletedEvent event = new PaymentCompletedEvent(
                paymentId, accountId, amount, Instant.now());
        // Fire and forget: returns immediately
        kafkaTemplate.send("payment-events", accountId, event);
    }
}
TEXT
// Producer returns instantly after send()
// Notification Service picks up the event 50ms later
// Statement Service picks up the event 120ms later
// If Fraud Service is temporarily down, the event waits in the topic

How does Feign simplify synchronous REST calls?

Feign is a declarative HTTP client. Instead of manually building HTTP requests with RestClient or WebClient, you write a Java interface and annotate it. The framework generates the implementation, wires in service discovery, and adds load balancing. This reduces boilerplate and makes synchronous calls look like local method calls.

JAVA
package com.bank.client;

import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import java.math.BigDecimal;

@FeignClient(name = "account-service")
public interface AccountClient {

    @GetMapping("/api/v1/accounts/{accountId}/balance")
    BigDecimal getBalance(@PathVariable String accountId);
}
JAVA
package com.bank.service;

import com.bank.client.AccountClient;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;

@Service
public class TransferService {

    private final AccountClient accountClient;

    public TransferService(AccountClient accountClient) {
        this.accountClient = accountClient;
    }

    public void validateFunds(String accountId, BigDecimal amount) {
        // Looks like a local call but is a synchronous HTTP call
        BigDecimal balance = accountClient.getBalance(accountId);
        if (balance.compareTo(amount) < 0) {
            throw new InsufficientFundsException(
                    "Account " + accountId + " has " + balance
                    + " but needs " + amount);
        }
    }
}
TEXT
// Feign resolves "account-service" via Eureka, picks a healthy instance,
// makes the HTTP GET, and returns the balance as a BigDecimal.

How does gRPC differ from REST for internal calls?

gRPC uses HTTP/2 and binary protocol buffers instead of HTTP/1.1 and JSON. You define the contract in a .proto file, generate strongly typed stubs, and calls are type-safe at compile time. gRPC supports unary calls, server streaming, client streaming, and bidirectional streaming. It shines for high-throughput internal service-to-service calls where latency and payload size matter.

PROTOBUF
// account_service.proto
syntax = "proto3";
package com.bank.grpc;

service AccountService {
    rpc GetBalance (BalanceRequest) returns (BalanceResponse);
}

message BalanceRequest {
    string account_id = 1;
}

message BalanceResponse {
    string account_id = 1;
    string balance = 2;  // String to preserve BigDecimal precision
}
JAVA
package com.bank.service;

import com.bank.grpc.AccountServiceGrpc;
import com.bank.grpc.BalanceRequest;
import com.bank.grpc.BalanceResponse;
import io.grpc.ManagedChannel;
import java.math.BigDecimal;

public class GrpcAccountClient {

    private final AccountServiceGrpc.AccountServiceBlockingStub stub;

    public GrpcAccountClient(ManagedChannel channel) {
        this.stub = AccountServiceGrpc.newBlockingStub(channel);
    }

    public BigDecimal getBalance(String accountId) {
        BalanceRequest request = BalanceRequest.newBuilder()
                .setAccountId(accountId)
                .build();
        BalanceResponse response = stub.getBalance(request);
        return new BigDecimal(response.getBalance());
    }
}
TEXT
// gRPC call to account-service for SAV2001:
// Request: 12 bytes (binary protobuf) vs ~45 bytes (JSON)
// Response: 18 bytes (binary) vs ~60 bytes (JSON)
// Latency: ~2ms (HTTP/2 multiplexed) vs ~8ms (HTTP/1.1 new connection)

How is synchronous different from asynchronous? (comparison table)

The table below summarizes the key differences between synchronous and asynchronous communication in a microservices architecture.

| Aspect | Synchronous (REST / gRPC) | Asynchronous (Kafka / RabbitMQ) |
|---|---|---|
| Model | Request-response, caller blocks | Publish-subscribe, producer moves on |
| Coupling | Temporal: caller depends on callee being up | Decoupled: producer does not know consumers |
| Latency | Immediate answer | Eventually consistent |
| Failure mode | Caller fails if callee is down | Messages queue up, processed when consumer recovers |
| Throughput | Limited by slowest service in the chain | Buffered by broker, consumers scale independently |
| Debugging | Simple: request in, response out | Harder: trace events across topics and consumers |
| Use case | Queries, validations, reads | Notifications, analytics, saga events, fan-out |

JAVA
package com.bank.service;

import java.math.BigDecimal;

public class TransferOrchestrator {

    private final AccountClient accountClient;       // Synchronous
    private final PaymentEventPublisher publisher;    // Asynchronous

    public TransferOrchestrator(AccountClient accountClient,
                                 PaymentEventPublisher publisher) {
        this.accountClient = accountClient;
        this.publisher = publisher;
    }

    public String executeTransfer(String fromAccount, String toAccount,
                                   BigDecimal amount) {
        // Step 1: Synchronous - must have the answer now
        BigDecimal balance = accountClient.getBalance(fromAccount);
        if (balance.compareTo(amount) < 0) {
            throw new InsufficientFundsException("Insufficient funds");
        }

        // Step 2: Synchronous - debit must succeed before responding
        accountClient.debit(fromAccount, amount);
        accountClient.credit(toAccount, amount);

        // Step 3: Asynchronous - side effects happen later
        String paymentId = "PAY-" + System.currentTimeMillis();
        publisher.publishPaymentCompleted(paymentId, fromAccount, amount);

        return paymentId;
    }
}
TEXT
// Synchronous path: check balance + debit + credit = ~25ms (critical)
// Asynchronous path: publish event = ~2ms (fire and forget)
// Notification SMS sent ~200ms later by a separate consumer

What are common mistakes when choosing between sync and async?

The most common mistake is using synchronous calls for work that does not need an immediate answer. This creates unnecessary coupling and fragility.

Wrong approach: synchronous notification call blocks the transfer.

JAVA
package com.bank.service;

import java.math.BigDecimal;

public class TransferServiceWrong {

    private final AccountClient accountClient;
    private final NotificationClient notificationClient; // Synchronous!

    public TransferServiceWrong(AccountClient accountClient,
                                 NotificationClient notificationClient) {
        this.accountClient = accountClient;
        this.notificationClient = notificationClient;
    }

    public String executeTransfer(String fromAccount, BigDecimal amount) {
        accountClient.debit(fromAccount, amount);

        // WRONG: transfer waits for SMS delivery
        // If Notification Service is down, the transfer fails!
        notificationClient.sendSms(fromAccount, "Transfer of " + amount);

        return "DONE";
    }
}

Correct approach: publish an event and let the notification service react independently.

JAVA
package com.bank.service;

import java.math.BigDecimal;

public class TransferServiceCorrect {

    private final AccountClient accountClient;
    private final PaymentEventPublisher publisher; // Asynchronous!

    public TransferServiceCorrect(AccountClient accountClient,
                                   PaymentEventPublisher publisher) {
        this.accountClient = accountClient;
        this.publisher = publisher;
    }

    public String executeTransfer(String fromAccount, BigDecimal amount) {
        accountClient.debit(fromAccount, amount);

        // CORRECT: fire and forget, Notification Service reacts on its own
        publisher.publishPaymentCompleted("PAY-123", fromAccount, amount);

        return "DONE";
        // If Notification Service is briefly down, the event waits in Kafka
    }
}
DIAGRAM
// Wrong: Notification Service down → transfer fails → customer frustrated
// Correct: Notification Service down → transfer succeeds → SMS sent later

When should you use synchronous communication?

Use synchronous calls when the caller genuinely cannot proceed without the answer. Typical scenarios in banking include checking the account balance before approving a transfer, validating KYC status before opening a new account, and fetching data to render a response to the customer. The caller needs the result right now, so blocking is the correct behavior.

Synchronous calls are also the right choice at the public edge. Browsers, mobile apps, and third-party integrations speak HTTP and JSON, so REST is the natural protocol for external-facing APIs.

JAVA
// Synchronous is correct here: cannot approve a loan without a credit score
BigDecimal creditScore = creditServiceClient.getScore("CUST-1001");
if (creditScore.compareTo(new BigDecimal("650")) < 0) {
    throw new LoanRejectedException("Credit score too low: " + creditScore);
}

When should you use asynchronous communication?

Use asynchronous messaging when the producer should not wait and the interaction is an event, meaning a statement that something happened rather than a command to one service. Common scenarios include sending notifications, updating analytics, refreshing read models, triggering downstream saga steps, and broadcasting domain events for fan-out.

Kafka is ideal when you need durability, replay, high throughput, and per-partition ordering. RabbitMQ is better for complex routing, per-message acknowledgement, and traditional task queues.

JAVA
// Asynchronous is correct here: the transfer is already done,
// these are independent side effects
kafkaTemplate.send("payment-events", accountId, paymentCompletedEvent);
// Notification consumer sends SMS
// Statement consumer updates the monthly statement
// Fraud consumer scores the transaction
// Analytics consumer updates the dashboard

What about resilience for synchronous calls?

Every synchronous call must be protected with a timeout, retry with backoff, and a circuit breaker. Without these, one slow or dead service can cascade and bring down the entire system.

JAVA
package com.bank.service;

import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.retry.annotation.Retry;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;

@Service
public class ResilientAccountClient {

    private final AccountClient accountClient;

    public ResilientAccountClient(AccountClient accountClient) {
        this.accountClient = accountClient;
    }

    @CircuitBreaker(name = "accountService", fallbackMethod = "balanceFallback")
    @Retry(name = "accountService")
    public BigDecimal getBalance(String accountId) {
        return accountClient.getBalance(accountId);
    }

    private BigDecimal balanceFallback(String accountId, Throwable t) {
        // Fail fast with a clear message instead of hanging
        throw new ServiceUnavailableException(
                "Account Service unavailable for " + accountId + ": " + t.getMessage());
    }
}
DIAGRAM
// Circuit breaker states:
// CLOSED  → normal operation, calls go through
// OPEN    → failure threshold exceeded, calls fail fast (no network call)
// HALF_OPEN → after wait duration, a few test calls are allowed through

What should a senior engineer know?

A senior engineer understands that synchronous chains couple availability. If Service A calls B, which calls C synchronously, then the availability of A is the product of all three availabilities. At 99.9% each, three hops give 99.7%, and five hops give 99.5%, which is a meaningful degradation. The rule is to keep synchronous chains short and push everything else onto events.

A senior engineer also knows that gRPC is not a replacement for REST everywhere. REST stays at the public edge for its universality and browser support. gRPC shines for hot internal paths where latency and payload size matter. Kafka is complementary to both, not competing: it handles the event backbone.

JAVA
// Anti-pattern: long synchronous chain
// Gateway → Payment → Account → Fraud → Compliance → Notification
// Combined availability: 0.999^5 = 0.995 = 4.38 hours of downtime/year

// Better: short sync path + async fan-out
// Gateway → Payment → Account (sync, critical path)
// Payment → Kafka → Fraud, Compliance, Notification (async, independent)

Visual Overview

DIAGRAM
  Synchronous (REST / Feign / gRPC)
  ─────────────────────────────────
  Client         Service A         Service B
    │                │                 │
    │── request ────▶│                 │
    │                │── request ─────▶│
    │                │   (blocks)      │
    │                │◀── response ────│
    │◀── response ───│                 │
    │                │                 │
    │  Total time = latency(A) + latency(B)


  Asynchronous (Kafka / RabbitMQ)
  ─────────────────────────────────
  Producer        Kafka Broker       Consumer A    Consumer B
    │                │                  │              │
    │── publish ────▶│                  │              │
    │◀── ack ────────│                  │              │
    │  (moves on)    │                  │              │
    │                │── poll ─────────▶│              │
    │                │── poll ──────────────────────▶  │
    │                │                  │              │
    │  Producer never waits for consumers


  Hybrid Pattern (Banking Transfer)
  ─────────────────────────────────
  Mobile App    API Gateway    Payment Svc    Account Svc    Kafka    Notification
    │               │              │              │           │           │
    │── POST ──────▶│              │              │           │           │
    │               │── route ────▶│              │           │           │
    │               │              │── balance? ─▶│ (sync)    │           │
    │               │              │◀── 75000 ───│           │           │
    │               │              │── debit ────▶│ (sync)    │           │
    │               │              │◀── ok ──────│           │           │
    │               │◀── 200 OK ──│              │           │           │
    │◀── 200 OK ───│              │              │           │           │
    │               │              │── event ────────────────▶│           │
    │               │              │              │           │── poll ──▶│
    │               │              │              │           │   (async) │

Key Takeaway

Microservices communicate in two fundamental styles, and the choice comes down to one question: does the caller need the answer right now? Synchronous communication, implemented with REST, Feign, or gRPC, blocks the caller until the response arrives. It is the right choice when the caller genuinely cannot proceed without the result, such as checking whether account SAV2001 has enough balance before approving Rajesh's transfer. Asynchronous communication, implemented with Kafka or RabbitMQ, lets the producer publish a message and move on immediately. It is the right choice for side effects and fan-out, such as notifying Priya, updating the statement, and scoring the transaction for fraud, all of which can happen independently and do not need to block the transfer.

In banking at National Bank of India, Pankaj designed the transfer flow to use both deliberately. The critical path, checking funds and debiting, runs synchronously because correctness depends on those answers. The side effects, notification, audit, and fraud scoring, run asynchronously because they are independent, and if the Notification Service is briefly down the event simply waits in the Kafka topic. Every synchronous call on the critical path is wrapped in a timeout, retry, and circuit breaker, because without those protections one slow service can cascade and bring down the whole transfer flow.

The senior-level principle is to keep synchronous chains short and push everything else onto events. Long chains of blocking calls multiply latency and couple the availability of every service in the chain. Every asynchronous hop you add is one fewer way the request can fail in real time, which is what makes the system both more resilient and more scalable.

Remember one sentence: use synchronous calls only when the caller needs the answer right now, and push everything else onto asynchronous events so the critical path stays short, fast, and resilient.

---

Share
Use ← → arrow keys to navigate