0%

gRPC vs REST: Real-World Performance Comparison

Technical article

Apr 15, 2026 10 min read Vaibhav Waghmare

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:

  1. Accepts investor ID
  2. Queries database for holdings (5-50 mutual fund schemes)
  3. Enriches each holding with current NAV (Net Asset Value)
  4. 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)

MetricREST (JSON)gRPC (Protobuf)Improvement
P50 Latency45ms28ms38% faster
P95 Latency120ms62ms48% faster
P99 Latency280ms95ms66% faster
Max Latency850ms320ms62% faster

Winner: gRPC (significantly faster)

Test 2: Throughput (Requests/second)

MetricRESTgRPCImprovement
Throughput8,500 req/s15,200 req/s79% more
Failed Requests0.5%0.1%80% fewer

Winner: gRPC (nearly 2x throughput)

Test 3: Payload Size

Holdings CountREST (JSON)gRPC (Protobuf)Reduction
5 holdings2.8 KB0.9 KB68%
10 holdings4.5 KB1.5 KB67%
50 holdings18.5 KB5.2 KB72%
100 holdings35.2 KB9.8 KB72%

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)

MetricRESTgRPCDifference
JSON Serialization12% CPUN/A-
Protobuf SerializationN/A2% CPU83% 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

MetricRESTgRPCDifference
Heap allocated/request85 KB32 KB62% less
GC pressureHighLow-

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: curl shows JSON directly
  • Browser-based testing: Just open DevTools
  • Log readability: JSON logs are easy to parse visually

gRPC challenges:

  • Binary payloads: Need grpcurl or specialized tools
  • Steeper learning curve: New developers struggle with .proto files
  • 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: .proto file 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 .proto files)

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

Want to discuss microservices architecture? Contact me or connect on LinkedIn.

Related Posts:

Topics

gRPC REST Performance Microservices Protocol Buffers

Share this article

Related Articles