The Debate: gRPC vs REST
When building our mutual fund platform with 100+ microservices, we faced a critical decision: should we use REST (the industry standard) or gRPC (Google’s high-performance RPC framework)?
Everyone has opinions:
- REST fans: “It’s simple, widely supported, and uses JSON which is human-readable”
- gRPC fans: “It’s faster, type-safe, and uses HTTP/2 for multiplexing”
Instead of going with gut feeling, I benchmarked both in our actual production environment.
Test Setup: Apples-to-Apples Comparison
The Service: Investor Portfolio Lookup
A real service from our platform that:
- Accepts investor ID
- Queries database for holdings (5-50 mutual fund schemes)
- Enriches each holding with current NAV (Net Asset Value)
- Calculates total portfolio value and returns
Why this service?
- Represents typical microservice workload (database query + external API calls)
- Variable response size (5-50 holdings)
- Called frequently (1000+ times/minute)
REST Implementation
@RestController
@RequestMapping("/api/v1/portfolio")
public class PortfolioRestController {
@Autowired
private PortfolioService portfolioService;
@GetMapping("/{investorId}")
public Mono<PortfolioResponse> getPortfolio(@PathVariable String investorId) {
return portfolioService.getPortfolio(investorId)
.map(portfolio -> PortfolioResponse.builder()
.investorId(portfolio.getInvestorId())
.holdings(portfolio.getHoldings().stream()
.map(this::toHoldingDto)
.collect(Collectors.toList()))
.totalValue(portfolio.getTotalValue())
.lastUpdated(portfolio.getLastUpdated())
.build()
);
}
private HoldingDto toHoldingDto(Holding holding) {
return HoldingDto.builder()
.schemeCode(holding.getSchemeCode())
.schemeName(holding.getSchemeName())
.units(holding.getUnits())
.navValue(holding.getNavValue())
.currentValue(holding.getCurrentValue())
.investedValue(holding.getInvestedValue())
.returns(holding.getReturns())
.returnsPercentage(holding.getReturnsPercentage())
.build();
}
}
Response (JSON):
{
"investorId": "INV-12345",
"holdings": [
{
"schemeCode": "MF001",
"schemeName": "HDFC Equity Fund - Growth",
"units": 1000.5,
"navValue": 250.75,
"currentValue": 250875.38,
"investedValue": 200000.00,
"returns": 50875.38,
"returnsPercentage": 25.44
}
// ... 49 more holdings
],
"totalValue": 5250000.00,
"lastUpdated": "2026-04-13T10:30:00Z"
}
Payload size (50 holdings): 18.5 KB
gRPC Implementation
1. Define Protocol Buffer schema:
// portfolio.proto
syntax = "proto3";
package portfolio;
option java_package = "com.giftcity.portfolio.grpc";
option java_outer_classname = "PortfolioProto";
service PortfolioService {
rpc GetPortfolio (PortfolioRequest) returns (PortfolioResponse);
rpc GetPortfolioStream (PortfolioRequest) returns (stream Holding);
}
message PortfolioRequest {
string investor_id = 1;
}
message PortfolioResponse {
string investor_id = 1;
repeated Holding holdings = 2;
double total_value = 3;
int64 last_updated = 4;
}
message Holding {
string scheme_code = 1;
string scheme_name = 2;
double units = 3;
double nav_value = 4;
double current_value = 5;
double invested_value = 6;
double returns = 7;
double returns_percentage = 8;
}
2. Implement gRPC service:
@GrpcService
public class PortfolioGrpcService extends PortfolioServiceGrpc.PortfolioServiceImplBase {
@Autowired
private PortfolioService portfolioService;
@Override
public void getPortfolio(
PortfolioProto.PortfolioRequest request,
StreamObserver<PortfolioProto.PortfolioResponse> responseObserver
) {
portfolioService.getPortfolio(request.getInvestorId())
.map(portfolio -> PortfolioProto.PortfolioResponse.newBuilder()
.setInvestorId(portfolio.getInvestorId())
.addAllHoldings(portfolio.getHoldings().stream()
.map(this::toHoldingProto)
.collect(Collectors.toList()))
.setTotalValue(portfolio.getTotalValue())
.setLastUpdated(portfolio.getLastUpdated().toEpochMilli())
.build()
)
.subscribe(
responseObserver::onNext,
responseObserver::onError,
responseObserver::onCompleted
);
}
private PortfolioProto.Holding toHoldingProto(Holding holding) {
return PortfolioProto.Holding.newBuilder()
.setSchemeCode(holding.getSchemeCode())
.setSchemeName(holding.getSchemeName())
.setUnits(holding.getUnits())
.setNavValue(holding.getNavValue())
.setCurrentValue(holding.getCurrentValue())
.setInvestedValue(holding.getInvestedValue())
.setReturns(holding.getReturns())
.setReturnsPercentage(holding.getReturnsPercentage())
.build();
}
}
Payload size (50 holdings): 5.2 KB (binary Protocol Buffers)
Benchmark Results: The Numbers
I ran load tests using Gatling with 10,000 concurrent users for 10 minutes.
Test 1: Latency (Portfolio with 50 holdings)
| Metric | REST (JSON) | gRPC (Protobuf) | Improvement |
|---|---|---|---|
| P50 Latency | 45ms | 28ms | 38% faster |
| P95 Latency | 120ms | 62ms | 48% faster |
| P99 Latency | 280ms | 95ms | 66% faster |
| Max Latency | 850ms | 320ms | 62% faster |
Winner: gRPC (significantly faster)
Test 2: Throughput (Requests/second)
| Metric | REST | gRPC | Improvement |
|---|---|---|---|
| Throughput | 8,500 req/s | 15,200 req/s | 79% more |
| Failed Requests | 0.5% | 0.1% | 80% fewer |
Winner: gRPC (nearly 2x throughput)
Test 3: Payload Size
| Holdings Count | REST (JSON) | gRPC (Protobuf) | Reduction |
|---|---|---|---|
| 5 holdings | 2.8 KB | 0.9 KB | 68% |
| 10 holdings | 4.5 KB | 1.5 KB | 67% |
| 50 holdings | 18.5 KB | 5.2 KB | 72% |
| 100 holdings | 35.2 KB | 9.8 KB | 72% |
Winner: gRPC (~70% smaller payloads)
Why? Protocol Buffers use binary encoding, while JSON is text. Numbers are especially wasteful in JSON:
"returns": 50875.38(19 bytes in JSON)- Binary double: 8 bytes in Protobuf
- Field name overhead: JSON includes field names in every object, Protobuf uses numeric tags
Test 4: CPU Usage (Server-side)
| Metric | REST | gRPC | Difference |
|---|---|---|---|
| JSON Serialization | 12% CPU | N/A | - |
| Protobuf Serialization | N/A | 2% CPU | 83% less |
| Total CPU (avg) | 45% | 28% | 38% less |
Winner: gRPC (5-6x faster serialization)
Why? Protocol Buffers generate optimized Java classes at compile time, while Jackson (JSON library) uses reflection at runtime.
Test 5: Memory Allocation
| Metric | REST | gRPC | Difference |
|---|---|---|---|
| Heap allocated/request | 85 KB | 32 KB | 62% less |
| GC pressure | High | Low | - |
Winner: gRPC (fewer allocations, less garbage collection)
When gRPC Shines: Our Use Cases
1. Service-to-Service Communication
Scenario: Order Service calls Portfolio Service, Payment Service, and RTA Service
// gRPC client (type-safe, generated code)
PortfolioServiceBlockingStub portfolioClient =
PortfolioServiceGrpc.newBlockingStub(channel);
PortfolioResponse portfolio = portfolioClient.getPortfolio(
PortfolioRequest.newBuilder()
.setInvestorId(investorId)
.build()
);
// Compile-time type safety!
double totalValue = portfolio.getTotalValue();
Benefits:
- Type safety: Compiler catches errors (e.g.,
getTotalValu()typo fails at compile-time) - Auto-generated clients: No manual HTTP client code
- Version compatibility: Protobuf supports backward/forward compatibility
2. Streaming Real-Time Data
Scenario: Stream portfolio updates as NAV changes throughout the day
// Server-side streaming
@Override
public void getPortfolioStream(
PortfolioProto.PortfolioRequest request,
StreamObserver<PortfolioProto.Holding> responseObserver
) {
portfolioService.getPortfolioStream(request.getInvestorId())
.doOnNext(holding -> responseObserver.onNext(toHoldingProto(holding)))
.doOnError(responseObserver::onError)
.doOnComplete(responseObserver::onCompleted)
.subscribe();
}
// Client-side
Iterator<Holding> holdings = portfolioClient.getPortfolioStream(request);
while (holdings.hasNext()) {
Holding holding = holdings.next();
updateUI(holding); // Update UI as data arrives
}
REST alternative: Would require WebSockets or Server-Sent Events (SSE), which are more complex to implement.
3. Bidirectional Streaming (Chat-like Features)
Scenario: Real-time order status updates
// Bidirectional streaming
@Override
public StreamObserver<OrderUpdate> streamOrders(
StreamObserver<OrderStatus> responseObserver
) {
return new StreamObserver<OrderUpdate>() {
@Override
public void onNext(OrderUpdate update) {
OrderStatus status = processOrder(update);
responseObserver.onNext(status); // Send status back
}
// ... onError, onCompleted
};
}
Use case: Client sends order updates → Server processes → Server sends status back → repeat
REST alternative: Would require polling or WebSockets.
When REST Still Wins
Despite gRPC’s performance advantages, we still use REST for:
1. Public-Facing APIs
Why?
- Browser support: gRPC-Web exists but adds complexity
- API documentation: Swagger/OpenAPI is well-established for REST
- Developer experience: Postman, curl, browser dev tools all support REST natively
- Third-party integrations: Partners expect REST APIs, not gRPC
Our API Gateway (Apigee) exposes REST to external clients, then translates to gRPC internally.
External (REST) → Apigee Gateway → Internal Services (gRPC)
2. Simple CRUD Operations
Why? The overhead of defining .proto files isn’t worth it for simple endpoints:
// REST: Simple and obvious
@GetMapping("/health")
public String healthCheck() {
return "OK";
}
// gRPC: Overkill for this
// Would need: .proto file, generated code, service implementation
Rule of thumb: If the endpoint is simple and called infrequently, REST is fine.
3. Debugging & Monitoring
REST advantages:
- Human-readable payloads:
curlshows JSON directly - Browser-based testing: Just open DevTools
- Log readability: JSON logs are easy to parse visually
gRPC challenges:
- Binary payloads: Need
grpcurlor specialized tools - Steeper learning curve: New developers struggle with
.protofiles - Logging: Binary data in logs is useless, need to decode
Our solution: Use gRPC for performance-critical paths, REST for everything else.
Migration Strategy: How We Adopted gRPC
We didn’t switch overnight. Here’s our phased approach:
Phase 1: Pilot Service (2 weeks)
- Chose Portfolio Service (frequently called, well-understood)
- Implemented gRPC version alongside REST
- Ran A/B test: 10% traffic to gRPC, 90% to REST
- Monitored errors, latency, CPU usage
Result: gRPC worked well, no production issues.
Phase 2: High-Traffic Services (1 month)
Migrated services with:
- High call volume (>1000 req/s)
- Large payloads (>5 KB)
- Service-to-service only (not public APIs)
Migrated services:
- Order Service → Payment Service (gRPC)
- Portfolio Service → NAV Service (gRPC)
- Onboarding Service → KYC Services (gRPC)
Result: 40% latency reduction, 30% CPU savings.
Phase 3: Streaming Use Cases (2 months)
- Real-time portfolio updates (server streaming)
- Order status notifications (bidirectional streaming)
Result: Eliminated polling, reduced server load by 50%.
What We Didn’t Migrate
- Public REST API Gateway (external partners)
- Admin dashboards (low traffic, simplicity matters)
- Health checks and metrics (simple endpoints)
Tooling & Libraries
Java gRPC Stack:
<dependencies>
<!-- gRPC core -->
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-netty-shaded</artifactId>
<version>1.62.2</version>
</dependency>
<!-- Protocol Buffers -->
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-protobuf</artifactId>
<version>1.62.2</version>
</dependency>
<!-- gRPC stub generation -->
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-stub</artifactId>
<version>1.62.2</version>
</dependency>
<!-- Spring Boot gRPC starter -->
<dependency>
<groupId>net.devh</groupId>
<artifactId>grpc-spring-boot-starter</artifactId>
<version>3.1.0.RELEASE</version>
</dependency>
</dependencies>
Code Generation (Maven plugin):
<plugin>
<groupId>org.xolstice.maven.plugins</groupId>
<artifactId>protobuf-maven-plugin</artifactId>
<version>0.6.1</version>
<configuration>
<protocArtifact>
com.google.protobuf:protoc:3.25.1:exe:${os.detected.classifier}
</protocArtifact>
<pluginId>grpc-java</pluginId>
<pluginArtifact>
io.grpc:protoc-gen-grpc-java:1.62.2:exe:${os.detected.classifier}
</pluginArtifact>
</configuration>
<executions>
<execution>
<goals>
<goal>compile</goal>
<goal>compile-custom</goal>
</goals>
</execution>
</executions>
</plugin>
Testing gRPC:
# Install grpcurl (like curl for gRPC)
brew install grpcurl
# List services
grpcurl -plaintext localhost:9090 list
# Call method
grpcurl -plaintext \
-d '{"investor_id": "INV-12345"}' \
localhost:9090 \
portfolio.PortfolioService/GetPortfolio
Lessons Learned
1. ✅ gRPC Wins for Performance
Our metrics:
- 48% faster P95 latency
- 79% more throughput
- 72% smaller payloads
- 83% less CPU for serialization
When it matters: High-traffic, performance-critical services.
2. ❌ gRPC Isn’t Always Worth the Complexity
Challenges we faced:
- Learning curve: Team took 2 weeks to become productive with Protobuf
- Debugging: Binary payloads are harder to inspect than JSON
- Tooling: Need
grpcurl, specialized monitoring tools - Breaking changes:
.protofile changes can break clients if not careful
When to skip it: Simple CRUD, low-traffic endpoints, public APIs.
3. 💡 Hybrid Approach is Best
Our final architecture:
External Clients (REST)
↓
Apigee API Gateway (REST → gRPC translation)
↓
Internal Services (gRPC for high-traffic, REST for simple endpoints)
Benefits:
- Best of both worlds: Performance where needed, simplicity elsewhere
- Backward compatibility: Existing REST clients still work
- Gradual migration: No big-bang rewrite
4. 📊 Always Benchmark Your Use Case
Our benchmark setup:
- Realistic load: 10,000 concurrent users (actual production traffic)
- Real service: Portfolio lookup with database queries + API calls
- Real payloads: Variable-size responses (5-100 holdings)
- Full stack: Including database, caching, external APIs
Don’t trust blog posts (including this one!)—benchmark YOUR use case.
Conclusion: When to Use What
Use gRPC for:
- ✅ Service-to-service communication (microservices)
- ✅ High-traffic endpoints (>1000 req/s)
- ✅ Large payloads (>5 KB)
- ✅ Streaming (real-time updates, bidirectional communication)
- ✅ Type safety (strongly-typed contracts between services)
Use REST for:
- ✅ Public APIs (external partners, third-party integrations)
- ✅ Browser clients (unless you want gRPC-Web complexity)
- ✅ Simple CRUD (low-traffic, straightforward endpoints)
- ✅ Developer experience (easier debugging, widely understood)
- ✅ Quick prototyping (faster to build without
.protofiles)
Our Production Split:
- gRPC: 70% of internal service-to-service calls
- REST: 100% of public APIs, 30% of internal calls
Result: Best performance where it matters, best DX where it doesn’t.
Resources
- gRPC Official Documentation
- Protocol Buffers Guide
- Spring Boot gRPC Starter
- grpcurl - Command-line tool
Want to discuss microservices architecture? Contact me or connect on LinkedIn.
Related Posts:
- Building a Mutual Fund Platform: Architecture Deep-Dive
- Spring WebFlux: When and Why to Go Reactive
- Coming soon: “Kubernetes Best Practices for Spring Boot Apps”