Skip to content
NexiferLabs
All services
Solutions overview
Browse the library
About Nexifer
grpcapi-designprotobufbackendarchitecture

gRPC in practice: fast internal APIs without creating a debugging nightmare

Protocol Buffers, streaming, service contracts, browser limits, observability and versioning - what gRPC costs, and when plain HTTP APIs are enough.

T

team

17 min read
A stylised illustration of a gRPC service, with a client and server connected by a stream of binary data. The client is represented as a computer, and the server is represented as a cloud. The stream of data is represented as a series of arrows, with the arrows pointing from the client to the server.

gRPC's advantages are easy to demonstrate and easy to sell: binary encoding, HTTP/2 multiplexing, generated clients in every language, real streaming. The costs arrive later and in a less quotable form - at three in the morning, when a service returns INTERNAL with no message, your packet capture is unreadable binary, and the load balancer you configured months ago has been sending every request to one pod.

None of that is an argument against gRPC. It is an argument for going in with the operational bill itemised. This covers Protocol Buffers and the rules that keep a schema evolvable, the four streaming modes and when each is right, contract tooling, the observability you need before you ship, the browser situation as it actually stands, and the cases where plain HTTP is the better answer.

What gRPC actually is

Three things bolted together: Protocol Buffers as the serialisation format and interface definition language, HTTP/2 as the transport, and a code generator that turns a .proto file into a typed client and server stub in your language.

The mental model is a function call, not a resource. You do not GET /orders/42; you call OrderService.GetOrder(GetOrderRequest{id: 42}) and get back a typed GetOrderResponse. That framing is the source of both its ergonomics and its opacity - a function call has no URL, no readable body, and nothing a browser can show you.

proto/shop/orders/v1/orders.proto
syntax = "proto3";

package shop.orders.v1;

option go_package = "github.com/acme/shop/gen/orders/v1;ordersv1";

service OrderService {
  rpc GetOrder(GetOrderRequest) returns (Order);
  rpc ListOrders(ListOrdersRequest) returns (ListOrdersResponse);
  rpc CreateOrder(CreateOrderRequest) returns (Order);
  rpc WatchOrders(WatchOrdersRequest) returns (stream OrderEvent);
}

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

enum OrderStatus {
  ORDER_STATUS_UNSPECIFIED = 0;
  ORDER_STATUS_PENDING = 1;
  ORDER_STATUS_PAID = 2;
  ORDER_STATUS_SHIPPED = 3;
}
The version lives in the package path, not a header. shop.orders.v1 and shop.orders.v2 are different namespaces that can run side by side in one binary.

The four call types

TypeSignatureUse it for
Unaryrpc Get(Req) returns (Resp)Ordinary request/response - the large majority of RPCs
Server streamingreturns (stream Resp)Feeds, tailing logs, large result sets, progress updates
Client streaming(stream Req) returns (Resp)Uploads, batch ingestion, metric reporting
Bidirectional(stream Req) returns (stream Resp)Chat, live collaboration, long-lived control channels
All four run over one HTTP/2 connection, multiplexed. That is the part REST cannot match without you building it.

Protocol Buffers

Protobuf is the part that pays off and the part that bites. It is a binary format where field names do not appear on the wire - only field numbers, types and values. That is why messages are small and parsing is fast, and also why a captured packet means nothing without the schema that produced it.

Field numbers are the contract

protobuf
message Order {
  string id = 1;            // 1 through 15 use a single byte for the tag
  string customer_id = 2;   // use them for your most frequent fields
  int64 total_cents = 4;

  reserved 3;               // was `status_code`, removed in v1.4
  reserved "status_code";   // stop anyone reusing the name either
}
reserved is not optional hygiene. Reuse a retired field number and an old client will decode new data into the old field's type - silent corruption rather than an error.
SafeBreaking
Adding a new field with a new numberChanging a field's number
Removing a field, if you reserve the numberRemoving a field without reserving
Renaming a field (names are not on the wire)Changing a field's type, with narrow exceptions
Adding a value to an enumRemoving or renumbering an enum value
Adding a new RPC to a serviceRemoving or renaming an RPC
int32int64bool (compatible varints)int32string, or optionalrepeated
Renaming being safe and renumbering being fatal is the inverse of most people's intuition from JSON, where the name is the contract.

The zero-value problem

In proto3, scalar fields have no explicit presence by default. A field set to 0, false or "" is indistinguishable from a field never set at all - both are absent from the wire and both decode to the zero value.

protobuf
message UpdateOrderRequest {
  string id = 1;
  int64 total_cents = 2;              // 0 means "unset" OR "set it to zero"
  optional int64 discount_cents = 3;  // `optional` restores explicit presence
  google.protobuf.FieldMask update_mask = 4;  // or say which fields you meant
}
This matters most for partial updates. Without presence tracking, a client cannot express "set the discount to zero" and your server cannot tell the difference from "leave it alone".

Editions, and why you can probably ignore them

Protobuf Editions replace the syntax = "proto2" / syntax = "proto3" declarations with edition = "2024". Instead of two hardcoded behaviour sets, each behaviour - field presence, enum openness, repeated encoding - becomes a feature you can configure at file, message or field level.

protobuf
edition = "2024";

package shop.orders.v1;

message Order {
  // field_presence defaults to EXPLICIT in editions,
  // which is proto2 behaviour rather than proto3's
  string id = 1;
  int64 total_cents = 2 [features.field_presence = IMPLICIT];
}

Service contracts and the tooling that enforces them

The .proto file is the contract, and unlike an OpenAPI document it is not a description of the implementation - it generates it. That is a real advantage: drift between spec and code is structurally impossible.

What is not automatic is preventing a breaking change from reaching a consumer. buf is the standard toolchain for this, and the breaking-change check is the part worth adopting even if you use nothing else.

buf.yaml
version: v2
modules:
  - path: proto
lint:
  use:
    - STANDARD
breaking:
  use:
    - FILE
  except:
    - FIELD_SAME_DEFAULT
bash
buf lint                              # naming, package structure, style
buf breaking --against '.git#branch=main'   # fails on an incompatible change
buf generate                          # all languages, one config
buf format -w

# Publish to a registry so consumers depend on a version, not a copied file
buf push
buf breaking in CI is the single highest-value item in this article. It converts "we renumbered a field" from a production data-corruption incident into a failed pull request.

Conventions that pay off

  • Version in the package path - shop.orders.v1. It costs nothing on day one and is the only mechanism that lets v1 and v2 coexist in one process.
  • A request and response message per RPC, always, even when one field would do. GetOrderRequest can grow; a bare string cannot.
  • Enum zero value is UNSPECIFIED. Proto3 enums default to zero, so making zero a meaningful value means you can never distinguish unset from that value.
  • Prefix enum values with the enum name. Protobuf enum values share a C++-style namespace with their parent scope, so PENDING in two enums in one package collides.
  • Distribute schemas through a registry, not by copying files between repositories. A copied .proto drifts, and nothing detects it.
  • Use the well-known types - Timestamp, Duration, FieldMask, Struct - rather than inventing your own.

Streaming, and when it is worth it

Streaming is gRPC's clearest advantage over request/response HTTP, and the feature most often adopted for the wrong reason. A stream is a long-lived logical channel over an HTTP/2 stream, with backpressure and flow control handled for you.

go
// Server streaming: push events as they happen
func (s *Server) WatchOrders(
    req *ordersv1.WatchOrdersRequest,
    stream ordersv1.OrderService_WatchOrdersServer,
) error {
    events, cleanup := s.bus.Subscribe(req.GetCustomerId())
    defer cleanup()

    for {
        select {
        case <-stream.Context().Done():
            // Client went away, or the deadline expired. Stop immediately.
            return stream.Context().Err()

        case ev := <-events:
            if err := stream.Send(ev); err != nil {
                return err
            }
        }
    }
}
Always select on stream.Context().Done(). A stream handler that only watches its data source leaks a goroutine for every client that disconnects uncleanly - which is all of them, eventually.
SituationStreaming or unary?
Result set larger than comfortable memoryServer streaming - the consumer processes as it arrives
Live events pushed to a serviceServer streaming, or a message broker if durability matters
Large file or batch uploadClient streaming, chunked
A few hundred rowsUnary. Streaming adds complexity for no gain
Work that must survive a restartNeither - that is a queue, not a stream
Request/response that happens to be slowUnary with a longer deadline

Long-lived streams also interact badly with infrastructure that assumes short requests. Load balancer idle timeouts, proxy read timeouts and Kubernetes rolling restarts will all sever streams. Design for reconnection from the start rather than discovering it during a deployment.

Errors, deadlines and retries

Status codes

gRPC has its own status code set, deliberately smaller than HTTP's. The mapping to HTTP status codes is lossy in both directions, which matters when you put a gateway in front.

CodeMeaningRetry?
OKSuccess-
INVALID_ARGUMENTClient sent something wrong. Fix and resendNo
NOT_FOUNDNo such entityNo
ALREADY_EXISTSConflict on a unique constraintNo
PERMISSION_DENIEDAuthenticated but not allowedNo
UNAUTHENTICATEDMissing or invalid credentialsNo
RESOURCE_EXHAUSTEDRate limited or out of quotaYes, with backoff
FAILED_PRECONDITIONSystem state is wrong. Do not retry blindlyNo
ABORTEDConcurrency conflict - transaction abortYes, at a higher level
UNAVAILABLETransient. The canonical retryable codeYes
DEADLINE_EXCEEDEDRan out of timeSometimes - it may have succeeded
INTERNALA bug on the server sideNo
go
import (
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
    epb "google.golang.org/genproto/googleapis/rpc/errdetails"
)

func notEnoughStock(sku string, want, have int32) error {
    st := status.New(codes.FailedPrecondition, "insufficient stock")

    detailed, err := st.WithDetails(&epb.ErrorInfo{
        Reason: "INSUFFICIENT_STOCK",
        Domain: "shop.acme.com",
        Metadata: map[string]string{
            "sku":       sku,
            "requested": fmt.Sprint(want),
            "available": fmt.Sprint(have),
        },
    })
    if err != nil {
        return st.Err()   // detail encoding failed; the base status still works
    }
    return detailed.Err()
}
ErrorInfo.Reason is the stable machine-readable identifier - the equivalent of the type URI in an RFC 9457 problem document. Clients branch on it; the human message stays free to change.

Deadlines propagate, and that is the point

go
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()

order, err := client.GetOrder(ctx, &ordersv1.GetOrderRequest{Id: id})
The remaining deadline travels with the call. A downstream service five hops away knows how long the original caller is still prepared to wait, and can stop work nobody is listening for.
  • Always set a deadline. A client without one waits forever, and one stuck call holds a connection, a goroutine and a database handle indefinitely.
  • Set it at the edge and let it propagate, rather than picking an arbitrary timeout per hop.
  • Check the deadline server-side before starting expensive work. If less than the operation needs remains, fail fast rather than doing work that will be discarded.
  • DEADLINE_EXCEEDED does not mean it did not happen. The write may have succeeded after you stopped listening - which is why idempotency keys matter here exactly as they do in REST.

Retries belong in configuration

json
{
  "methodConfig": [{
    "name": [{ "service": "shop.orders.v1.OrderService" }],
    "retryPolicy": {
      "maxAttempts": 4,
      "initialBackoff": "0.1s",
      "maxBackoff": "2s",
      "backoffMultiplier": 2,
      "retryableStatusCodes": ["UNAVAILABLE", "RESOURCE_EXHAUSTED"]
    }
  }]
}
A gRPC service config, applied by the client channel. Retrying only UNAVAILABLE and RESOURCE_EXHAUSTED is deliberate - anything else risks duplicating a write.

The debugging problem, and what fixes it

This is the section that earns the title. gRPC's opacity is not a minor inconvenience - it invalidates a set of habits every engineer has, and you have to replace them deliberately.

Habit that stops workingWhat replaces it
curl the endpointgrpcurl, which needs reflection enabled or the .proto files
Read the response body in a logStructured logging of decoded messages, written by you
Eyeball a packet captureWireshark with the schema loaded, or nothing
Filter proxy logs by URL pathPaths are /package.Service/Method - usable, but not in most dashboards by default
Group metrics by HTTP statusgRPC status codes, which your APM may not break out by default
Browser dev toolsNothing. There is no equivalent

Turn on reflection, at least outside production

bash
# With reflection enabled, grpcurl needs nothing else
grpcurl -plaintext localhost:50051 list
grpcurl -plaintext localhost:50051 list shop.orders.v1.OrderService
grpcurl -plaintext localhost:50051 describe shop.orders.v1.Order

grpcurl -plaintext \
  -d '{"id": "ord_42"}' \
  localhost:50051 shop.orders.v1.OrderService/GetOrder

# Without reflection, point it at the schema
grpcurl -import-path ./proto -proto shop/orders/v1/orders.proto \
  -d '{"id": "ord_42"}' api.internal:443 shop.orders.v1.OrderService/GetOrder
Server reflection lets a client discover the schema at runtime. Enable it in development and staging without hesitation; in production, weigh the schema disclosure against how much you will want it during an incident.

What to instrument before you ship

  • OpenTelemetry interceptors on both client and server. gRPC metadata carries trace context cleanly, so distributed tracing works well - but only if you wire it up. A gRPC call graph without tracing is genuinely hard to reason about.
  • Metrics broken out by method and status code, not aggregated per service. OrderService being healthy tells you nothing if CreateOrder is failing.
  • Log the method, status code, deadline remaining and a correlation ID on every call. Put the correlation ID in metadata so it propagates.
  • Health checking via the standard grpc.health.v1.Health service, so your orchestrator probes the same path as real traffic.
  • channelz for connection-level state - which subchannels exist, which are ready, what the load balancer is actually doing. It answers questions nothing else can.
  • Payload logging behind a flag, sampled, with sensitive fields redacted. When you need to see a message body, you need it immediately.
go
import "google.golang.org/grpc/stats/opentelemetry"

server := grpc.NewServer(
    opentelemetry.ServerOption(opentelemetry.Options{
        MetricsOptions: opentelemetry.MetricsOptions{MeterProvider: mp},
    }),
)

// Reflection and health, both worth having from day one
reflection.Register(server)
healthpb.RegisterHealthServer(server, health.NewServer())

The load balancing trap

This one deserves its own section because it silently produces the exact opposite of what you configured, and teams often run in that state for months.

gRPC uses long-lived HTTP/2 connections and multiplexes many calls over each one. A layer-4 load balancer balances connections, not requests. So a client that opens one connection sends every subsequent RPC to whichever backend that connection landed on - while your dashboard shows a load balancer distributing traffic evenly across connections that no longer reflect load.

ApproachHow it worksNotes
L7 proxyEnvoy, Linkerd, NGINX with gRPC support - balances individual streamsThe standard answer; also gives you retries, mTLS and telemetry
Client-side load balancingClient resolves all backends and picks per callLowest latency, no extra hop; the client must track membership
Service meshA sidecar does L7 balancing transparentlyCorrect by default; a large operational commitment
Periodic reconnectionMAX_CONNECTION_AGE forces clients to redialA mitigation, not a fix - worth setting regardless
L4 load balancer aloneBalances connectionsEffectively pins traffic. Do not ship this
go
server := grpc.NewServer(
    grpc.KeepaliveParams(keepalive.ServerParameters{
        MaxConnectionAge:      30 * time.Minute,
        MaxConnectionAgeGrace: 5 * time.Minute,
        Time:                  30 * time.Second,
        Timeout:               10 * time.Second,
    }),
)
MaxConnectionAge sends a graceful GOAWAY so clients redial, which redistributes them. The grace period lets in-flight calls finish. This helps even with an L7 proxy, because it also rebalances after a scale-up.

The browser situation

Browsers cannot speak gRPC. Not for lack of trying - the fetch API and XHR do not expose the control over HTTP/2 frames that the protocol requires. Three workarounds exist, and their trade-offs are different enough to matter.

OptionHow it worksCost
gRPC-WebA modified protocol plus a proxy (Envoy or similar) that translates to real gRPCA proxy in the path; no client streaming, no bidirectional streaming
ConnectAn HTTP-idiomatic protocol from Buf; servers speak Connect, gRPC and gRPC-Web at onceA different protocol, though wire-compatible with gRPC; no proxy needed
A REST or GraphQL gatewayTranslate at the edge; browsers never see gRPCA layer to write and maintain, and a second contract

Connect is the most interesting of the three. A Connect server responds to all three protocols and the client selects via Content-Type, so Node-to-Node services can use native gRPC while the browser talks Connect against the same .proto contract and the same server. Unary Connect calls are notably plain - no length-prefix framing, the serialised message is simply the HTTP body - which is exactly why they work with ordinary HTTP tooling.

The practical shape most teams land on: gRPC between internal services, and a REST, GraphQL or Connect layer at the edge for browsers. That is not a compromise so much as correct placement - the edge has different requirements from the interior.

Versioning and evolution

Protobuf's compatibility rules do most of the work, which means the majority of changes need no version bump at all. Add fields freely; old clients ignore what they do not know about, and new servers see zero values for what old clients omit.

  • Add, never modify. New field, new number. Deprecate the old one with [deprecated = true] and remove it in a later major version, reserving the number.
  • Version the package, not the service. shop.orders.v1 and shop.orders.v2 are distinct namespaces, so one binary can serve both during a migration.
  • Run v1 and v2 together. Register both implementations, have v1 delegate to v2 where possible, and retire v1 when usage analytics say it is safe.
  • Track per-method usage by client. Same requirement as GraphQL field analytics - without it, deprecation never leads to removal.
  • Never reuse a field number. Ever. reserved exists precisely so the compiler enforces this after everyone has forgotten why.
  • Treat enums as open. A client may receive a value it was not compiled with. Handle the unknown case explicitly rather than assuming exhaustiveness.

When plain HTTP is enough

gRPC is a strong default between internal services in a polyglot estate. It is a poor default almost everywhere else, and the reasons are practical rather than philosophical.

  • Public or partner APIs. Third parties want curl, a browser, Postman and a README. Handing them a .proto file and a code generation step is a real adoption cost with no benefit to them.
  • Browser-facing endpoints. You need a proxy, a translation layer or Connect. If that is the only reason you would run a proxy, use HTTP.
  • A handful of services, one language. The generated-client advantage is largest across language boundaries. Within one language, a typed HTTP client gets you most of it for far less machinery.
  • Cache-heavy reads. HTTP caching at the CDN is free and effective. gRPC has no equivalent, and rebuilding it is not worth doing.
  • File uploads and downloads. Chunked client streaming works and is more code than a signed URL and a PUT.
  • Webhooks and callbacks. The receiver is someone else's system. It wants JSON over HTTP.
  • Low call volume. If you make forty inter-service calls a second, the serialisation and transport savings are noise against the operational cost.
  • No capacity for the tooling. gRPC without buf breaking, tracing, L7 balancing and structured errors is worse than REST, not better.

The honest summary of the performance argument: binary encoding and HTTP/2 multiplexing produce real savings in CPU and bytes, and those savings scale with call volume. Published comparisons vary enormously depending on payload size, language runtime and whether the REST baseline uses HTTP/2 and connection pooling. Measure your own workload before treating any specific multiplier as a reason to migrate.

Mistakes that keep recurring

MistakeConsequence
Reusing a retired field numberOld clients decode new data into the wrong field - silent corruption
No buf breaking in CIThe above reaches production before anyone notices
L4 load balancing in front of gRPCAll traffic pinned to one backend while the dashboard looks fine
No deadlines on client callsStuck calls hold connections and goroutines indefinitely
Returning bare INTERNALCallers cannot distinguish a bug from a transient fault
Retrying non-idempotent methodsDuplicate writes, most visibly duplicate payments
Streaming without watching the contextA leaked goroutine for every disconnected client
Enum zero value with meaningYou can never distinguish unset from that value
Copying .proto files between repositoriesSilent drift with nothing to detect it
No reflection anywhere, including stagingEvery debugging session starts by locating the right schema version
Treating a stream as a durable queueMessages lost on every reconnect
Adopting gRPC for two services in one languageAll of the machinery, none of the benefit

Verdict

gRPC is the right choice for internal service-to-service communication at meaningful volume, especially across languages. The contract is generated rather than described, so drift is impossible. Deadlines propagate through the call graph. Streaming is first-class instead of bolted on. Those are real engineering advantages and they compound as the estate grows.

The bill is paid in observability. Every debugging habit that relies on reading text off the wire stops working, and you replace it with tracing, structured logging, reflection and tooling you have to set up before you need it - not during the incident that made you want it.

Put buf breaking in CI, deadlines on every call, an L7 proxy in front, and OpenTelemetry on both sides. Those four things are the difference between gRPC being fast and gRPC being a debugging nightmare.

Our checklist before any service ships on gRPC

And keep it inside. Internal services speak gRPC; the edge speaks HTTP. That boundary is not a compromise - it is putting each protocol where its trade-offs are the right ones. The teams who regret gRPC are almost always the ones who pushed it out to a browser or a third party and spent the next year maintaining a translation layer.

Sources

Back to Blog
Share:

Related Posts