gRPC vs REST: Performance Deep-Dive
The Context
Our microservices architecture had 12 services communicating via REST APIs. As traffic grew to 5,000 RPS, we hit performance issues:
- P95 latency: 280ms
- Large JSON payloads (avg 50KB)
- Network overhead killing us
- Serialization/deserialization bottleneck
We needed to optimize. Enter gRPC.
What is gRPC?
gRPC (Google Remote Procedure Call):
- Uses Protocol Buffers (binary format)
- HTTP/2 based (multiplexing, streaming)
- Strongly typed contracts
- Bi-directional streaming
vs
REST:
- Uses JSON (text format)
- HTTP/1.1 (mostly)
- Loosely typed (schemas optional)
- Request-response pattern
Performance Benchmarks
Test Setup
Service A β Service B
- Fetch user profile with 20 fields
- 1000 concurrent requests
- Same hardware (8 CPU, 16GB RAM)
- Same network conditions
Results
| Metric | REST | gRPC | Improvement |
|---|---|---|---|
| P50 Latency | 45ms | 5ms | 9x faster |
| P95 Latency | 280ms | 28ms | 10x faster |
| P99 Latency | 450ms | 65ms | 6.9x faster |
| Payload Size | 8.2 KB | 3.1 KB | 62% smaller |
| Throughput | 2,100 RPS | 8,500 RPS | 4x higher |
| CPU Usage | 85% | 45% | 47% lower |
| Memory | 2.1 GB | 1.4 GB | 33% lower |
Winner: gRPC by a landslide π
Implementation
1. Define Protocol Buffer Schema
syntax = "proto3";
package user;
// User service definition
service UserService {
// Unary RPC
rpc GetUser(GetUserRequest) returns (UserResponse);
// Server streaming
rpc ListUsers(ListUsersRequest) returns (stream UserResponse);
// Client streaming
rpc CreateUsers(stream CreateUserRequest) returns (CreateUsersResponse);
// Bi-directional streaming
rpc SyncUsers(stream UserUpdate) returns (stream UserUpdate);
}
// Request messages
message GetUserRequest {
string user_id = 1;
repeated string fields = 2; // Field mask
}
message ListUsersRequest {
int32 page_size = 1;
string page_token = 2;
string filter = 3;
}
// Response messages
message UserResponse {
string id = 1;
string name = 2;
string email = 3;
int64 created_at = 4;
UserMetadata metadata = 5;
}
message UserMetadata {
string role = 1;
repeated string permissions = 2;
map<string, string> attributes = 3;
}
2. Generate Code
# For Java (Spring Boot)
./gradlew generateProto
# For Python
python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. user.proto
# For Go
protoc --go_out=. --go-grpc_out=. user.proto
3. Spring Boot gRPC Service
@GrpcService
public class UserServiceImpl extends UserServiceGrpc.UserServiceImplBase {
@Autowired
private UserRepository userRepository;
@Override
public void getUser(
GetUserRequest request,
StreamObserver<UserResponse> responseObserver
) {
// Fetch user from database
User user = userRepository.findById(request.getUserId())
.orElseThrow(() -> new StatusException(Status.NOT_FOUND));
// Build response
UserResponse response = UserResponse.newBuilder()
.setId(user.getId())
.setName(user.getName())
.setEmail(user.getEmail())
.setCreatedAt(user.getCreatedAt().toEpochMilli())
.setMetadata(buildMetadata(user))
.build();
// Send response
responseObserver.onNext(response);
responseObserver.onCompleted();
}
@Override
public void listUsers(
ListUsersRequest request,
StreamObserver<UserResponse> responseObserver
) {
// Server streaming: send users one by one
Flux<User> users = userRepository.findAll()
.filter(user -> matchesFilter(user, request.getFilter()))
.take(request.getPageSize());
users.subscribe(
user -> responseObserver.onNext(toUserResponse(user)),
error -> responseObserver.onError(error),
() -> responseObserver.onCompleted()
);
}
private UserMetadata buildMetadata(User user) {
return UserMetadata.newBuilder()
.setRole(user.getRole())
.addAllPermissions(user.getPermissions())
.putAllAttributes(user.getAttributes())
.build();
}
}
4. gRPC Client (Java)
@Service
public class UserClient {
private final UserServiceGrpc.UserServiceBlockingStub blockingStub;
private final UserServiceGrpc.UserServiceStub asyncStub;
public UserClient(
@GrpcClient("user-service") ManagedChannel channel
) {
this.blockingStub = UserServiceGrpc.newBlockingStub(channel);
this.asyncStub = UserServiceGrpc.newStub(channel);
}
// Synchronous call
public UserResponse getUser(String userId) {
GetUserRequest request = GetUserRequest.newBuilder()
.setUserId(userId)
.addFields("id")
.addFields("name")
.addFields("email")
.build();
return blockingStub.getUser(request);
}
// Asynchronous call
public CompletableFuture<UserResponse> getUserAsync(String userId) {
CompletableFuture<UserResponse> future = new CompletableFuture<>();
GetUserRequest request = GetUserRequest.newBuilder()
.setUserId(userId)
.build();
asyncStub.getUser(request, new StreamObserver<UserResponse>() {
@Override
public void onNext(UserResponse response) {
future.complete(response);
}
@Override
public void onError(Throwable t) {
future.completeExceptionally(t);
}
@Override
public void onCompleted() {
// Stream completed
}
});
return future;
}
// Server streaming
public void streamUsers(Consumer<UserResponse> onUser) {
ListUsersRequest request = ListUsersRequest.newBuilder()
.setPageSize(100)
.build();
Iterator<UserResponse> users = blockingStub.listUsers(request);
users.forEachRemaining(onUser);
}
}
Advanced Features
1. Load Balancing
@Configuration
public class GrpcConfig {
@Bean
public ManagedChannel userServiceChannel() {
return ManagedChannelBuilder
.forTarget("user-service:///")
.defaultLoadBalancingPolicy("round_robin") // or "grpclb"
.usePlaintext()
.build();
}
}
2. Interceptors (Middleware)
public class AuthInterceptor implements ServerInterceptor {
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
ServerCall<ReqT, RespT> call,
Metadata headers,
ServerCallHandler<ReqT, RespT> next
) {
// Extract auth token
String token = headers.get(
Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER)
);
// Validate
if (!isValidToken(token)) {
call.close(Status.UNAUTHENTICATED, new Metadata());
return new ServerCall.Listener<>() {};
}
// Proceed
return next.startCall(call, headers);
}
}
3. Deadlines & Timeouts
// Client-side deadline
UserResponse response = blockingStub
.withDeadlineAfter(500, TimeUnit.MILLISECONDS)
.getUser(request);
// Server-side check
@Override
public void getUser(GetUserRequest request, StreamObserver<UserResponse> responseObserver) {
// Check if deadline exceeded
if (Context.current().isCancelled()) {
responseObserver.onError(Status.CANCELLED.asException());
return;
}
// Process request...
}
4. Streaming (Bi-directional)
// Client code
StreamObserver<UserUpdate> requestObserver = asyncStub.syncUsers(
new StreamObserver<UserUpdate>() {
@Override
public void onNext(UserUpdate update) {
System.out.println("Received update: " + update);
}
@Override
public void onError(Throwable t) {
System.err.println("Error: " + t);
}
@Override
public void onCompleted() {
System.out.println("Sync completed");
}
}
);
// Send updates
requestObserver.onNext(UserUpdate.newBuilder()...build());
requestObserver.onNext(UserUpdate.newBuilder()...build());
requestObserver.onCompleted();
Production Challenges & Solutions
Challenge 1: Debugging is Harder
Problem: Binary format makes debugging difficult
Solution: Use tools
# grpcurl - curl for gRPC
grpcurl -plaintext localhost:9090 user.UserService/GetUser
# grpc_cli
grpc_cli call localhost:9090 GetUser "user_id: '123'"
# gRPC UI
docker run -p 8080:8080 fullstorydev/grpcui -plaintext localhost:9090
Challenge 2: Browser Support
Problem: Browsers donβt natively support HTTP/2 gRPC
Solution: gRPC-Web
@Bean
public GrpcWebFilter grpcWebFilter() {
return new GrpcWebFilter();
}
Challenge 3: Version Compatibility
Problem: Proto changes can break clients
Solution: Follow best practices
- Never change field numbers
- Use
reservedfor deprecated fields - Always add, never remove
message User {
reserved 4; // Deprecated field
reserved "old_field_name";
string id = 1;
string name = 2;
string email = 3;
// string deprecated_field = 4; // Removed
string new_field = 5; // Added
}
When to Use gRPC vs REST
Use gRPC When:
β Microservice-to-microservice communication β Low latency is critical β Need bi-directional streaming β Strong typing is important β Polyglot services (auto-generated clients)
Use REST When:
β Public-facing APIs (browser clients) β Human-readable responses needed β Simple CRUD operations β Third-party integrations β Team unfamiliar with gRPC
Our Decision:
- Internal services: gRPC
- Public API: REST (GraphQL for complex queries)
- Mobile apps: REST + WebSocket for real-time
Migration Strategy
Phase 1: Pilot (1 service pair)
- User Service β Payment Service
- Measure performance
- Identify issues
Phase 2: Critical Path (3 months)
- Migrate high-traffic routes
- Run dual stack (REST + gRPC)
- Gradual traffic shift
Phase 3: Full Migration (6 months)
- All internal services
- Deprecate REST endpoints
- Monitor and optimize
Results After Migration
Performance Gains
- Latency: 280ms β 28ms (P95)
- Throughput: 2,100 β 8,500 RPS
- Infrastructure cost: -35% (fewer servers needed)
Developer Experience
- Type safety: Caught 15+ bugs at compile time
- Auto-generated clients: Saved 40 hours/month
- Streaming: Enabled real-time features easily
Trade-offs
- Learning curve: 2-3 weeks for team
- Debugging: Required new tools
- Browser support: Needed gRPC-Web gateway
Conclusion
gRPC delivered 10x performance improvement for our microservices. The migration was worth it, but not without challenges.
Key Takeaways:
- Binary serialization is significantly faster
- HTTP/2 multiplexing eliminates head-of-line blocking
- Streaming enables real-time use cases
- Type safety prevents entire classes of bugs
- But⦠REST still has its place for public APIs
Tech Stack: Spring Boot 3, gRPC, Protocol Buffers, Kubernetes, Envoy
Resources
- Code: github.com/vaibhav7k/grpc-microservices
- Benchmarks: Full performance test suite included
- Blog: More microservices content coming soon
Questions? Tweet @vaibhav7k