gRPC vs REST: When to Use Each in Modern APIs
- REST over HTTP/JSON for external APIs, public-facing endpoints, and any consumer you don't control β ubiquity and tooling win.
- gRPC over protobuf/HTTP/2 for internal microservice communication, high-frequency calls, and when streaming is required.
- gRPC is 2-5x faster for frequent small-payload calls. For large payloads or infrequent calls, the difference is smaller.
I've built and maintained both REST and gRPC APIs in production. The decision is rarely about technical superiority β both work. It's about the consumers and their requirements. External API consumed by customers? REST. Internal microservice-to-microservice communication that processes 50,000 RPCs per second? gRPC. The mistake I see is teams defaulting to one or the other without asking 'who is consuming this and what do they need?'
In 2022, we migrated an internal order-processing service from REST to gRPC. The service handled inter-service communication between six microservices, averaging 30,000 requests per minute. After migration: 40% reduction in serialisation overhead, 60% reduction in request latency at P99. The consumer apps stayed on REST. The internal fabric went gRPC. Both in production, each doing what it's good at.
Protocol and Serialisation: Where the Performance Difference Comes From
REST typically sends JSON over HTTP/1.1. JSON is text β human readable, but verbose. Every field name is repeated as a string in every message. HTTP/1.1 opens a new TCP connection per request (or reuses one with keep-alive, but still has head-of-line blocking).
gRPC uses Protocol Buffers (protobuf) over HTTP/2. Protobuf is binary β fields are identified by integer tags, not string names. The same data that takes 200 bytes as JSON might take 40 bytes as protobuf. HTTP/2 multiplexes multiple requests over a single TCP connection and supports full-duplex streaming.
The performance difference is real but often overstated in benchmarks that don't reflect production conditions. The 3-5x latency improvement cited for gRPC over REST usually holds for high-frequency, small-payload inter-service calls. For large payloads or infrequent calls, the difference shrinks.
// ββ 1. Define the contract in .proto file ββββββββββββββββββββββββββββββββββ // File: src/main/proto/payment.proto /* syntax = "proto3"; option java_package = "io.thecodeforge.payment.grpc"; option java_outer_classname = "PaymentProto"; service PaymentService { // Unary RPC β one request, one response (like REST) rpc ProcessPayment(PaymentRequest) returns (PaymentResponse); // Server streaming β one request, stream of responses rpc StreamTransactions(TransactionFilter) returns (stream Transaction); // Client streaming β stream of requests, one response rpc BatchPayments(stream PaymentRequest) returns (BatchResult); // Bidirectional streaming β both sides stream rpc PaymentFeed(stream PaymentEvent) returns (stream PaymentEvent); } message PaymentRequest { string customer_id = 1; int64 amount_pence = 2; string currency = 3; } message PaymentResponse { string payment_id = 1; string status = 2; int64 timestamp = 3; } */ // ββ 2. Generated server implementation βββββββββββββββββββββββββββββββββββββ package io.thecodeforge.payment.grpc; import io.grpc.stub.StreamObserver; public class PaymentServiceImpl extends PaymentServiceGrpc.PaymentServiceImplBase { @Override public void processPayment( PaymentRequest request, StreamObserver<PaymentResponse> responseObserver) { // Business logic String paymentId = processInternally( request.getCustomerId(), request.getAmountPence(), request.getCurrency() ); // Build protobuf response (binary, not JSON) PaymentResponse response = PaymentResponse.newBuilder() .setPaymentId(paymentId) .setStatus("COMPLETED") .setTimestamp(System.currentTimeMillis()) .build(); responseObserver.onNext(response); responseObserver.onCompleted(); } private String processInternally(String customerId, long amount, String currency) { // Payment processing logic return "pay-" + System.nanoTime(); } } // ββ 3. Client usage β generated stub handles serialisation βββββββββββββββββ package io.thecodeforge.payment.grpc; import io.grpc.ManagedChannel; import io.grpc.ManagedChannelBuilder; public class PaymentClient { public static void main(String[] args) { ManagedChannel channel = ManagedChannelBuilder .forAddress("payment-service.internal", 50051) .usePlaintext() .build(); PaymentServiceGrpc.PaymentServiceBlockingStub stub = PaymentServiceGrpc.newBlockingStub(channel); PaymentRequest request = PaymentRequest.newBuilder() .setCustomerId("customer-42") .setAmountPence(10000) // Β£100.00 .setCurrency("GBP") .build(); PaymentResponse response = stub.processPayment(request); System.out.println("Payment ID: " + response.getPaymentId()); System.out.println("Status: " + response.getStatus()); } }
Status: COMPLETED
# Wire comparison (same PaymentRequest):
# JSON: {"customer_id":"customer-42","amount_pence":10000,"currency":"GBP"} β 62 bytes
# Protobuf: binary encoded β 21 bytes
# ~3x smaller on the wire
REST: Why It Still Wins for Most APIs
REST's dominance for public and external APIs isn't about technical merit β it's about ubiquity. Every HTTP client in every language speaks REST. Browsers speak REST natively. curl speaks REST. Postman speaks REST. Your customers' mobile app developers know REST. Your partners' integration teams know REST.
gRPC requires a generated client from the .proto definition. Your API consumers need to run protoc, add gRPC dependencies, and handle the generated code. For internal services under your control, this is a minor inconvenience. For external API consumers who might be using PHP, Ruby, or a legacy Java stack, it's a barrier.
REST also wins on tooling maturity: API gateways, load balancers, proxies, observability tools, and browsers all understand HTTP/JSON natively. gRPC on HTTP/2 requires specific support at every layer.
package io.thecodeforge.payment.rest; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/v1/payments") public class PaymentRestController { private final PaymentService paymentService; public PaymentRestController(PaymentService paymentService) { this.paymentService = paymentService; } @PostMapping public ResponseEntity<PaymentResponse> processPayment( @RequestBody PaymentRequest request) { // REST: consumed by any HTTP client, browser, curl, Postman // No code generation required. Request/response in JSON. PaymentResult result = paymentService.process( request.getCustomerId(), request.getAmountPence(), request.getCurrency() ); return ResponseEntity.ok(new PaymentResponse( result.getPaymentId(), result.getStatus().name(), result.getTimestamp() )); } @GetMapping("/{paymentId}") public ResponseEntity<PaymentResponse> getPayment(@PathVariable String paymentId) { return paymentService.findById(paymentId) .map(p -> ResponseEntity.ok(new PaymentResponse(p))) .orElse(ResponseEntity.notFound().build()); } } // curl -X POST https://api.thecodeforge.io/v1/payments \ // -H 'Content-Type: application/json' \ // -d '{"customerId":"c-42","amountPence":10000,"currency":"GBP"}' // // Response: // {"paymentId":"pay-1234","status":"COMPLETED","timestamp":1711756800000}
Content-Type: application/json
{
"paymentId": "pay-1234567890",
"status": "COMPLETED",
"timestamp": 1711756800000
}
Streaming: The Capability REST Can't Match
gRPC's streaming support is its genuinely unique capability. REST over HTTP/1.1 is inherently request-response. Long polling, WebSockets, and Server-Sent Events are REST workarounds for streaming β they work but they're not native to the protocol.
gRPC has four communication patterns built into the protocol:
Unary: one request, one response. Same as REST.
Server streaming: one request, stream of responses. Useful for: real-time notifications, progress updates, large dataset pagination without polling.
Client streaming: stream of requests, one response. Useful for: bulk ingestion, file upload with progress tracking.
Bidirectional streaming: both sides stream simultaneously. Useful for: real-time chat, collaborative editing, live dashboards.
If your use case involves any of these patterns, gRPC's native streaming is significantly cleaner than working around REST's limitations.
package io.thecodeforge.payment.grpc; import io.grpc.stub.StreamObserver; import java.util.List; public class TransactionStreamingServiceImpl extends TransactionServiceGrpc.TransactionServiceImplBase { private final TransactionRepository transactionRepo; public TransactionStreamingServiceImpl(TransactionRepository repo) { this.transactionRepo = repo; } // Server streaming: client sends one filter, server streams matching transactions // Useful for: exporting large datasets without pagination loops @Override public void streamTransactions( TransactionFilter filter, StreamObserver<Transaction> responseObserver) { // Fetch and stream β client receives each transaction as it's sent // No need to buffer the entire result set in memory transactionRepo.findByCustomerIdAndDateRange( filter.getCustomerId(), filter.getFromTimestamp(), filter.getToTimestamp() ).forEach(tx -> { Transaction proto = Transaction.newBuilder() .setTransactionId(tx.getId()) .setAmountPence(tx.getAmountPence()) .setCurrency(tx.getCurrency()) .setTimestamp(tx.getTimestamp()) .build(); responseObserver.onNext(proto); // Each transaction sent immediately }); responseObserver.onCompleted(); } // Client streaming: client sends batch of payments, server responds once @Override public StreamObserver<PaymentRequest> batchPayments( StreamObserver<BatchResult> responseObserver) { return new StreamObserver<>() { int processed = 0; int failed = 0; @Override public void onNext(PaymentRequest request) { // Process each payment as it arrives from client try { processPayment(request); processed++; } catch (Exception e) { failed++; } } @Override public void onCompleted() { // Client done sending β send summary response responseObserver.onNext(BatchResult.newBuilder() .setProcessed(processed) .setFailed(failed) .build()); responseObserver.onCompleted(); } @Override public void onError(Throwable t) { // Handle client-side error } }; } }
// Received: Transaction{id='tx-001', amount=10000, currency='GBP'}
// Received: Transaction{id='tx-002', amount=25000, currency='GBP'}
// Received: Transaction{id='tx-003', amount=5500, currency='USD'}
// Stream completed. 3 transactions received.
// Batch payments output:
// BatchResult{processed=47, failed=3}
| Aspect | REST | gRPC |
|---|---|---|
| Protocol | HTTP/1.1 or HTTP/2 | HTTP/2 only |
| Serialisation | JSON (text) | Protocol Buffers (binary) |
| Contract | OpenAPI/Swagger (optional) | .proto file (required) |
| Code generation | Optional | Required (client + server stubs) |
| Browser support | Native | Requires grpc-web proxy |
| Streaming | Workarounds (SSE, WebSocket) | Native (4 patterns) |
| Performance | Baseline | ~2-5x faster for frequent calls |
| Tooling | Universal (Postman, curl, etc.) | Specialised (BloomRPC, grpcurl) |
| Best for | Public APIs, external consumers | Internal microservices, high-frequency calls |
| Error handling | HTTP status codes | gRPC status codes (richer) |
π― Key Takeaways
- REST over HTTP/JSON for external APIs, public-facing endpoints, and any consumer you don't control β ubiquity and tooling win.
- gRPC over protobuf/HTTP/2 for internal microservice communication, high-frequency calls, and when streaming is required.
- gRPC is 2-5x faster for frequent small-payload calls. For large payloads or infrequent calls, the difference is smaller.
- The .proto file is the source of truth for a gRPC API β it enforces a strict contract, enables code generation in any language, and prevents the schema drift common in JSON APIs.
- You don't have to choose: REST gateway for external traffic routing to internal gRPC services is a standard production pattern.
β Common Mistakes to Avoid
- βUsing gRPC for a public API without a REST/JSON facade β external developers cannot easily consume gRPC without generated clients. Always provide a REST gateway for external consumers.
- βChoosing REST for high-frequency internal service calls because 'it's simpler' β at 10,000+ RPCs per minute, the serialisation overhead of JSON vs protobuf and HTTP/1.1 connection overhead becomes a real cost.
- βNot defining .proto contracts upfront for gRPC β the schema is the contract. Changing field types or removing fields without versioning breaks all clients. Use field numbers carefully and never reuse them.
- βIgnoring gRPC's built-in deadline/timeout propagation β gRPC propagates deadlines across service chains automatically. REST requires explicit timeout header forwarding. Missing this leads to cascading timeouts in microservice chains.
Interview Questions on This Topic
- QWhat are the main differences between gRPC and REST? When would you choose each?
- QExplain how Protocol Buffers differ from JSON and what performance implications that has.
- QWhat are the four communication patterns in gRPC and when would you use each?
- QYou're designing a microservices architecture with 8 internal services and a public API. How would you decide which services use gRPC vs REST?
- QWhat is gRPC-Gateway and why would you use it?
Frequently Asked Questions
When should I use gRPC instead of REST?
Use gRPC for internal microservice-to-microservice communication where performance matters, when you need native streaming (server push, client streaming, bidirectional), or when strong schema contracts and code generation are important. Use REST for public APIs, external consumers, browser-facing services, or any situation where you can't control client code generation.
Is gRPC faster than REST?
Yes, typically 2-5x faster for frequent small-payload calls. The advantages come from Protocol Buffers (binary serialisation, smaller payloads than JSON) and HTTP/2 (multiplexing, header compression, no head-of-line blocking). For infrequent calls or large payloads, the performance gap narrows. The difference matters most at high request volumes β 10,000+ RPCs per minute.
Can gRPC and REST coexist in the same system?
Yes, and this is a common production pattern. A REST/JSON API gateway handles external traffic and translates to internal gRPC calls. External consumers get REST ergonomics and universal tooling. Internal services get gRPC performance and streaming. gRPC-Gateway and Envoy proxy both support this pattern out of the box.
Does gRPC work in browsers?
Not natively. Browsers cannot make HTTP/2 requests with the required trailers that gRPC uses. The solution is grpc-web, a modified protocol with a JavaScript client library, combined with an Envoy or Nginx proxy that translates grpc-web to grpc on the server side. For browser-facing APIs, REST remains the simpler choice.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.