When your gRPC client gets rpc error: code = ResourceExhausted, it means the server ran out of something critical — memory, file descriptors, a connection pool, or a rate quota. gRPC status 8 (RESOURCE_EXHAUSTED) is technically retryable, but only if the resource is freed or the quota window resets. Blindly retrying a quota error just burns your budget faster. Understanding what resource is exhausted and why it happened is the key to a real fix.
The most common cause by far is the message size limit: gRPC enforces a default maximum receive message size of 4 MB (4,194,304 bytes), but has no limit on send size by default — which is exactly why you see this error asymmetrically. This guide covers the real-world causes, how to read the error, and the architectural fixes that don't just raise a limit and hope.
What RESOURCE_EXHAUSTED (Code 8) means
Per the official gRPC specification, RESOURCE_EXHAUSTED means some resource has been exhausted. It's marked as potentially retryable, but the retry contract is crucial: retrying only helps if the resource is freed or the limiting condition changes. A quota error won't resolve on retry unless time passes or someone manually increases your quota. See all 17 gRPC status codes explained for context on when each code appears.
This is different from UNAVAILABLE (code 14) — which means the service is temporarily down — or DEADLINE_EXCEEDED (code 4) — which means the client's deadline passed. RESOURCE_EXHAUSTED is a stable state: the server hit a hard limit.
Max receive message size exceeded (the dominant cause)
The most frequent trigger is a message larger than the server's (or client's) configured max receive size. The default is 4 MB on both sides. When a message arrives that's larger than this limit, the gRPC runtime rejects it before any handler code runs.
Reading the error
The error message gives you both sides of the comparison:
rpc error: code = ResourceExhausted desc = received message larger than max (5242880 vs 4194304)
The format is (actual vs. limit). So 5242880 vs 4194304 means the message was 5 MB but the limit is 4 MB. The second number is always the configured max receive size; the first is what arrived.
Why this asymmetry exists
gRPC has a default limit on receive size (4 MB) to protect servers from memory exhaustion under attack or misconfiguration. The send side has no built-in limit in many implementations because the sender controls its own memory — it's your problem if you try to build a 1 GB message.
This asymmetry causes confusion: "My client sends this fine, but the server rejects it!" That's because the client's handler code successfully crafted the 5 MB response, but the client's receive side then rejected it.
Raising the receive message size limit
If you genuinely need messages larger than 4 MB, raise the limit deliberately. The option names vary by language:
Go (server):
srv := grpc.NewServer(
grpc.MaxRecvMsgSize(10 * 1024 * 1024), // 10 MB
)
Go (client):
conn, err := grpc.Dial("service:5050",
grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(10 * 1024 * 1024)),
)
Python (channel-level, applies to client and server):
import grpc
# For a client channel:
channel = grpc.secure_channel(
'service:5050',
grpc.ssl_channel_credentials(),
options=[('grpc.max_receive_message_length', 10 * 1024 * 1024)]
)
# For a server:
server = grpc.server(
futures.ThreadPoolExecutor(max_workers=10),
options=[('grpc.max_receive_message_length', 10 * 1024 * 1024)]
)
Java (server):
import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
NettyServerBuilder.forPort(5050)
.maxInboundMessageSize(10 * 1024 * 1024) // 10 MB
.addService(new MyServiceImpl())
.build()
.start();
Java (client):
ManagedChannel channel = ManagedChannelBuilder
.forTarget("service:5050")
.maxInboundMessageSize(10 * 1024 * 1024)
.build();
.NET (server):
var options = new GrpcServerOptions();
options.MaxReceiveMessageSize = 10 * 1024 * 1024; // 10 MB
var server = new GrpcServer(options);
Raising the limit is a band-aid. Large unary messages hurt latency (the entire payload must arrive before the handler starts), consume memory (the whole message is buffered), and slow down serialization/deserialization. If you're hitting this limit regularly, the architectural fix is streaming or pagination, not a bigger limit.
The better fix: streaming or pagination
Instead of sending one giant response, break it into pieces. This is the right answer architecturally and operationally.
Server streaming (for large result sets):
service MyService {
rpc GetLargeData(Request) returns (stream DataChunk);
}
func (s *server) GetLargeData(req *pb.Request, stream grpc.ServerStream) error {
// Send data in chunks; each chunk is under 4 MB
for _, chunk := range getAllData(req.Id) {
if err := stream.Send(&pb.DataChunk{Data: chunk}); err != nil {
return err
}
}
return nil
}
The client receives each chunk as it arrives, processes it, and discards it — no buffering of the full payload. Latency is lower, memory is constant, and you're never hitting the message size limit.
Pagination:
service MyService {
rpc GetData(GetDataRequest) returns (GetDataResponse);
}
message GetDataRequest {
string id = 1;
int32 page = 2; // page number, 0-indexed
int32 page_size = 3; // items per page, e.g., 1000
}
message GetDataResponse {
repeated Item items = 1;
int32 total_count = 2;
int32 page = 3;
}
The client loops, requesting page 0, 1, 2, etc., until it has all results. Each response stays under 4 MB.
Both approaches are more cacheable, more resilient to client-server version skew, and easier to monitor than one monolithic unary call.
Rate limits and quotas
If the error message doesn't mention "message" or "size," the resource is often a quota or rate limit — either your own limiter or a managed API's quota system.
Your own quota limiter:
// Simple per-user quota: 1000 requests per minute
func (s *server) MyMethod(ctx context.Context, req *pb.Request) (*pb.Response, error) {
user := extractUser(ctx)
if !s.quotaLimiter.Allow(user, 1) {
return nil, status.Error(codes.ResourceExhausted, "quota exceeded: 1000 req/min")
}
// ... handle request
}
When a quota error is returned, the correct client response is exponential backoff — but also check if the server sent a RetryInfo in the rich error model, which tells you exactly how long to wait before retrying:
st, ok := status.FromError(err)
if ok && st.Code() == codes.ResourceExhausted {
for _, detail := range st.Details() {
if info, ok := detail.(*errdetails.RetryInfo); ok {
log.Printf("Retry after: %v", info.RetryDelay.AsDuration())
time.Sleep(info.RetryDelay.AsDuration())
}
}
}
Respecting RetryInfo prevents you from hammering the quota and gives you a fair chance to eventually succeed.
Connection pool saturation and max concurrent streams
If the server is under heavy load, it might hit its limit on concurrent streams or connection pool size. Each gRPC connection can handle multiple concurrent streams, but the server typically sets a cap (e.g., 100 streams per connection).
When the server's pool is exhausted:
Go (server-side):
srv := grpc.NewServer(
grpc.MaxConcurrentStreams(200),
)
Python:
server = grpc.server(
futures.ThreadPoolExecutor(max_workers=50),
options=[('grpc.max_concurrent_streams', 200)]
)
If you're consistently hitting this, you're either:
- Not sending requests concurrently enough (you're pipelining many small RPCs instead of batching).
- The server is legitimately under-provisioned. Increase the worker pool size or add replicas.
Compression interaction: the limit applies to decompressed size
A non-obvious detail: the message size limit applies after decompression. If you send a 5 MB message compressed to 2 MB, and the receive limit is 4 MB, the server will still reject it because the decompressed size is 5 MB.
Compression doesn't save you from hitting the limit; it only reduces network bandwidth. The real size limit is on the decompressed message.
// This will still fail if the uncompressed message is > 4 MB
conn, _ := grpc.Dial("service:5050",
grpc.WithDefaultCallOptions(grpc.UseCompressor("gzip")),
)
The fix is the same: stream or paginate.
Retry behavior: RESOURCE_EXHAUSTED is retryable per the gRPC spec, but only if the caller has reasonable grounds to believe the resource will be freed. For message size, there's nothing to retry — you need to fix the message. For quotas, retry with backoff and honor any RetryInfo from the server. For connection saturation, retry with backoff; the server should recover as in-flight requests complete.
Distinguishing RESOURCE_EXHAUSTED from related errors
- UNAVAILABLE (14): The service is down or unreachable. Retry immediately with backoff.
- RESOURCE_EXHAUSTED (8): The service is up but a specific resource (message size, quota, connections) is exhausted. Retrying might work if the resource is freed.
- FAILED_PRECONDITION (9): The system is in the wrong state for this operation. You need to fix the precondition before retrying.
In your error handling and monitoring for gRPC, pay special attention to which resource is exhausted. A spike in message-size rejections signals a broken client contract; a spike in quota errors signals you're hitting your limits and need to throttle or increase capacity. Distributed tracing across your services will show you whether RESOURCE_EXHAUSTED is a localized issue or part of a wider bottleneck.
Reading RESOURCE_EXHAUSTED in code
Go
st, ok := status.FromError(err)
if ok && st.Code() == codes.ResourceExhausted {
fmt.Println("ResourceExhausted:", st.Message())
// Check for RetryInfo or QuotaFailure details (from rich error model)
for _, detail := range st.Details() {
switch detail.(type) {
case *errdetails.RetryInfo:
fmt.Println("Server recommends retry after:", detail.(*errdetails.RetryInfo).RetryDelay)
case *errdetails.QuotaFailure:
fmt.Println("Quota violation:", detail.(*errdetails.QuotaFailure).Violations)
}
}
}
Node.js
const { status } = require('@grpc/grpc-js');
try {
await client.myMethod(request);
} catch (err) {
if (err.code === status.RESOURCE_EXHAUSTED) {
console.log('ResourceExhausted:', err.message);
// Inspect metadata for details
if (err.metadata) {
console.log('Metadata:', err.metadata);
}
}
}
Java
try {
stub.myMethod(request);
} catch (StatusRuntimeException e) {
if (e.getStatus().getCode() == Status.Code.RESOURCE_EXHAUSTED) {
System.out.println("ResourceExhausted: " + e.getStatus().getDescription());
// Extract rich error details if present
e.getTrailers(); // includes RetryInfo, QuotaFailure, etc.
}
}
Start tracking errors in minutes
Catch RESOURCE_EXHAUSTED errors in production with full context: trace the message that triggered it, see which quotas are hit, and monitor patterns across your services with LightTrace.