← All weekly notes

9 min read

Why gRPC fits internal service-to-service calls

A simple guide to gRPC contracts, generated code, HTTP/2, deadlines, errors, streaming and the production details that matter.

Distributed systems · gRPC · Go · Networking · Protobuf

The common explanation for gRPC is:

“It is binary, so it is faster than REST.”

That is incomplete. Smaller messages can help, but a database call, network hop or overloaded server often costs much more than serialization.

The stronger reason is:

gRPC gives services one shared contract and one standard way to call, wait, fail, retry, stream and observe work.

This is most useful when the same organization controls both the client and the server.

The answer to remember

1. Strong contracts that can evolve

The .proto file defines the methods and fields. Field numbers keep old and new versions compatible during gradual deployments.

It prevents: client and server definitions drifting apart.

2. Generated, type-safe code

The compiler generates client methods, server interfaces and message types. Many mistakes fail during the build instead of in production.

It prevents: hand-written routing, parsing and client glue for every service.

3. Compact binary messages

Protobuf sends field numbers and values instead of repeating JSON field names. This can reduce bytes, parsing work and allocations.

It prevents: avoidable encoding cost on busy boundaries.

4. HTTP/2 connections and streams

Many RPCs share one warm connection. gRPC also supports unary and streaming calls with flow control.

It prevents: repeated connection setup and custom streaming protocols.

5. One shrinking time budget

A deadline travels with the request. If service A uses part of the budget, service B receives only what remains.

It prevents: abandoned work continuing across the system.

6. One failure language

All services use the same status codes, such as INVALID_ARGUMENT, UNAVAILABLE and DEADLINE_EXCEEDED.

It prevents: every API inventing different error and retry meanings.

7. Shared production hooks

Interceptors provide one place for authentication, tracing, metrics and controlled retries. Health checks and graceful shutdown also have standard mechanisms.

It prevents: operational code being copied into business handlers.

8. A clear internal boundary

gRPC is strongest when both ends are controlled services. Public and browser APIs often benefit more from familiar HTTP and easy inspection.

It prevents: choosing gRPC merely because it sounds faster.

Define → generate → call with a budget → execute → return a status → observe

01 · Remote calls

A remote call is not a local function

This looks like a normal Go call:

quote, err := pricing.GetQuote(ctx, request)

Across a network, the real path is longer:

Order service    → encode request    → network    → server queue    → pricing handler    → encode response or error    → network    → decode result

The request may never arrive. The server may finish but lose the response. The client may time out while the server is still working.

That is why a remote call needs a contract, time budget, failure model, retry rule and observability. gRPC packages these pieces together. It does not make the network reliable.

02 · Contracts

The .proto file is the shared contract

syntax = "proto3"; package pricing.v1; service PricingService {  rpc GetQuote(GetQuoteRequest) returns (GetQuoteResponse);} message GetQuoteRequest {  string product_id = 1;  uint32 quantity = 2;} message GetQuoteResponse {  int64 unit_price_minor = 1;  string currency = 2;}

The numbers 1 and 2 identify fields on the wire. This lets old and new versions overlap:

Old client ──→ new serverNew client ──→ old serverNew client ──→ new server

Keep evolution simple:

  • add fields instead of changing old meanings;
  • never reuse an old field number;
  • reserve removed numbers and names;
  • choose safe behaviour when a field is absent.
message GetQuoteResponse {  reserved 3;  reserved "legacy_discount";   int64 unit_price_minor = 1;  string currency = 2;  int64 valid_until_unix_ms = 4;}

This is backward and forward compatibility. The Protobuf compatibility guide contains the detailed rules.

Compatibility only means the message can be read. The application must still decide what missing fields mean and whether the business behaviour remains safe.

03 · Generated code

Generated code removes repeated transport work

From the same .proto file, gRPC generates:

  • request and response types;
  • typed client methods;
  • a server interface;
  • encoding, decoding and dispatch code.

The application still owns validation, authorization and business logic.

func (s *pricingServer) GetQuote(    ctx context.Context,    req *pricingv1.GetQuoteRequest,) (*pricingv1.GetQuoteResponse, error) {    if req.GetQuantity() == 0 {        return nil, status.Error(            codes.InvalidArgument,            "quantity must be positive",        )    }     return s.store.Lookup(ctx, req)}

Generated code does not mean zero work. It means the transport glue is consistent and type checked. The official Go tutorial shows this client-and-server generation flow.

Protobuf is usually smaller and cheaper to parse than equivalent JSON, but there is no honest universal multiplier. Measure realistic payloads, concurrency, p99 latency, CPU, allocations and wire bytes.

04 · HTTP/2 + streams

HTTP/2 carries calls and streams

One gRPC channel normally reuses a connection. HTTP/2 places several RPCs on separate logical streams inside it.

One TCP/TLS connection    ├─ GetQuote    ├─ ReserveStock    └─ GetCustomer

This avoids opening a connection for every call. It does not remove every delay: the streams still share the underlying TCP connection.

gRPC supports four call shapes:

ShapeRequestResponseExample
Unaryoneoneget a quote
Server streamingonemanyfollow block headers
Client streamingmanyoneupload chunks
Bidirectional streamingmanymanyreplication session

Flow control slows a sender when the receiver cannot keep up. This is backpressure. It applies to streaming RPCs, as explained in the gRPC flow-control guide.

The application still needs bounded queues and a slow-consumer policy. Use streaming when the work is naturally incremental, not as a general speed switch.

05 · Failure control

Deadlines, status codes and retries control failure

One deadline for the whole path

Original budget                 300 msService A uses                  -65 msBudget passed to service B      235 ms

This is deadline propagation. Downstream services receive the remaining time instead of starting a fresh timeout.

ctx, cancel := context.WithTimeout(parent, 120*time.Millisecond)defer cancel() response, err := pricingClient.GetQuote(ctx, request)

Go propagates incoming deadlines to outgoing gRPC calls by default. gRPC does not create a deadline automatically, so callers must set one. Downstream work must also observe cancellation. The deadline guide explains the full lifecycle.

Status codes guide the response

Normal reactionCodesMeaning
Fix the requestINVALID_ARGUMENT, UNIMPLEMENTEDthe same request will fail again
Fix accessUNAUTHENTICATED, PERMISSION_DENIEDrefresh credentials once, or stop
Change state firstFAILED_PRECONDITION, NOT_FOUND, ALREADY_EXISTSdo not blindly repeat
Retry carefullyUNAVAILABLE, sometimes RESOURCE_EXHAUSTEDuse backoff and jitter
Restart the workflowABORTEDreread state before trying again
Budget endedDEADLINE_EXCEEDEDretry only with time and safe semantics
InvestigateINTERNAL, DATA_LOSSnot a normal retry signal

The status-code guide gives the exact meanings.

A retry needs three yes answers

  1. Is the failure temporary?
  2. Is the operation safe to repeat?
  3. Is enough deadline left for another useful attempt?

Safe to repeat is called idempotent. Mutations often need an idempotency key so a repeated call does not create a second result.

Retries also need exponential backoff, random jitter and a small attempt limit. A retry at every service layer can multiply load during an outage. The retry guide separates transparent transport retries from configured application retries.

06 · Production

Production needs common guardrails

Use interceptors—gRPC middleware—to keep shared policy outside business handlers.

Client: deadline → trace metadata → controlled retryServer: authentication → logging → metrics → handler

For each RPC method, observe:

  • request rate, errors and p50/p95/p99 duration;
  • in-flight calls, cancellations and retries;
  • request and response sizes;
  • active connections and streams.

Pass trace context through metadata so one request can be followed across services. The OpenTelemetry metrics guide lists standard instruments.

Also keep four practical rules:

  1. Reuse channels and generated stubs. Do not reconnect for every RPC.
  2. Register the standard health service and update it during startup and shutdown.
  3. Set message-size and concurrency limits deliberately.
  4. For very large data, stream bounded chunks or send a storage reference instead.

The health-checking guide describes the standard health service.

07 · Kubernetes

Kubernetes balances connections, not RPCs

A normal Kubernetes ClusterIP Service chooses a backend when a TCP connection is created. gRPC can then place many RPCs on that one long-lived connection.

gRPC client    │ one HTTP/2 connection    ▼Kubernetes Service    └────────────→ Pod B                    ├─ RPC 1                    ├─ RPC 2                    └─ RPC 3 Pod A: idle              Pod C: idle

Kubernetes did balance the connection. It does not choose a new pod for each RPC on that connection.

This matters most when a few clients send heavy traffic through a few long-lived channels. Many client processes may create enough connections to spread traffic naturally, so measure before changing the architecture.

Two common solutions are:

Client-side balancing

A headless Service exposes pod IPs through DNS. The gRPC client maintains several connections and uses a policy such as round_robin to pick a ready backend for each RPC.

The client must handle DNS refresh and pod churn. The Kubernetes Service guide explains headless discovery, while the gRPC load-balancing guide explains pick_first and round_robin.

HTTP/2-aware proxying

A service mesh or L7 gateway understands HTTP/2 streams and can distribute RPCs across pods. This centralizes routing and telemetry, but adds another hop and another system to operate.

Graceful deployment shutdown

When a pod receives SIGTERM, mark it unready and start graceful gRPC shutdown. Let in-flight RPCs finish, but use a timeout so shutdown cannot wait forever.

done := make(chan struct{}) go func() {    grpcServer.GracefulStop()    close(done)}() select {case <-done:case <-time.After(20 * time.Second):    grpcServer.Stop()}

The graceful-shutdown guide describes this graceful-then-forceful pattern.

08 · Decision rule

Choose the boundary, not a winner

BoundaryGood starting pointWhy
controlled internal request/responsegRPCgenerated contract and call lifecycle
public or browser APIREST/JSON or Connectfamiliar HTTP and easy inspection
durable work that can happen latermessage broker or event logbuffering, replay and independent availability
long-lived browser conversationWebSocketbrowser-native full-duplex channel
client-shaped product queriesGraphQLflexible reads and aggregation

Prefer gRPC when both ends are controlled, several languages need generated clients, deadlines cross service hops, or the workload needs streaming.

Prefer simpler HTTP when unknown clients, browsers, curl, HTTP caching or straightforward low-volume integration matter more.

Prefer messaging when the caller does not need an immediate answer:

Checkout ──gRPC──→ reserve inventory nowCheckout ──event─→ send email later

The tools can work together. Choose based on who controls the clients, when the result is needed and how failure should behave.

What to remember

  1. gRPC is a contract and remote-call lifecycle, not merely binary JSON replacement.
  2. Protobuf and generated code catch many integration errors before deployment.
  3. Deadlines carry one shrinking budget across services.
  4. Status codes help classify failure; idempotency decides whether retry is safe.
  5. Streaming supplies flow control, but applications still need bounded queues.
  6. Interceptors, health, metrics and graceful shutdown make the service operable.
  7. Kubernetes connection balancing needs special care with long-lived channels.
  8. gRPC is mainly an internal boundary; REST, Connect, messaging and WebSocket solve different problems.

Choose gRPC when its strict contract and standard call lifecycle remove more complexity than its tooling and operational requirements add.