Spring gRPC with Spring Boot 4: A First-Class Starter, and the Failures Nobody Warns You About
Spring Boot 4 promotes gRPC to a first-class starter. A beginner-to-advanced guide: all four call types, then the failures whose error messages point somewhere other than their cause — the 4 MB limit and which side enforces it, the absent default deadline, cancellation that never interrupts your thread, UNKNOWN statuses, and the in-process test transport that cannot enforce message size limits at all.
gRPC arrived in the Spring ecosystem the long way round. For years it meant picking a community starter, wiring a BindableService by hand, and arguing about which of three competing projects to depend on. With Spring Boot 4 that ends: gRPC is a first-class starter, maintained by the Boot team and version-managed by the Boot BOM, alongside spring-boot-starter-web and the rest.
The getting-started half of this is genuinely short — a @Service and a .proto file. So rather than spend a post on that, most of what follows is the other half: the failures whose error messages point somewhere other than their cause. A limit that fails only on large payloads. A call that hangs forever because gRPC has no default timeout. A server that keeps working for a client that left ten minutes ago. And a test transport that will cheerfully pass a message your production cluster rejects.
Everything below was run against a live server on Spring Boot 4.1; the companion repository reproduces each failure and fixes it.
Versions (July 2026): Verified against Spring Boot 4.1.0, spring-grpc 1.1.0, grpc-java 1.80.0, protobuf-java 4.34.2 and JDK 25.0.3. The starters are spring-boot-starter-grpc-server and spring-boot-starter-grpc-client, with -server-test and -client-test companions.
“Spring gRPC” now means two things — know which you are versioning
Before any code, a distinction that will save you an afternoon of dependency archaeology. There are two separate projects on your classpath, with separate version numbers, and almost every search result you find conflates them.
auto-configuration, every spring.grpc.* property, the starters, health and observation wiring
the programming model: @GrpcService, @GrpcAdvice, @GrpcExceptionHandler, GrpcChannelFactory, @ImportGrpcClients
So when you look up a property name, you want the Boot 4.1 documentation. When you look up an annotation, you want Spring gRPC 1.1. Boot's BOM manages both, plus grpc-java (1.80.0 here) and protobuf-java (4.34.2) — both deliberately older than the newest releases on Maven Central, and overriding them independently is how you get a NoSuchMethodError between grpc-java and its shaded Netty.
The artifact most tutorials still show. Before Boot 4, the community project shipped its own starter, org.springframework.grpc:spring-grpc-spring-boot-starter. That artifact stops at 1.0.3 and is not what Boot 4 uses. If you are on Boot 4 and following a guide that tells you to add it, you are on the old path: the programming model is largely the same, but the dependency coordinates and several property names are not. Use the Boot starters and let the BOM manage the versions.
Both version properties come from the Boot BOM. A service is then just a bean:
@Service
public class OrderServiceImpl extends OrderServiceGrpc.OrderServiceImplBase {
@Override
public void getOrder(GetOrderRequest request, StreamObserver<Order> responseObserver) {
responseObserver.onNext(lookup(request.getId()));
responseObserver.onCompleted();
}
}
There is no registration code. The generated ImplBase is a BindableService, and Boot registers every such bean with the server automatically.
A @GrpcService annotation does exist in org.springframework.grpc.server.service, but it is optional and it is not for registration — it exists to attach per-service interceptors:
@GrpcService(interceptors = AuditInterceptor.class, blendWithGlobalInterceptors = true)
public class OrderServiceImpl extends OrderServiceGrpc.OrderServiceImplBase { … }
Plain @Service is enough if you only need global interceptors.
The four call types, and why they are not interchangeable
Most introductions show unary and stop. The three streaming forms have genuinely different failure modes, and experience with one does not transfer.
Two things bite people immediately. Client streaming requires a half-close — requestObserver.onCompleted() is what tells the server there are no more requests, and without it the server's onCompleted never fires and the call hangs. And StreamObserver is not thread-safe: two threads calling onNext on one observer corrupts the stream, and the symptom is usually a deserialization error on the far side, pointing nowhere near the bug.
gRPC has no default deadline
This is the one that takes down services.
A gRPC call with no deadline waits forever. Not sixty seconds — forever. Nothing warns you, and during development the server always responds quickly, so it never surfaces until the day something downstream is slow. Then a thread pool fills up and the service stops responding with nothing in the logs to explain it.
// Measured: DEADLINE_EXCEEDED after 300 ms against a server that sleeps 3 s
blockingStub.withDeadlineAfter(300, TimeUnit.MILLISECONDS)
.slowCall(request);
Deadlines are absolute, and they propagate. This is the property that makes them better than timeouts. If service A calls B with two seconds remaining, B sees two seconds — not a fresh two. A five-hop chain therefore cannot multiply its budget, which is exactly the failure mode that turns one slow dependency into a system-wide outage. The corollary is a rule: set the deadline at the edge and never re-set it at an inner hop, because doing so silently discards the caller's budget and reintroduces the problem deadlines were designed to solve. Treat a missing deadline the way you treat a missing HTTP client timeout — as a code review failure.
Cancellation does not interrupt your thread
The client timed out. The client process died. The user closed the tab. What happens on the server?
Nothing, unless you ask. gRPC does not interrupt your thread — it sets a flag on the Context. A handler that never checks it keeps computing, keeps querying the database, and keeps writing into a closed stream for the full duration of the work.
@Override
public void listOrders(ListOrdersRequest request, StreamObserver<Order> responseObserver) {
for (int i = 0; i < request.getCount(); i++) {
if (Context.current().isCancelled()) {
// Do NOT call onCompleted/onError -- the stream is already closed and
// touching it throws IllegalStateException. Just return.
return;
}
responseObserver.onNext(load(i));
}
responseObserver.onCompleted();
}
Measured: a client cancelling a 200-message stream after three messages, against a server that checks the flag, stops the server at message 5. Against a server that does not check, it produces all 200 — every database read, every serialization — into a stream nobody is reading.
If you hand blocking work to another thread, propagate the context with Context.current().wrap(runnable), or the flag is invisible there.
Errors arrive as UNKNOWN unless you make them otherwise
Any exception that escapes a gRPC handler becomes UNKNOWN with a null description. That is deliberate — leaking exception text across a service boundary is an information disclosure risk — but it means the useful part of your error is discarded by default.
Metadata trailers = new Metadata();
trailers.put(REASON_KEY, "ORDER_LOCKED");
trailers.put(RETRY_AFTER_KEY, "30");
responseObserver.onError(new StatusRuntimeException(
Status.FAILED_PRECONDITION.withDescription("order o-42 is locked"), trailers));
code : FAILED_PRECONDITION
description : order o-42 is locked
x-failure-reason : ORDER_LOCKED
x-retry-after-seconds : 30
The status code is the contract — retry policies, circuit breakers and dashboards all key off it:
Choosing carelessly makes non-retryable failures look retryable, which turns one bad request into a storm.
Do it centrally: @GrpcAdvice
Writing that mapping inline in every handler is exactly as unpleasant as it sounds, and it puts transport concepts into your domain code. Spring gRPC provides the direct analogue of @RestControllerAdvice:
@GrpcAdvice
public class OrderExceptionAdvice {
@GrpcExceptionHandler(OrderNotFoundException.class)
public StatusException handleNotFound(OrderNotFoundException ex) {
Metadata trailers = new Metadata();
trailers.put(REASON_KEY, "ORDER_NOT_FOUND");
return Status.NOT_FOUND.withDescription(ex.getMessage()).asException(trailers);
}
@GrpcExceptionHandler(IllegalArgumentException.class)
public StatusException handleBadInput(IllegalArgumentException ex) {
return Status.INVALID_ARGUMENT.withDescription(ex.getMessage()).asException();
}
// Catch-all: a deliberate INTERNAL beats an accidental UNKNOWN.
// Note what is NOT included -- ex.getMessage() may contain SQL, paths or user data,
// and this crosses a service boundary. Log it server-side, return a correlation id.
@GrpcExceptionHandler(Exception.class)
public StatusException handleEverythingElse(Exception ex) {
return Status.INTERNAL.withDescription("internal error").asException();
}
}
The service method then stays clean — it throws domain exceptions and never mentions Status:
if (repository.find(id) == null) {
throw new OrderNotFoundException(id); // no gRPC types here
}
Measured across the three cases:
id="missing-42" -> NOT_FOUND "no order with id missing-42" x-failure-reason: ORDER_NOT_FOUND
id="" -> INVALID_ARGUMENT "order id must not be blank"
id="boom" -> INTERNAL "internal error" (original message NOT leaked)
Handlers match most-specific-first, like @ExceptionHandler, so the Exception.class catch-all only fires for types nothing else claims.
A naming trap in this API. There are two types called GrpcExceptionHandler, in different packages. org.springframework.grpc.server.advice.GrpcExceptionHandler is the annotation used above. org.springframework.grpc.server.exception.GrpcExceptionHandler is a functional interface — StatusException handleException(Throwable) — which you implement as a bean when you want a single catch-all, or mapping driven by data rather than by exception type. Both are useful and they compose, but an IDE will happily import the wrong one, and the resulting error (“annotation type not applicable”, or “not a functional interface”) does not mention that a same-named sibling exists.
The 4 MB limit — and the test transport that hides it
gRPC's default maximum message size is 4 MB. Three properties of that limit make it confusing: it is per message, it is enforced by the receiver, and the two sides are configured separately.
# large RESPONSE -> the CLIENT is the receiver
spring.grpc.client.channel.orders.inbound.message.max-size=16MB
# large REQUEST -> the SERVER is the receiver
spring.grpc.server.inbound.message.max-size=16MB
Half of all “I raised the limit and it still fails” reports are that asymmetry: raising it on the server does nothing for a large response.
Now the part that cost me a debugging detour while writing this, and which is the most useful thing in the post:
The in-process transport cannot enforce message size limits at all. I wrote a test to demonstrate RESOURCE_EXHAUSTED at 4 MB and it failed — because the message went through cleanly. The in-process transport passes message objects by reference and never serialises them, so there are no bytes to measure and the limit simply does not apply. A payload-size regression will pass every in-process test you own and fail in production. The same blind spot covers compression, TLS negotiation, keepalive, GOAWAY handling, name resolution and load balancing — anything that depends on the wire. In-process remains an excellent transport for testing interceptors, statuses, deadlines and cancellation, all of which behave correctly. Just do not believe a green in-process suite about anything on that list; those need a real port and belong in a smaller, slower test tier. The companion repository asserts this behaviour rather than papering over it.
Prefer streaming to large unary messages — the limit is per message, so a thousand small ones are fine — and do not raise it globally, because a large limit turns a malformed request into an OOM.
The exact property names
Two of these bit me while writing the companion repository, and neither produces a helpful error. Both wrong forms are the ones you would guess.
The second is the expensive one. An unmatched channel name is handed to the DNS resolver as a literal target, so the error names your logical channel as though it were a hostname:
UNAVAILABLE: Unable to resolve host orders
Caused by: java.net.UnknownHostException: orders
— which sends you to investigate DNS, service discovery and networking rather than a typo.
The ones worth knowing by heart:
# --- server ---
spring.grpc.server.port=9090 # -1 = in-process only, 0 = ephemeral
spring.grpc.server.inprocess.name=orders-test # one word
spring.grpc.server.inbound.message.max-size=16MB # incoming REQUESTS
spring.grpc.server.inbound.metadata.max-size=8KB
spring.grpc.server.shutdown.grace-period=30s
spring.grpc.server.keepalive.time=5m
spring.grpc.server.keepalive.permit.time=5m # min client ping the server tolerates
spring.grpc.server.keepalive.connection.max-idle-time=15m
spring.grpc.server.reflection.enabled=true # needed for grpcurl without .proto files
spring.grpc.server.health.enabled=true
spring.grpc.server.ssl.bundle=my-bundle # Boot SSL bundles
spring.grpc.server.observation.enabled=true
# --- client: a MAP keyed by channel name, "channel" singular ---
spring.grpc.client.channel.orders.target=static://localhost:9090
spring.grpc.client.channel.orders.default.deadline=2s # the default gRPC lacks
spring.grpc.client.channel.orders.default.load-balancing-policy=round_robin
spring.grpc.client.channel.orders.inbound.message.max-size=16MB # incoming RESPONSES
spring.grpc.client.channel.orders.keepalive.time=30s
spring.grpc.client.channel.orders.keepalive.timeout=5s
spring.grpc.client.channel.orders.idle.timeout=30m
spring.grpc.client.channel.orders.ssl.bundle=my-bundle
spring.grpc.client.channel.orders.health.enabled=true
spring.grpc.client.channel.orders.service-config… # retry, LB, throttling
spring.grpc.client.inprocess.enabled=true
The complete list is in the repository, read straight out of the configuration metadata in the 4.1.0 jars.
Guard against typos: add spring-boot-configuration-processor and your IDE will flag unknown spring.grpc.* keys. It cannot validate the arbitrary channel names inside the channel.<name> map, but it catches everything else — including both mistakes above.
Everything else
The repository's troubleshooting guide is organised symptom-first — every entry starts from the error message you actually see. A few that did not fit here:
IllegalStateException: call already closed — responding after cancellation, calling onCompleted() twice, calling onError() then onCompleted(), or two threads sharing one StreamObserver.
Retries that do nothing — gRPC retries live in the service config, are off by default, and silently skip any status not listed in retryableStatusCodes or any stream already partially delivered.
Connections dropping every N minutes through a proxy — gRPC reuses one long-lived HTTP/2 connection, and load balancers disagree about what “idle” means. Keepalive must be shorter than the proxy's idle timeout, but not so short that the server responds with ENHANCE_YOUR_CALM.
All traffic hitting one backend — the default policy is pick_first, not round_robin, which is almost never what people expect behind a headless Kubernetes service.
IllegalStateException: No grpc channel factory found that supports target — the composite factory checks supports(target) before named-channel indirection is applied.
Reproducing this
git clone https://ankurm.com/git.app/asmhatre/spring-grpc-boot4.git
cd spring-grpc-boot4
mvn test
Eleven tests, a few seconds, nothing to install — protoc and the codegen plugin resolve as Maven artifacts and every test uses the in-process transport. Unedited output is committed as results-full.txt.
Further reading
spring-grpc-boot4 — the companion repository, including the symptom-first troubleshooting index
No Comments yet!