> ## Content Index
> Fetch the complete content index at: https://stalin-pereira.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# When to Choose gRPC Over REST: A Performance-First Approach
- URL: https://stalin-pereira.com/when-to-choose-grpc-over-rest-a-performance-first-approach/
- Published: 2025-11-29T10:31:00.000Z
- Updated: 2026-10-16T19:23:22.000Z
- Description: Where gRPC's speed actually comes from (HTTP/2, Protocol Buffers, streaming), what a typed contract buys you, and the costs tutorials skip: load balancing, debugging, browser support, and schema evolution. Ends with a clear guide on when to choose gRPC and when REST is better.
- Author: Stalin Pereira
- Tags: Backend

In a microservice architecture, a single user request can fan out into a dozen internal calls. Each one pays for connection handling, serialization, parsing, and validation. Individually those costs look trivial. Multiplied across every hop and every request, they show up as tail latency your users feel and CPU your cloud bill reflects.

REST with JSON is the default for internal traffic mostly because it is the default for everything else. It is a great choice for public APIs, but it was never designed for chatty, high-volume service-to-service communication. gRPC was. It combines HTTP/2 transport, a compact binary encoding, native streaming, and code generated from a strict contract.

This article explains where gRPC's performance advantage actually comes from, shows what a contract and service look like in practice, and covers the operational costs that tutorials tend to skip: load balancing, debugging, browser support, and schema evolution. It ends with a short decision guide, because gRPC is the right call for some internal APIs and the wrong one for others.

## Where the performance actually comes from

gRPC is not fast because of one trick. It is fast because several design choices remove overhead that REST stacks typically carry by default.

### HTTP/2 multiplexing and header compression

gRPC runs on HTTP/2\. A client opens one long-lived TCP connection to a server and multiplexes many concurrent calls over it as independent streams. With HTTP/1.1, a connection carries one request at a time, so clients either wait or open a pool of connections, each with its own TCP and TLS handshake.

HTTP/2 also compresses headers with HPACK. Internal calls tend to repeat the same headers on every request (authorization, tracing IDs, content type), and HPACK avoids resending them in full each time.

To be fair to REST: nothing stops a JSON API from running over HTTP/2, and many modern servers and proxies support it. The difference is that gRPC requires HTTP/2 and is built around it, while most REST clients and internal tooling still default to HTTP/1.1 unless you configure otherwise. One correction to a claim you will see repeated: gRPC does not use HTTP/2 server push. Its server-to-client streaming is built on ordinary HTTP/2 streams.

### Protocol Buffers instead of JSON

gRPC messages are encoded with [Protocol Buffers](https://protobuf.dev/?ref=stalin-pereira.com). Field names are not sent on the wire; each field is identified by a small integer tag. Integers use variable-length encoding, and there is no whitespace, quoting, or escaping. The result is usually smaller than equivalent JSON, and decoding it does not involve tokenizing text or converting number strings.

How much smaller and faster depends on the payload. Messages dominated by numbers, enums, and repeated fields shrink a lot. Messages that are mostly long strings or opaque blobs shrink very little, because the strings are the same bytes in either format. If you want a real number for your system, benchmark your own messages rather than trusting anyone's headline figure, including this article's.

The CPU side matters as much as the bytes. JSON parsing is a meaningful share of request cost in many services, and it often happens twice per hop: once to decode, once to validate into a typed object. Protobuf decoding goes straight into generated types.

### Streaming as a first-class feature

gRPC supports four call types: unary (one request, one response), server streaming, client streaming, and bidirectional streaming. Streams are defined in the contract and exposed as idiomatic iterators or async streams in each language.

That makes patterns like "push price updates to this subscriber," "upload telemetry in batches over one call," or "stream rows from a large query as they are produced" straightforward. With REST, the same flows usually mean polling, chunked responses, Server-Sent Events, or a separate WebSocket protocol with its own framing and error handling.

## The contract is the real feature

Performance gets the headlines, but many teams adopt gRPC for the contract. You define the service once in a `.proto` file and generate typed clients and servers in every language you use.

```protobuf
syntax = "proto3";

package orders.v1;

import "google/protobuf/timestamp.proto";

service OrderService {
  // Unary: fetch one order
  rpc GetOrder(GetOrderRequest) returns (Order);

  // Server streaming: push status changes as they happen
  rpc WatchOrder(WatchOrderRequest) returns (stream OrderEvent);
}

message GetOrderRequest {
  string order_id = 1;
}

message WatchOrderRequest {
  string order_id = 1;
}

enum OrderStatus {
  ORDER_STATUS_UNSPECIFIED = 0;
  ORDER_STATUS_PENDING = 1;
  ORDER_STATUS_SHIPPED = 2;
  ORDER_STATUS_DELIVERED = 3;
}

message Order {
  string order_id = 1;
  string customer_id = 2;
  OrderStatus status = 3;
  int64 total_cents = 4;
  google.protobuf.Timestamp created_at = 5;
}

message OrderEvent {
  OrderStatus status = 1;
  google.protobuf.Timestamp at = 2;
}
```

Generate Python code with `grpcio-tools`:

```bash
pip install grpcio grpcio-tools
python -m grpc_tools.protoc -I. \
  --python_out=. --pyi_out=. --grpc_python_out=. \
  orders/v1/orders.proto
```

The server implements the generated base class. A server-streaming method is just a generator:

```python
from concurrent import futures
import time

import grpc
from orders.v1 import orders_pb2, orders_pb2_grpc

ORDERS = {
    "o-123": orders_pb2.Order(
        order_id="o-123",
        customer_id="c-42",
        status=orders_pb2.ORDER_STATUS_PENDING,
        total_cents=4599,
    )
}

class OrderService(orders_pb2_grpc.OrderServiceServicer):
    def GetOrder(self, request, context):
        order = ORDERS.get(request.order_id)
        if order is None:
            context.abort(grpc.StatusCode.NOT_FOUND, f"order {request.order_id} not found")
        return order

    def WatchOrder(self, request, context):
        for status in (orders_pb2.ORDER_STATUS_SHIPPED, orders_pb2.ORDER_STATUS_DELIVERED):
            if not context.is_active():  # client cancelled or deadline passed
                return
            time.sleep(1)
            event = orders_pb2.OrderEvent(status=status)
            event.at.GetCurrentTime()
            yield event

def serve() -> None:
    server = grpc.server(futures.ThreadPoolExecutor(max_workers=16))
    orders_pb2_grpc.add_OrderServiceServicer_to_server(OrderService(), server)
    server.add_insecure_port("[::]:50051")
    server.start()
    server.wait_for_termination()

if __name__ == "__main__":
    serve()
```

And the client gets a typed stub. Note the `timeout`, which becomes a deadline that propagates to the server:

```python
import grpc
from orders.v1 import orders_pb2, orders_pb2_grpc

with grpc.insecure_channel("localhost:50051") as channel:
    stub = orders_pb2_grpc.OrderServiceStub(channel)

    try:
        order = stub.GetOrder(orders_pb2.GetOrderRequest(order_id="o-123"), timeout=0.5)
        print(order.status, order.total_cents)
    except grpc.RpcError as err:
        print(err.code(), err.details())

    for event in stub.WatchOrder(orders_pb2.WatchOrderRequest(order_id="o-123"), timeout=10):
        print("status changed:", orders_pb2.OrderStatus.Name(event.status))
```

Compare that to the typical internal REST client: a hand-written HTTP call, a URL assembled from strings, a JSON body that may or may not match what the server expects, and a response parsed into a dictionary. A field rename in the server breaks the client at runtime. With gRPC, the same rename shows up when the client regenerates its stubs and fails to compile or type-check.

### Schema evolution has rules

The contract only protects you if you evolve it correctly. Protobuf is designed for backward and forward compatibility, but only within its [rules for updating messages](https://protobuf.dev/programming-guides/proto3/?ref=stalin-pereira.com#updating):

- Never change or reuse a field number. Field numbers are the wire identity of a field; names are not sent at all.
- When you delete a field, mark its number and name as `reserved` so no one reuses them.
- Adding fields is safe. Old clients ignore fields they do not know about, and new code sees default values from old senders.
- Put the version in the package (`orders.v1`) so a genuinely breaking change can live side by side as `orders.v2`.

Tools like [Buf's breaking-change detection](https://buf.build/docs/breaking/?ref=stalin-pereira.com) can enforce these rules in CI so a careless edit never reaches production.

## What gRPC costs you

The performance and contract benefits are real. So are the operational costs, and they are the reason "switch everything to gRPC" is bad advice.

### Load balancing needs to be request-aware

This is the problem that catches most teams. Because a gRPC client keeps one long-lived HTTP/2 connection and multiplexes every call over it, a connection-level (L4) load balancer assigns that client to a single backend and keeps it there. Scale your service from three pods to ten and the existing clients may keep hammering the original three.

The fix is per-request balancing: an L7 proxy or service mesh that understands HTTP/2 (Envoy, Linkerd, Istio), or client-side load balancing using gRPC's built-in resolver and `round_robin` policy against a headless service. The Kubernetes blog's [post on gRPC load balancing](https://kubernetes.io/blog/2018/11/07/grpc-load-balancing-on-kubernetes-without-tears/?ref=stalin-pereira.com) explains the problem clearly. Plan for this before your first gRPC service goes to production, not after.

### Debugging and tooling

You cannot `curl` a gRPC endpoint and read the response. Binary payloads are opaque in logs and packet captures. Tools like [grpcurl](https://github.com/fullstorydev/grpcurl?ref=stalin-pereira.com), Postman, and Insomnia close most of the gap, especially if you enable [server reflection](https://grpc.io/docs/guides/reflection/?ref=stalin-pereira.com), but your team still needs to learn and install them.

```bash
grpcurl -plaintext -d '{"order_id": "o-123"}' \
  localhost:50051 orders.v1.OrderService/GetOrder
```

### Browsers are second-class clients

Browsers do not expose the HTTP/2 framing gRPC needs. Calling gRPC from a web app requires [gRPC-Web](https://github.com/grpc/grpc-web?ref=stalin-pereira.com) with a translating proxy, or the [Connect](https://connectrpc.com/?ref=stalin-pereira.com) protocol, which serves gRPC, gRPC-Web, and plain HTTP/JSON from the same handlers. Either works, but it is an extra moving part a REST API does not have.

### Deadlines and errors need discipline

gRPC gives you [deadlines](https://grpc.io/docs/guides/deadlines/?ref=stalin-pereira.com) and a fixed set of [status codes](https://grpc.io/docs/guides/status-codes/?ref=stalin-pereira.com), which is a real improvement over the inconsistent error bodies of most internal REST APIs. But the defaults are permissive: many gRPC clients set no deadline unless you pass one, so a slow dependency can hold calls open indefinitely. Set a deadline on every call, propagate it downstream, and decide as a team which status codes are retryable.

## Is REST really the bottleneck?

Before migrating, measure. In many services, latency is dominated by database queries, downstream calls made in sequence instead of parallel, or missing caches. Swapping the wire protocol does nothing for any of those.

gRPC pays off most clearly when you have high call volume between services, payloads that are structured rather than blob-like, deep call chains where per-hop overhead compounds, or a real need for streaming. If your profiler shows meaningful time in JSON encoding and decoding, or your connection pools are a recurring source of trouble, that is a strong signal. If neither is true, the contract and tooling benefits may still justify gRPC, but call it what it is: a developer-experience decision, not a performance one.

You also do not have to choose one protocol for everything. A common split is gRPC between internal services and REST or GraphQL at the edge, with a gateway or Connect translating between them.

## When to choose gRPC, and when not to

**Choose gRPC when:**

- The traffic is internal, service-to-service, and high volume.
- Both sides are services you control and can regenerate stubs for.
- You need server, client, or bidirectional streaming.
- You work in several languages and want one contract that generates typed clients for all of them.
- Profiling shows serialization or connection overhead is a real cost.

**Stay with REST when:**

- The API is public or consumed by third parties who expect JSON over HTTP.
- The main client is a browser and you do not want a proxy or Connect layer.
- You rely on HTTP caching through CDNs or intermediaries.
- Call volume is low and the team values `curl`\-level debuggability over efficiency.
- Your infrastructure only offers connection-level load balancing and you cannot change that yet.

**Before your first gRPC service ships:**

1. Put the `.proto` files in a shared repository with breaking-change checks in CI.
2. Choose your load-balancing approach: L7 proxy, service mesh, or client-side.
3. Require a deadline on every call and agree on retry policy per status code.
4. Enable server reflection in non-production environments and standardize on a CLI client.
5. Instrument with OpenTelemetry so traces cross gRPC hops as easily as REST ones.

For internal traffic where the volume is high and the contracts matter, gRPC is often the better default. It is not a free upgrade. You get speed and type safety, and in return you take on load balancing, tooling, and schema discipline. Decide whether that trade is worth it before you migrate.