Published on

Production gRPC with Spring Boot 4.1: Contracts, Deadlines, Streaming, and Virtual Threads

Authors
  • avatar
    Name
    Maria
    Twitter

gRPC is not automatically faster than every REST API, and virtual threads do not automatically make a gRPC service scalable. The useful combination is more specific:

  • Protocol Buffers provide an explicit, generated contract;
  • HTTP/2 allows multiplexed requests and streaming;
  • gRPC defines deadlines, cancellation, metadata, and status codes;
  • Spring Boot manages server, client, testing, and observability integration;
  • virtual threads make blocking application work cheaper when that work cannot be made non-blocking.

The transport is only one part of the design. A production service also needs a compatibility policy, bounded calls, safe retries, message-size limits, authentication, load-balancing awareness, and tests that exercise the generated contract.

This guide builds an inventory lookup service with Spring Boot 4.1 and Java 25. It deliberately avoids benchmark claims that cannot be reproduced.

TL;DR Use gRPC when a generated, cross-language service contract or streaming model provides a concrete benefit. Treat field numbers as permanent identifiers and make schema evolution backward compatible. Set a deadline on every outbound call and propagate the remaining budget downstream. Retry only eligible, idempotent calls. Keep blocking database or SDK work away from transport event-loop threads; virtual threads can help there. Test the real generated client and server together with Spring gRPC's in-process transport.

Choose gRPC for a Specific Reason

gRPC is a strong fit for internal service-to-service communication when:

  • several languages must implement the same contract;
  • payload size and serialization cost are material;
  • the interaction needs server, client, or bidirectional streaming;
  • generated clients are preferable to hand-maintained HTTP DTOs;
  • the organization can operate HTTP/2 end to end.

It may be the wrong choice when:

  • a public browser API must be easily inspected and cached through ordinary HTTP infrastructure;
  • consumers need flexible, ad hoc JSON payloads;
  • the team cannot distribute and govern .proto files;
  • a queue or event log fits the interaction better than a synchronous RPC;
  • an existing REST endpoint is already comfortably inside its latency and capacity objectives.

The meaningful comparison is not “binary versus JSON.” Measure the complete request path, including database work, downstream calls, TLS, load balancing, serialization, and connection reuse.

Start with the Failure Contract

Before defining a message, decide what callers should observe.

SituationgRPC statusRetry by default?
Request violates the contractINVALID_ARGUMENTNo
Item does not existNOT_FOUNDNo
Caller lacks permissionPERMISSION_DENIEDNo
Caller identity is missing or invalidUNAUTHENTICATEDAfter re-authentication
Optimistic concurrency conflictABORTEDSometimes, with a new read
Service is temporarily unable to serveUNAVAILABLESometimes, with backoff
Time budget has expiredDEADLINE_EXCEEDEDOnly if still useful and safe
Unexpected implementation failureINTERNALUsually no automatic retry

Do not return INTERNAL for every domain error. Callers then cannot distinguish a bad request from an outage, and broad retries can amplify failures.

Project Setup

Spring Boot 4.1 has first-party gRPC support. Let the Spring Boot parent manage compatible gRPC and Protocol Buffer versions.

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.1.0</version>
    <relativePath />
</parent>

<properties>
    <java.version>25</java.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.grpc</groupId>
        <artifactId>spring-grpc-spring-boot-starter</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.grpc</groupId>
        <artifactId>spring-grpc-test</artifactId>
        <scope>test</scope>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>io.github.ascopes</groupId>
            <artifactId>protobuf-maven-plugin</artifactId>
        </plugin>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
    </plugins>
</build>

Place service definitions under src/main/proto. The generated Java sources are build output and should not normally be edited by hand.

Design a Contract That Can Evolve

syntax = "proto3";

package inventory.v1;

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

import "google/protobuf/timestamp.proto";

service InventoryService {
  rpc GetAvailability(GetAvailabilityRequest)
      returns (GetAvailabilityResponse);

  rpc WatchAvailability(WatchAvailabilityRequest)
      returns (stream AvailabilityChanged);
}

message GetAvailabilityRequest {
  string product_id = 1;
  string location_id = 2;
}

message GetAvailabilityResponse {
  string product_id = 1;
  string location_id = 2;
  int64 available_quantity = 3;
  int64 version = 4;
  google.protobuf.Timestamp observed_at = 5;
}

message WatchAvailabilityRequest {
  repeated string product_ids = 1;
  string location_id = 2;
}

message AvailabilityChanged {
  string product_id = 1;
  string location_id = 2;
  int64 available_quantity = 3;
  int64 version = 4;
  google.protobuf.Timestamp changed_at = 5;
}

The package includes v1 because a package-level version creates a clear boundary for a genuinely incompatible redesign. It should not be used as an excuse to break the contract for every small change.

Compatibility rules that matter

  1. Never reuse a field number, even after removing the field.
  2. Reserve removed numbers and names.
  3. Add fields with safe defaults instead of changing the meaning of existing fields.
  4. Do not change a field between singular and repeated.
  5. Treat enum zero as an explicit unspecified value.
  6. Preserve unknown fields through intermediaries that decode and re-encode messages.
  7. Run a schema compatibility check in CI.

If field 6 is removed:

message GetAvailabilityResponse {
  reserved 6;
  reserved "warehouse_note";

  string product_id = 1;
  string location_id = 2;
  int64 available_quantity = 3;
  int64 version = 4;
  google.protobuf.Timestamp observed_at = 5;
}

Changing the field name while keeping its number can still break JSON-based tooling, documentation, or generated consumers. Compatibility is wider than the wire encoding.

Implement a Unary Service

Spring gRPC registers BindableService beans. The generated base class already implements that interface.

package com.example.inventory.grpc;

import com.example.inventory.grpc.v1.GetAvailabilityRequest;
import com.example.inventory.grpc.v1.GetAvailabilityResponse;
import com.example.inventory.grpc.v1.InventoryServiceGrpc;
import com.google.protobuf.Timestamp;
import io.grpc.Status;
import io.grpc.stub.StreamObserver;
import org.springframework.stereotype.Service;

import java.time.Instant;

@Service
public class InventoryGrpcService
        extends InventoryServiceGrpc.InventoryServiceImplBase {

    private final AvailabilityQuery query;

    public InventoryGrpcService(AvailabilityQuery query) {
        this.query = query;
    }

    @Override
    public void getAvailability(
            GetAvailabilityRequest request,
            StreamObserver<GetAvailabilityResponse> responseObserver) {

        if (request.getProductId().isBlank()
                || request.getLocationId().isBlank()) {
            responseObserver.onError(
                    Status.INVALID_ARGUMENT
                            .withDescription(
                                    "product_id and location_id are required")
                            .asRuntimeException());
            return;
        }

        try {
            Availability availability = query.find(
                    request.getProductId(),
                    request.getLocationId());

            responseObserver.onNext(toResponse(availability));
            responseObserver.onCompleted();
        }
        catch (AvailabilityNotFoundException exception) {
            responseObserver.onError(
                    Status.NOT_FOUND
                            .withDescription("availability was not found")
                            .asRuntimeException());
        }
    }

    private GetAvailabilityResponse toResponse(
            Availability availability) {
        Instant observedAt = availability.observedAt();

        return GetAvailabilityResponse.newBuilder()
                .setProductId(availability.productId())
                .setLocationId(availability.locationId())
                .setAvailableQuantity(availability.quantity())
                .setVersion(availability.version())
                .setObservedAt(
                        Timestamp.newBuilder()
                                .setSeconds(observedAt.getEpochSecond())
                                .setNanos(observedAt.getNano())
                                .build())
                .build();
    }
}

Avoid including stack traces, SQL fragments, hostnames, or secrets in status descriptions. Detailed diagnostic information belongs in server logs and traces correlated to the request.

Deadlines Are Part of Correctness

A call without a deadline can remain useful to no one while still holding memory, a database connection, and downstream capacity.

Configure a named channel:

spring:
  grpc:
    client:
      channels:
        inventory:
          address: dns:///inventory.default.svc.cluster.local:9090
          health:
            enabled: true

Create a stub:

@Bean
InventoryServiceGrpc.InventoryServiceBlockingStub inventoryStub(
        GrpcChannelFactory channels) {
    return InventoryServiceGrpc.newBlockingStub(
            channels.createChannel("inventory"));
}

Set a deadline for each logical call:

GetAvailabilityResponse response = inventoryStub
        .withDeadlineAfter(800, TimeUnit.MILLISECONDS)
        .getAvailability(request);

The 800 ms value is an example, not a universal recommendation. Derive it from the caller's end-to-end latency objective and measured tail latency.

If service A receives a request with 700 ms remaining, it must not give service B a new independent five-second timeout. Propagate a smaller remaining budget so work is cancelled after it can no longer contribute to the response.

Cancellation should also reach application work. Check the gRPC context before starting another expensive step, and ensure blocking clients support interruption or their own timeout.

Retry Only Safe Calls

Retries are appropriate only when all of these are true:

  • the failure is transient;
  • the operation is idempotent or protected by an idempotency key;
  • the deadline leaves enough time;
  • attempts are bounded;
  • backoff includes jitter;
  • the retry policy does not exist at several layers simultaneously.

A lookup is usually retryable. A command such as ReserveInventory is not automatically safe. If the client times out after the server committed the reservation, an unprotected retry can reserve twice.

For commands, include a stable command identifier:

message ReserveInventoryRequest {
  string command_id = 1;
  string order_id = 2;
  string product_id = 3;
  int64 quantity = 4;
}

The server must persist the identifier with the result in the same transaction as the business change. Returning the previous result is safer than merely checking an in-memory cache.

Virtual Threads: Where They Help

gRPC Java's Netty transport uses event-loop threads. Blocking an event-loop thread is harmful because it delays many connections. Virtual threads are useful when the service handler must call blocking code such as JDBC or a synchronous SDK, but only if that work is dispatched to an executor that does not run on the event loop.

Configure an executor on the server builder:

@Bean(destroyMethod = "close")
ExecutorService grpcApplicationExecutor() {
    return Executors.newVirtualThreadPerTaskExecutor();
}

@Bean
ServerBuilderCustomizer virtualThreadExecutor(
        ExecutorService grpcApplicationExecutor) {
    return builder -> builder.executor(grpcApplicationExecutor);
}

Verify the exact server implementation and customizer behavior used by the deployed Spring gRPC version. A servlet-based gRPC server delegates network threading to the servlet container and has different configuration boundaries.

Virtual threads do not:

  • make CPU-bound work faster;
  • remove database connection-pool limits;
  • make a downstream API faster;
  • add concurrency to sequential code;
  • eliminate deadlines or backpressure;
  • make thread-local context propagation automatic.

If 10,000 virtual threads wait for a JDBC pool containing 40 connections, the database still sees at most 40 active connections while the application queues thousands of tasks. Capacity limits remain necessary.

Implement Server Streaming Carefully

Server streaming is not an instruction to buffer an unbounded result in memory.

@Override
public void watchAvailability(
        WatchAvailabilityRequest request,
        StreamObserver<AvailabilityChanged> responseObserver) {

    ServerCallStreamObserver<AvailabilityChanged> serverObserver =
            (ServerCallStreamObserver<AvailabilityChanged>) responseObserver;

    AtomicBoolean cancelled = new AtomicBoolean();
    serverObserver.setOnCancelHandler(() -> cancelled.set(true));

    AutoCloseable subscription = changes.subscribe(
            request.getLocationId(),
            Set.copyOf(request.getProductIdsList()),
            change -> {
                if (!cancelled.get()) {
                    serverObserver.onNext(toChangedMessage(change));
                }
            },
            error -> {
                if (!cancelled.get()) {
                    serverObserver.onError(
                            Status.UNAVAILABLE
                                    .withDescription(
                                            "availability stream interrupted")
                                    .asRuntimeException());
                }
            });

    serverObserver.setOnCancelHandler(() -> {
        cancelled.set(true);
        closeQuietly(subscription);
    });
}

This simplified example demonstrates cancellation cleanup, but a busy production stream also needs explicit flow-control handling. A slow client must not cause an unlimited server-side queue. Use readiness callbacks or a bounded application buffer, define an overflow policy, and measure queue depth.

For replayable business events, Kafka or another durable log may be better than a gRPC stream. gRPC streaming transports live responses; it does not become durable merely because the connection remains open.

Authentication, Authorization, and Metadata

Transport security and caller identity are separate concerns.

  • use TLS for traffic that crosses an untrusted network;
  • use mutual TLS when workload identity is required;
  • validate bearer tokens in a server interceptor when OAuth access tokens are used;
  • authorize the requested resource inside the application;
  • avoid logging authorization metadata;
  • propagate only the identity and tracing fields a downstream service needs.

Do not trust a caller-supplied user-id header just because it arrived through gRPC metadata. Identity must be bound to a verified credential.

Observability

Adding Spring Boot Actuator allows Spring gRPC to configure observability interceptors. Record:

  • method name;
  • status code;
  • server and client duration;
  • request and response message size;
  • deadline-exceeded and cancelled call counts;
  • active streams;
  • retries and retry outcomes;
  • downstream latency;
  • saturation of application executors and database pools.

Never use customer identifiers, product IDs, tokens, or arbitrary error descriptions as metric labels. Those create high-cardinality series and can expose sensitive data.

Traces should show the remaining deadline and downstream call structure without recording message bodies by default.

Test the Generated Contract In Process

An in-process test exercises serialization, generated stubs, interceptors, and service registration without binding a network port.

@SpringBootTest(properties = {
        "spring.grpc.test.inprocess.enabled=true"
})
@AutoConfigureInProcessTransport
class InventoryGrpcServiceTest {

    @Autowired
    private GrpcChannelFactory channels;

    @Test
    void returnsAvailability() {
        var stub = InventoryServiceGrpc.newBlockingStub(
                channels.createChannel("test"));

        var response = stub.getAvailability(
                GetAvailabilityRequest.newBuilder()
                        .setProductId("product-42")
                        .setLocationId("seoul-1")
                        .build());

        assertThat(response.getProductId())
                .isEqualTo("product-42");
        assertThat(response.getAvailableQuantity())
                .isGreaterThanOrEqualTo(0);
    }

    @Test
    void rejectsMissingProductId() {
        var stub = InventoryServiceGrpc.newBlockingStub(
                channels.createChannel("test"));

        StatusRuntimeException exception = catchThrowableOfType(
                () -> stub.getAvailability(
                        GetAvailabilityRequest.newBuilder()
                                .setLocationId("seoul-1")
                                .build()),
                StatusRuntimeException.class);

        assertThat(exception.getStatus().getCode())
                .isEqualTo(Status.Code.INVALID_ARGUMENT);
    }
}

Also add:

  • compatibility tests against the last released .proto;
  • deadline and cancellation tests;
  • authorization tests;
  • message-size boundary tests;
  • slow-client streaming tests;
  • real-network tests through the same proxy or ingress used in production.

An in-process test cannot reveal HTTP/2 proxy misconfiguration, TLS problems, DNS behavior, or connection balancing.

Common Production Failures

Calls hang during a partial outage

Check that every client call has a deadline and that downstream operations use a smaller remaining budget. Inspect connection establishment and DNS timeouts as well as RPC deadlines.

One instance receives most long-lived streams

HTTP/2 carries many calls over one connection. Confirm how the client resolves addresses, creates channels, and applies load balancing. A connection-oriented load balancer may not redistribute established streams.

CPU rises after enabling compression

Compression trades CPU for network bytes. Measure it with representative messages. Small messages can cost more to compress than they save.

Memory grows with slow streaming clients

Inspect application buffers and whether the sender respects readiness. Bound queues and choose an explicit overflow or disconnect policy.

Virtual threads are enabled but latency is unchanged

The bottleneck may be the database, CPU, downstream latency, connection pools, or sequential code. Virtual threads reduce the cost of waiting; they do not reduce the wait itself.

Deployment Checklist

  • .proto compatibility is checked in CI.
  • Every outbound call has a deadline.
  • Retry rules identify eligible codes and idempotent methods.
  • commands use durable idempotency where duplicate execution matters.
  • TLS and caller authentication are configured.
  • authorization is tested at the resource level.
  • inbound and outbound message sizes are bounded.
  • streaming buffers and cancellation cleanup are bounded and tested.
  • health checking reflects whether the service can actually serve requests.
  • metrics, logs, and traces avoid sensitive or high-cardinality fields.
  • a real-network test crosses the production proxy path.
  • benchmarks document hardware, payload, concurrency, warm-up, and failure conditions.

Conclusion

The strongest reason to adopt gRPC is a disciplined service contract with well-defined call semantics, not a generic promise of speed. Spring Boot 4.1 reduces the integration work, but production behavior still depends on deadlines, status mapping, compatibility, idempotency, flow control, and observability.

Java virtual threads are a useful bridge when a gRPC handler must perform blocking application work. They should be applied at the blocking boundary and tested with the actual transport, executor, and resource pools. With those constraints made explicit, gRPC can provide a compact, type-safe internal API without hiding failure behind generated code.


Official References