Homeβ€Ί System Designβ€Ί gRPC vs REST: When to Use Each in Modern APIs

gRPC vs REST: When to Use Each in Modern APIs

Where developers are forged. Β· Structured learning Β· Free forever.
πŸ“ Part of: Components β†’ Topic 17 of 17
Understand the real differences between gRPC and REST APIs: protocol, serialization, streaming, performance, and when to choose each for microservices, mobile clients, and public APIs.
βš™οΈ Intermediate β€” basic System Design knowledge assumed
In this tutorial, you'll learn:
  • 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.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
⚑ Quick Answer
REST uses HTTP and JSON β€” the universal language of the web. Any browser, curl command, or HTTP client speaks it natively. gRPC uses Protocol Buffers and HTTP/2 β€” a binary format that's faster and more efficient but requires a code-generated client. REST is the front door everyone can use. gRPC is the dedicated freight entrance for services that need to move a lot of data fast.

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.

PaymentServiceGrpc.java Β· JAVA
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
// ── 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());
    }
}
β–Ά Output
Payment ID: pay-1234567890123
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.

PaymentRestController.java Β· JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
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}
β–Ά Output
HTTP/1.1 200 OK
Content-Type: application/json

{
"paymentId": "pay-1234567890",
"status": "COMPLETED",
"timestamp": 1711756800000
}
πŸ”₯
You can use both in the same systemA common production pattern: REST-facing API gateway that external consumers call, internally routing to gRPC microservices. The gateway translates HTTP/JSON to protobuf. External consumers get REST ergonomics. Internal services get gRPC performance. Tools like gRPC-Gateway or Envoy proxy make this straightforward. We run this exact architecture on a payments platform handling Β£2M daily volume.

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.

TransactionStreamingService.java Β· JAVA
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
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
            }
        };
    }
}
β–Ά Output
// Server streaming output (client side):
// 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}
AspectRESTgRPC
ProtocolHTTP/1.1 or HTTP/2HTTP/2 only
SerialisationJSON (text)Protocol Buffers (binary)
ContractOpenAPI/Swagger (optional).proto file (required)
Code generationOptionalRequired (client + server stubs)
Browser supportNativeRequires grpc-web proxy
StreamingWorkarounds (SSE, WebSocket)Native (4 patterns)
PerformanceBaseline~2-5x faster for frequent calls
ToolingUniversal (Postman, curl, etc.)Specialised (BloomRPC, grpcurl)
Best forPublic APIs, external consumersInternal microservices, high-frequency calls
Error handlingHTTP status codesgRPC 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.

πŸ”₯
Naren Founder & Author

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.

← PreviousWhat is a Browser Cache? How It Works and When It Breaks Things
Forged with πŸ”₯ at TheCodeForge.io β€” Where Developers Are Forged