00:00

Synchronous Communication in Microservices

In a microservices architecture, an application is split into small, independent and loosely coupled services. To work together as one application, they must communicate, and they do so in two main ways: synchronously and asynchronously. This guide takes a deep look at synchronous communication.

You will learn how it works, walk through a complete order-processing example, and implement it in Spring Boot with RestClient, clear error responses, timeouts, parallel calls, gRPC, resilience patterns and tests. For a broader overview of both styles, see Communication in Microservices.

1. What Is Synchronous Communication?

Synchronous communication is a direct, real-time interaction between two services. The client service (the caller) sends a request and then waits for a response from the server service (the one doing the work) before it continues.

Think of it like a phone call. You dial, the other person answers, you talk (request and response), and you do not hang up until you have your answer. While the call lasts, your line is busy and you are actively waiting.

Request and response
Order Service                         Inventory Service
     |---- POST /api/inventory/reservations --->|
     |           (waiting...)                   |  checks stock
     |<--------- 200 OK { reserved: true } -----|
     |
  continues with the next step

2. Key Characteristics

CharacteristicWhat it means
Request–response cycleEvery request is answered with a response, success or error
BlockingThe caller waits, usually holding a thread, until it gets a response or a timeout
Immediate feedbackThe caller knows right away whether the request succeeded or failed
Temporal couplingBoth services must be running and reachable at the same moment

3. How It Works: HTTP and REST

The most common protocol for synchronous communication is HTTP, usually with a REST API design. The client sends a request with a method (GET, POST, PUT, PATCH, DELETE) to an endpoint URL. The server processes it and returns a response with a status code and, usually, a JSON body.

Status codeMeaningTypical use between services
200 OK / 201 CreatedSuccessData returned, or a resource created
400 Bad RequestThe request itself is invalidMissing or malformed fields
404 Not FoundThe resource does not existUnknown product or order ID
409 ConflictThe request conflicts with the current stateOut of stock, already paid, duplicate request
422 Unprocessable ContentValid format, but a business rule rejects itPayment declined, invalid quantity
503 Service UnavailableThe server is temporarily unable to handle the requestOverload, maintenance; safe to retry later
504 Gateway TimeoutA proxy did not get an answer in timeA downstream service was too slow

Using the right status code matters: it tells the caller whether to show an error, try something else, or retry. Codes in the 4xx range mean “do not retry the same request”; 503 and 504 usually mean “try again later”.

4. A Detailed Example: E-Commerce Order Processing

Consider an e-commerce application with three microservices:

  • Order service: creates and manages orders.
  • Inventory service: manages product stock levels.
  • Payment service: processes customer payments.

4.1 Step 1: the initial request

The user clicks Place Order. The front end sends a request to the order service to create a new order.

4.2 Step 2: reserving stock (synchronous call 1)

Before creating the order, the order service must make sure the items are in stock. It sends POST /api/inventory/reservations with the product IDs and quantities, and waits.

  • Success: 200 OK with a reservation ID.
  • Failure: 409 Conflict with the message “Product X is out of stock”. The order service immediately tells the user that the order cannot be placed.

4.3 Step 3: processing payment (synchronous call 2)

With stock reserved, the order service sends POST /api/payments with a card token and the amount, and again waits. The payment service talks to the payment provider and replies.

  • Success: 200 OK with a transaction ID.
  • Failure: 422 Unprocessable Content with a reason, such as “card declined”. The order service releases the reserved stock and asks the user to try another payment method.

4.4 Step 4: finalizing the order

Only after both calls succeed does the order service save the order as CONFIRMED in its own database and return a success response to the front end.

Order flow
Browser -> Order Service
             |-- 1. reserve stock --> Inventory Service  (wait)
             |<--------- 200 OK ------
             |-- 2. charge card ----> Payment Service    (wait)
             |<--------- 200 OK ------
             |-- 3. save order (CONFIRMED)
Browser <- 201 Created
Note: Some APIs use 402 Payment Required for payment problems. That code is officially reserved for future use, so a clear 422 or 409 with an explanatory message is more common between microservices.

5. Choosing an HTTP Client in Spring

ClientStyleWhen to use it
RestClientSynchronous, fluent API (Spring Framework 6.1+)The default choice for new blocking code
HTTP interfaces (@HttpExchange)Declarative: describe the API as a Java interfaceMany endpoints; clean, testable client code
WebClientReactive, non-blockingSpring WebFlux applications, or streaming
OpenFeignDeclarative (Spring Cloud)Existing projects already using it
RestTemplateOlder synchronous APILegacy code only; prefer RestClient for new work

This guide uses RestClient. HTTP interfaces can be built on top of it when you prefer a declarative style.

6. Implementing the Order Flow with RestClient

6.1 The inventory client

The client turns HTTP details into simple Java methods and translates error codes into meaningful exceptions:

InventoryClient.java
package com.example.order.client;

import java.util.List;

import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

@Component
public class InventoryClient {

    private final RestClient restClient;

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

    public String reserve(String orderId, List<OrderLine> lines) {
        ReservationResponse response = restClient.post()
                .uri("/api/inventory/reservations")
                .body(new ReservationRequest(orderId, lines))
                .retrieve()
                // 409 means "out of stock": a business outcome, not a technical error
                .onStatus(status -> status.value() == 409, (request, resp) -> {
                    throw new OutOfStockException("Some items in order " + orderId + " are out of stock");
                })
                .body(ReservationResponse.class);

        return response.reservationId();
    }

    public void release(String reservationId) {
        restClient.delete()
                .uri("/api/inventory/reservations/{id}", reservationId)
                .retrieve()
                .toBodilessEntity();
    }

    public record OrderLine(String productId, int quantity) {
    }

    record ReservationRequest(String orderId, List<OrderLine> lines) {
    }

    record ReservationResponse(String reservationId) {
    }
}

The payment client follows the same shape, translating 422 into a PaymentDeclinedException. Other 4xx and 5xx responses become Spring’s HttpClientErrorException and HttpServerErrorException automatically.

6.2 The order service

OrderService.java
package com.example.order;

import org.springframework.stereotype.Service;

import com.example.order.client.InventoryClient;
import com.example.order.client.PaymentClient;
import com.example.order.client.PaymentDeclinedException;

@Service
public class OrderService {

    private final InventoryClient inventoryClient;
    private final PaymentClient paymentClient;
    private final OrderRepository orders;

    public OrderService(InventoryClient inventoryClient, PaymentClient paymentClient,
                        OrderRepository orders) {
        this.inventoryClient = inventoryClient;
        this.paymentClient = paymentClient;
        this.orders = orders;
    }

    public Order placeOrder(PlaceOrderRequest request) {
        Order order = orders.save(Order.pending(request));

        // Synchronous call 1: throws OutOfStockException if stock is missing
        String reservationId = inventoryClient.reserve(order.getId(), request.lines());

        try {
            // Synchronous call 2
            String transactionId = paymentClient.charge(order.getId(), request.cardToken(), order.getTotal());
            order.confirm(transactionId);
        } catch (PaymentDeclinedException ex) {
            // Undo step 1 so the stock can be sold to someone else
            inventoryClient.release(reservationId);
            order.reject(ex.getMessage());
            orders.save(order);
            throw ex;
        }

        return orders.save(order);
    }
}

Notice the manual “undo” when payment fails. With only two steps this is manageable, but longer chains of synchronous calls that each need undoing are a sign that the Saga pattern with events is a better fit.

7. Returning Clear Errors with ProblemDetail

The server side should return errors that clients can understand. Spring supports the standard Problem Details format (RFC 9457) through the ProblemDetail class:

ReservationController.java (inventory-service)
package com.example.inventory;

import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ReservationController {

    private final StockService stock;

    public ReservationController(StockService stock) {
        this.stock = stock;
    }

    @PostMapping("/api/inventory/reservations")
    public ResponseEntity<?> reserve(@RequestBody ReservationRequest request) {
        return stock.tryReserve(request)
                .<ResponseEntity<?>>map(reservationId -> ResponseEntity.ok(new ReservationResponse(reservationId)))
                .orElseGet(() -> {
                    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                            HttpStatus.CONFLICT, "One or more items are out of stock");
                    problem.setTitle("Out of stock");
                    return ResponseEntity.of(problem).build();
                });
    }
}
409 response body
{
  "type": "about:blank",
  "title": "Out of stock",
  "status": 409,
  "detail": "One or more items are out of stock",
  "instance": "/api/inventory/reservations"
}

A consistent error format across all services makes client code simpler and logs easier to read. Never return stack traces or internal details to other services.

8. Timeouts: The Most Important Setting

Without timeouts, a synchronous call to a hung service can wait for minutes, holding a thread the whole time. Set timeouts once for every client with a RestClientCustomizer:

HttpClientConfig.java
package com.example.order.config;

import java.time.Duration;

import org.springframework.boot.web.client.RestClientCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;

@Configuration
public class HttpClientConfig {

    @Bean
    public RestClientCustomizer timeouts() {
        return builder -> {
            SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
            factory.setConnectTimeout(Duration.ofSeconds(1));   // time to open a connection
            factory.setReadTimeout(Duration.ofSeconds(3));      // time to wait for the response
            builder.requestFactory(factory);
        };
    }
}

Every RestClient.Builder that Spring injects now has these timeouts. Choose values from real response times: slightly above the slowest normal response, and well below what users will accept. See the Timeout pattern for more.

9. Blocking Threads and Virtual Threads

In a classic Spring MVC application, each incoming request uses one thread from a pool, often 200 threads in Tomcat. During a synchronous call, that thread just waits. This limits throughput when downstream services are slow:

Why slow dependencies hurt
200 threads, each request waits on a downstream call

Downstream answers in 0.1 s  ->  up to ~2,000 requests/second
Downstream answers in 10 s   ->  up to ~20 requests/second
                                 (all threads busy waiting)

With Java 21 and Spring Boot 3.2 or later, you can use virtual threads: very lightweight threads that the JVM can create in large numbers. Waiting no longer ties up a scarce platform thread:

application.properties
# Handle each request on a virtual thread (Java 21+)
spring.threads.virtual.enabled=true

Virtual threads make blocking code scale much better, but they do not make a slow service faster, and they do not protect the downstream service from too many concurrent calls. You still need timeouts, and limits such as the Bulkhead pattern.

10. Making Independent Calls in Parallel

When calls do not depend on each other, run them at the same time. The total time becomes the time of the slowest call instead of the sum of all calls:

ProductPageService.java
@Service
public class ProductPageService {

    private final CatalogClient catalogClient;
    private final ReviewClient reviewClient;
    private final InventoryClient inventoryClient;
    private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

    public ProductPageService(CatalogClient catalogClient, ReviewClient reviewClient,
                              InventoryClient inventoryClient) {
        this.catalogClient = catalogClient;
        this.reviewClient = reviewClient;
        this.inventoryClient = inventoryClient;
    }

    public ProductPage load(String productId) {
        var product = CompletableFuture.supplyAsync(() -> catalogClient.get(productId), executor);
        var reviews = CompletableFuture.supplyAsync(() -> reviewClient.top(productId), executor);
        var stock = CompletableFuture.supplyAsync(() -> inventoryClient.stockLevel(productId), executor);

        // About max(t1, t2, t3) instead of t1 + t2 + t3
        return new ProductPage(product.join(), reviews.join(), stock.join());
    }
}

Add a timeout and fallback to each future for optional data, such as reviews, so one slow service does not delay the whole page. The Aggregator pattern covers this in detail.

11. Synchronous Calls with gRPC

gRPC is a high-performance alternative to REST. It uses HTTP/2 and a compact binary format, Protocol Buffers. You describe the service in a .proto file, and a build plugin generates Java client and server classes:

inventory.proto
syntax = "proto3";

option java_package = "com.example.inventory.grpc";
option java_multiple_files = true;

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

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

message StockReply {
  bool available = 1;
  int32 in_stock = 2;
}
GrpcInventoryClient.java
package com.example.order.client;

import java.util.concurrent.TimeUnit;

import com.example.inventory.grpc.InventoryServiceGrpc;
import com.example.inventory.grpc.StockReply;
import com.example.inventory.grpc.StockRequest;

import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;

public class GrpcInventoryClient {

    private final InventoryServiceGrpc.InventoryServiceBlockingStub stub;

    public GrpcInventoryClient(String host, int port) {
        ManagedChannel channel = ManagedChannelBuilder.forAddress(host, port)
                .usePlaintext()            // use TLS in production
                .build();
        this.stub = InventoryServiceGrpc.newBlockingStub(channel);
    }

    public boolean isAvailable(String productId, int quantity) {
        StockReply reply = stub
                .withDeadlineAfter(2, TimeUnit.SECONDS)   // gRPC's timeout
                .checkStock(StockRequest.newBuilder()
                        .setProductId(productId)
                        .setQuantity(quantity)
                        .build());
        return reply.getAvailable();
    }
}
REST + JSONgRPC
FormatText (JSON), easy to readBinary (Protocol Buffers), compact
ContractOptional (OpenAPI)Required (.proto), code generated
PerformanceGoodFaster, smaller messages
StreamingLimitedBuilt in, in both directions
Browser supportNativeNeeds a proxy (gRPC-Web)
Best forPublic APIs and most internal callsHigh-volume internal calls between services

12. Retries, Circuit Breakers and Fallbacks

Because synchronous calls depend on the other service being available, protect them with resilience patterns:

PaymentClient.java (resilient)
@Retry(name = "payment")
@CircuitBreaker(name = "payment", fallbackMethod = "paymentUnavailable")
public String charge(String orderId, String cardToken, BigDecimal amount) {
    return restClient.post()
            .uri("/api/payments")
            .header("Idempotency-Key", orderId)   // safe to retry: never charges twice
            .body(new ChargeRequest(orderId, cardToken, amount))
            .retrieve()
            .body(ChargeResponse.class)
            .transactionId();
}

private String paymentUnavailable(String orderId, String cardToken, BigDecimal amount, Exception ex) {
    throw new ServiceTemporarilyUnavailableException("Payments are temporarily unavailable. Please try again.");
}
PatternProtects against
TimeoutWaiting forever for a slow service
RetryShort, temporary failures (only for idempotent requests)
Circuit breakerRepeatedly calling a service that is down
BulkheadOne slow dependency using up all threads
FallbackShowing users a raw error when a call fails

13. Testing Synchronous Clients

Spring Boot’s @RestClientTest starts only what a client needs, plus a mock server that answers instead of the real service:

InventoryClientTest.java
package com.example.order.client;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;

import java.util.List;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.client.RestClientTest;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;

@RestClientTest(InventoryClient.class)
class InventoryClientTest {

    private static final String URL = "http://inventory-service/api/inventory/reservations";

    @Autowired
    private InventoryClient client;

    @Autowired
    private MockRestServiceServer server;

    @Test
    void returnsReservationIdWhenStockIsAvailable() {
        server.expect(requestTo(URL))
              .andExpect(method(HttpMethod.POST))
              .andRespond(withSuccess("{\"reservationId\":\"R-77\"}", MediaType.APPLICATION_JSON));

        String id = client.reserve("1001", List.of(new InventoryClient.OrderLine("P-11", 2)));

        assertThat(id).isEqualTo("R-77");
    }

    @Test
    void throwsOutOfStockOn409() {
        server.expect(requestTo(URL))
              .andRespond(withStatus(HttpStatus.CONFLICT));

        assertThatThrownBy(() -> client.reserve("1001", List.of(new InventoryClient.OrderLine("P-11", 99))))
                .isInstanceOf(OutOfStockException.class);
    }
}

For full integration tests, tools such as WireMock can simulate slow responses and failures, and contract tests, for example with Spring Cloud Contract or Pact, check that client and server still agree on the API. The test annotation packages moved in Spring Boot 4; the code itself stays the same.

14. Advantages and Disadvantages

AdvantagesDisadvantages
Simplicity: easy to understand, like calling a functionAvailability dependency: if the inventory service is down, no orders can be placed, even though the order service is healthy
Immediate error handling: the caller knows at once if something failedCascading delays: a chain is only as fast and reliable as its weakest link
Easy to reason about: steps happen in a clear orderTemporal coupling: both services must be available at the same time
Great tooling: HTTP is understood everywhere and easy to debugResource pressure: waiting threads can be exhausted under load

15. When to Use Synchronous vs Asynchronous Communication

SituationPrefer
A user is waiting for this exact answerSynchronous
The caller needs data to decide its next stepSynchronous
Several services must react to something that happenedAsynchronous (events)
The work is slow, such as reports or emailsAsynchronous
The receiving service may be offline for a whileAsynchronous
Traffic comes in big spikes that need smoothingAsynchronous (a queue buffers it)

A well-designed system uses both: synchronous calls for what the user waits for, and events for everything that can happen afterwards. See Asynchronous Communication in Microservices.

16. Best Practices

  1. Set connect and read timeouts on every client, in one central place.
  2. Keep call chains short: ideally no more than two or three synchronous hops per user request.
  3. Call independent services in parallel.
  4. Use meaningful status codes and a consistent error format such as Problem Details.
  5. Translate errors at the client boundary into clear exceptions, such as OutOfStockException.
  6. Retry only idempotent requests, or use idempotency keys.
  7. Add circuit breakers and fallbacks for important dependencies.
  8. Use service discovery instead of fixed addresses.
  9. Pass trace IDs along with every call, so a request can be followed across services.
  10. Version APIs and keep changes backward compatible.

17. Common Mistakes to Avoid

MistakeWhat goes wrongBetter approach
No timeoutsA hung service blocks threads until the caller also failsSet connect and read timeouts
Long synchronous chainsLatency adds up and any failure breaks the whole requestUse events for follow-up steps
Wrong status codes, such as 200 with an error bodyCallers cannot react correctlyUse proper 4xx/5xx codes
Retrying non-idempotent POST requestsDuplicate orders or paymentsUse idempotency keys
Calling services one after another when they are independentSlow pagesCall them in parallel
Exposing internal errors and stack tracesLeaks details and confuses callersReturn Problem Details with a clear message
Sharing request/response classes between servicesServices must be released togetherEach client defines its own small records

18. Summary

Synchronous communication, usually HTTP/REST or gRPC, is the natural choice when a caller needs an immediate answer, especially for user-facing operations. It is simple and gives instant feedback, but its blocking nature and temporal coupling make it fragile if used carelessly. In Spring Boot, build clients with RestClient, translate error codes into clear exceptions, return Problem Details from services, set timeouts everywhere, use virtual threads and parallel calls for better throughput, and protect important calls with retries, circuit breakers and fallbacks. Keep synchronous chains short, and move follow-up work to asynchronous events. A thoughtful mix of both styles balances responsiveness, resilience and simplicity. Continue with Asynchronous Communication to complete the picture.