0%

gRPC vs REST: Real-World Performance Comparison at Scale

Comprehensive performance analysis showing why we migrated from REST to gRPC and achieved 10x lower latency and 60% smaller payloads in production

Mar 25, 2024 β€’ 7 min read β€’ Vaibhav Waghmare

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

MetricRESTgRPCImprovement
P50 Latency45ms5ms9x faster
P95 Latency280ms28ms10x faster
P99 Latency450ms65ms6.9x faster
Payload Size8.2 KB3.1 KB62% smaller
Throughput2,100 RPS8,500 RPS4x higher
CPU Usage85%45%47% lower
Memory2.1 GB1.4 GB33% 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 reserved for 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:

  1. Binary serialization is significantly faster
  2. HTTP/2 multiplexing eliminates head-of-line blocking
  3. Streaming enables real-time use cases
  4. Type safety prevents entire classes of bugs
  5. But… REST still has its place for public APIs

Tech Stack: Spring Boot 3, gRPC, Protocol Buffers, Kubernetes, Envoy


Resources

Questions? Tweet @vaibhav7k

Topics

Backend gRPC Performance Microservices Spring Boot

Share this article

Related Articles