gRPC and Protocol Buffers
Fast, typed service-to-service calls with generated clients and streaming over HTTP/2.
In 30 seconds
gRPC is a way for services to call each other's functions over the network as if they were local. You describe the API once in a .proto file, and tools generate typed client and server code in many languages. Messages are encoded with Protocol Buffers, a compact binary format, and travel over HTTP/2, which allows streaming in both directions. It shines inside a backend between microservices; browsers need an extra layer.
Not in my daily stack — I know the concepts and would learn the details when a project needs it.
Why it matters
- A single .proto contract generates typed clients for Go, Java, Python, Node.js, C#, Kotlin and more, which removes a whole class of integration bugs between teams.
- Binary Protobuf messages are smaller and faster to parse than JSON, which matters at high request volumes between internal services.
- Built-in streaming, deadlines and standard status codes make it a strong fit for real-time feeds and service meshes.
- Large platforms such as Kubernetes (via etcd and the CRI), Envoy and many cloud APIs use gRPC, so it is worth understanding when a project grows into many services.
How it works
01The idea: remote procedure calls with a contract
With REST, you think in resources and HTTP verbs: GET /orders/42. With gRPC, you think in functions: OrderService.GetOrder(id). This is called RPC, remote procedure call. The difference that matters is the contract. In gRPC the API is written in a .proto file using Protocol Buffers (Protobuf), an interface definition language. A compiler (protoc or the Buf CLI) turns that file into code: message classes, a client stub you call like a normal method, and a server interface you implement. If the server changes a field type, clients that regenerate get a compile error instead of a production surprise. Think of the .proto file as a legally binding menu both kitchen and customers must follow.
- Protobuf: the schema language and the binary encoding.
- Stub: generated client object whose methods make network calls.
- Channel: a long-lived connection to a server address.
02Writing a .proto file
A .proto file declares a package, messages (data shapes) and services (groups of RPC methods). Each field has a type and a field number. The number, not the name, is what goes on the wire, so it must never change or be reused once published. proto3 syntax is the common choice; newer Protobuf 'editions' unify proto2 and proto3 behaviour with feature flags, but proto3 files remain fully supported. Use well-known types such as google.protobuf.Timestamp for dates and keep one request and one response message per method so you can add fields later without breaking signatures.
syntax = "proto3";
package shop.orders.v1;
import "google/protobuf/timestamp.proto";
service OrderService {
rpc GetOrder(GetOrderRequest) returns (GetOrderResponse);
rpc WatchOrders(WatchOrdersRequest) returns (stream OrderEvent);
}
message GetOrderRequest { string order_id = 1; }
message GetOrderResponse { Order order = 1; }
message WatchOrdersRequest { string customer_id = 1; }
message OrderEvent { Order order = 1; string type = 2; }
message Order {
string id = 1;
int64 total_cents = 2;
google.protobuf.Timestamp created_at = 3;
}
03Under the hood: HTTP/2, framing and status codes
gRPC runs over HTTP/2. Each call is an HTTP/2 stream on a shared TCP connection, so many calls are multiplexed without head-of-line blocking at the HTTP level. The request is a POST to /package.Service/Method with content-type application/grpc. Each message is length-prefixed binary Protobuf. Metadata (like headers) carries auth tokens and tracing IDs. Results end with trailers that contain grpc-status and grpc-message. gRPC has its own status codes, separate from HTTP: OK, INVALID_ARGUMENT, NOT_FOUND, ALREADY_EXISTS, PERMISSION_DENIED, UNAUTHENTICATED, RESOURCE_EXHAUSTED, UNAVAILABLE, DEADLINE_EXCEEDED and others. Clients should retry only safe codes like UNAVAILABLE, and only for idempotent methods.
POST /shop.orders.v1.OrderService/GetOrder HTTP/2
content-type: application/grpc
te: trailers
authorization: Bearer <token>
grpc-timeout: 2S
<5-byte prefix><binary GetOrderRequest>
HTTP/2 200
content-type: application/grpc
<5-byte prefix><binary GetOrderResponse>
grpc-status: 0
04The four kinds of RPC
gRPC supports four call shapes. Unary is one request and one response, like a normal function. Server streaming sends one request and receives a stream of responses, useful for live order updates or large result sets. Client streaming sends a stream of requests and gets one response, useful for uploading telemetry or chunks. Bidirectional streaming lets both sides send independently on the same call, used for chat, collaborative editing or real-time control. Streams are ordered within one call, and flow control is handled by HTTP/2. Long-lived streams need keepalives and reconnection logic, because proxies and load balancers close idle connections.
- Unary: rpc Get(Req) returns (Res)
- Server streaming: rpc Watch(Req) returns (stream Event)
- Client streaming: rpc Upload(stream Chunk) returns (Summary)
- Bidirectional: rpc Chat(stream Msg) returns (stream Msg)
05A Node.js server and client in practice
In Node.js, the official implementation is @grpc/grpc-js. You can load the .proto at runtime with @grpc/proto-loader, as below, or generate TypeScript code ahead of time with Buf and plugins such as ts-proto or protobuf-es for full type safety. Every client call should pass a deadline; without one, a hung server can hold resources forever. Production servers use TLS (createSsl), or run plaintext only inside a mesh that provides mTLS.
import * as grpc from "@grpc/grpc-js";
import * as loader from "@grpc/proto-loader";
const def = loader.loadSync("orders.proto", { keepCase: false, longs: String });
const pkg = grpc.loadPackageDefinition(def) as any;
const Svc = pkg.shop.orders.v1.OrderService;
const server = new grpc.Server();
server.addService(Svc.service, {
getOrder: (call: any, cb: any) => {
if (!call.request.orderId) return cb({ code: grpc.status.INVALID_ARGUMENT, details: "orderId required" });
cb(null, { order: { id: call.request.orderId, totalCents: "4200" } });
},
});
server.bindAsync("0.0.0.0:50051", grpc.ServerCredentials.createInsecure(), () => {});
const client = new Svc("localhost:50051", grpc.credentials.createInsecure());
client.getOrder({ orderId: "42" }, { deadline: Date.now() + 2000 }, console.log);
06gRPC in the browser and at the edge
Browsers cannot fully control HTTP/2 frames or read trailers from fetch, so they cannot speak native gRPC. There are two common solutions. gRPC-Web is a slightly different wire format that a proxy (Envoy) translates to real gRPC; it supports unary and server streaming only. Connect is a newer protocol family from Buf whose servers speak gRPC, gRPC-Web and a simple HTTP/JSON-friendly Connect protocol at the same time, so a browser can call the same service with plain fetch. Many teams instead keep REST or GraphQL at the public edge and use gRPC only behind it, with a gateway such as grpc-gateway or Envoy transcoding JSON to gRPC.
07Tooling and production checklist
Use the Buf CLI to lint .proto files, detect breaking changes in CI and generate code reproducibly from a buf.gen.yaml. Version packages in the name (orders.v1, orders.v2). Enable the standard health checking service so load balancers and Kubernetes probes can check readiness, and server reflection in non-production so tools like grpcurl can explore the API. Propagate deadlines and tracing metadata (OpenTelemetry) across calls. Set message size limits deliberately; the default maximum receive size is 4 MB in most implementations.
# Lint, check for breaking changes vs main, then generate code
buf lint
buf breaking --against ".git#branch=main"
buf generate
# Call a running server (needs reflection or the .proto)
grpcurl -plaintext -d '{"order_id":"42"}' localhost:50051 shop.orders.v1.OrderService/GetOrder
grpcurl -plaintext localhost:50051 grpc.health.v1.Health/Check
Use it when
- Internal service-to-service calls in a polyglot microservice system where a shared, enforced contract matters.
- High-throughput or low-latency paths where JSON parsing and payload size are measurable costs.
- Real-time streams between backends: telemetry, price feeds, job progress, bidirectional control channels.
- Mobile or IoT clients on constrained networks, where compact binary payloads and generated SDKs help.
Skip it when
- Public APIs for third parties who expect curl-friendly JSON, readable docs and easy debugging; REST is friendlier.
- Simple web apps where the browser is the main client and one backend serves it; the proxy and codegen overhead is not worth it.
- Teams without CI and tooling discipline for schema evolution; unmanaged .proto changes break clients silently.
Alternatives
- REST + OpenAPI
Universal, cacheable, human-readable JSON; generated clients are possible from OpenAPI specs.
- GraphQL
Client-driven queries over one endpoint; better for flexible frontend data needs than for service-to-service RPC.
- Connect RPC
gRPC-compatible protocol that also works with plain HTTP/JSON and browsers without a translating proxy.
- Message queues (Redis/BullMQ, Kafka, NATS)
Asynchronous events when the caller does not need an immediate answer, decoupling services in time.
Best practices
- Never change or reuse field numbers; mark removed fields as reserved so nobody reuses them by accident.
- Give every RPC its own Request and Response message, even if they are tiny today.
- Always set deadlines on the client and propagate the remaining deadline to downstream calls.
- Run buf lint and buf breaking in CI so incompatible schema changes fail the build.
- Return precise gRPC status codes with useful details; use the rich error model (google.rpc.Status) for field errors.
- Use TLS or mTLS everywhere outside localhost, and send auth tokens in metadata, not in messages.
- Expose the standard health service and add OpenTelemetry interceptors for tracing and metrics.
Common mistakes
- Putting a plain layer-4 (TCP) load balancer in front of gRPC: long-lived HTTP/2 connections pin all traffic to one backend.
- No deadlines, so one slow dependency ties up threads and connections across the whole call chain.
- Retrying non-idempotent methods like CreatePayment on UNAVAILABLE without an idempotency key.
- Renaming or renumbering fields, or changing types, which silently corrupts data for old clients.
- Exposing native gRPC directly to browsers and then discovering it cannot work without gRPC-Web or Connect.
Deep dive
08Protobuf wire format and why it is small
Each field is encoded as a tag (field number shifted left three bits, plus a wire type) followed by the value. Integers use varints: small numbers take one byte, larger ones more. That is why field numbers 1 to 15 are precious: their tag fits in one byte, so reserve them for frequent fields. Negative numbers in int32/int64 always take ten bytes; use sint32/sint64 (zigzag encoding) for values that are often negative. Strings, bytes and nested messages are length-delimited. Default values (0, empty string, false) are not written at all in proto3 unless the field is marked optional, which is why you cannot tell 'unset' from 'zero' without optional or wrapper types. Repeated scalars are packed by default in proto3.
message Reading {
int32 sensor_id = 1; // tag 0x08, varint
sint32 delta = 2; // zigzag: -1 encodes as 1 byte
optional double value = 3; // presence tracked: unset != 0.0
repeated int32 samples = 4; // packed into one length-delimited block
reserved 5, 6;
reserved "legacy_name";
}
09Load balancing, name resolution and xDS
A gRPC channel is not a single connection; it is a manager that resolves a target name (dns:///orders.internal:50051) into addresses, opens subchannels to each, and picks one per call according to a load-balancing policy. The default pick_first sends everything to the first healthy address; round_robin spreads calls across all. Service config, delivered by DNS or set in code, configures policies, retries and per-method timeouts. At larger scale, gRPC clients can speak xDS, the Envoy control-plane protocol, to receive endpoints, weights and routing rules directly from a control plane, giving proxyless service mesh behaviour. This avoids a sidecar hop but couples clients to the mesh's control plane.
// Node.js: client-side round robin + retry policy via service config
const serviceConfig = JSON.stringify({
loadBalancingConfig: [{ round_robin: {} }],
methodConfig: [{
name: [{ service: "shop.orders.v1.OrderService", method: "GetOrder" }],
timeout: "2s",
retryPolicy: { maxAttempts: 3, initialBackoff: "0.1s", maxBackoff: "1s",
backoffMultiplier: 2, retryableStatusCodes: ["UNAVAILABLE"] },
}],
});
const client = new Svc("dns:///orders.internal:50051", creds,
{ "grpc.service_config": serviceConfig });
10Streaming at scale: flow control and backpressure
HTTP/2 flow control gives each stream and connection a window of bytes the receiver is willing to accept. If a client reads a server stream slowly, the window fills and the server's writes block or buffer, which is backpressure working as designed. Problems appear when server code ignores it and buffers unbounded messages in memory. In Node.js, respect the return value of call.write() and wait for 'drain'. Long-lived streams also need keepalive pings so NAT gateways and proxies do not silently drop idle connections, but aggressive keepalives get the client rejected with GOAWAY 'too_many_pings'. Design streams to be resumable: include a cursor or sequence number so a reconnecting client can continue where it left off.
11Security: mTLS, auth and interceptors
Between services, mutual TLS gives both encryption and identity: each workload has a certificate, often issued automatically by a mesh or SPIFFE/SPIRE, and servers authorise callers by their certificate identity. User identity travels separately as a bearer token in metadata, validated in a server interceptor, which is gRPC's middleware. Interceptors are also where you add logging, metrics, tracing, rate limiting and panic recovery, consistently for every method. Disable server reflection in production or protect it, cap max message sizes to limit memory abuse, and validate input explicitly; protovalidate lets you declare rules such as string length or email format in the .proto and enforce them in generated code.
Real-world scenarios
One backend pod is at 100% CPU while others idle
Problem
A gRPC service runs five replicas in Kubernetes behind a ClusterIP Service. Metrics show one pod handling almost all traffic from the API gateway.
How to solve it
A ClusterIP Service balances TCP connections, and the gateway holds one long-lived HTTP/2 connection, so all calls land on one pod. Fix it per request: either switch to a headless Service and configure the client with dns:/// targets and the round_robin policy, or put an L7 proxy or service mesh (Envoy, Linkerd, Istio) in the path. Also set a max connection age on the server (grpc.max_connection_age_ms) so clients periodically reconnect and rebalance after scale-ups. Verify by checking per-pod request rates after the change.
A field rename broke the mobile app
Problem
A backend developer renamed and renumbered a field in a .proto file. Older mobile app versions in the wild now show empty prices.
How to solve it
Restore the original field number and type immediately; that brings old clients back because the wire format matches again. If a new name is needed, rename the field but keep its number, or add a new field with a new number and populate both during a transition. Add reserved entries for anything removed. Then prevent repeats: run buf breaking against the main branch in CI so wire-incompatible changes fail the build, and require a version bump (orders.v2) for intentional breaking changes, served side by side until old app versions are retired.
Exposing an internal gRPC service to a web dashboard
Problem
A team has internal services in gRPC and wants a React dashboard to call some of them directly, including a live progress stream.
How to solve it
Rather than a hand-written REST layer, use Connect: implement or wrap the service with a Connect-compatible server, so the browser calls it with the generated protobuf-es client over fetch, and server streaming works for progress. If the service must stay pure gRPC, run Envoy with the grpc_web filter and use a gRPC-Web client. In both cases put authentication at the edge with short-lived tokens in metadata, configure CORS for the dashboard origin only, and expose only the read and progress methods the dashboard needs, not the full internal surface.
Cheat sheet
- Reserve a removed field
reserved 4; reserved "old_field";- Lint protos
buf lint- Fail CI on breaking changes
buf breaking --against ".git#branch=main"- Generate code from buf.gen.yaml
buf generate- List services on a server
grpcurl -plaintext localhost:50051 list- Call a unary method
grpcurl -plaintext -d '{"order_id":"42"}' localhost:50051 pkg.Svc/Method- Health check
grpcurl -plaintext localhost:50051 grpc.health.v1.Health/Check- Client deadline (Node.js)
client.getOrder(req, { deadline: Date.now() + 2000 }, cb)- Debug logs (grpc-js)
GRPC_TRACE=all GRPC_VERBOSITY=DEBUG node server.js
Questions & answers
Q1gRPC vs REST: what are the real differences?
REST is resource-oriented, usually JSON over HTTP/1.1 or HTTP/2, human-readable and supported by every tool, browser and cache. gRPC is contract-first RPC: a .proto file generates typed clients and servers, payloads are binary Protobuf, it requires HTTP/2 and supports four streaming modes natively. gRPC wins on type safety across languages, payload size and streaming between services. REST wins on reach, debuggability, HTTP caching and public developer experience. A common architecture is REST or GraphQL at the edge for browsers and partners, and gRPC between internal services.
Q2How does backward compatibility work in Protobuf?
On the wire, fields are identified by number and type, not by name. A decoder skips unknown field numbers, and missing fields get default values. That means you can safely add new fields with new numbers, and old clients ignore them. You can rename a field without breaking the binary format, though it breaks generated code and JSON mapping. What breaks compatibility is reusing a number, changing a field's type incompatibly, or changing a field from singular to repeated in some cases. When removing a field, add its number and name to a reserved statement. Buf's breaking-change check enforces these rules in CI.
Q3What are deadlines and why do they matter?
A deadline is the absolute time by which the client needs an answer. It is sent in the grpc-timeout header, and when it passes, the client gets DEADLINE_EXCEEDED and the server sees the call as cancelled. Deadlines matter because, in a chain of services, one slow dependency can otherwise hold resources across every upstream service and cause cascading failures. Good practice is to set a deadline on every call, propagate the remaining budget to downstream calls rather than starting a fresh timeout, and check for cancellation in long server handlers so they stop work nobody is waiting for.
Q4Can you use gRPC from a browser?
Not natively. Browsers do not expose the HTTP/2 framing and trailers that gRPC relies on. The options are gRPC-Web, which uses a modified protocol and needs a proxy such as Envoy to translate to standard gRPC, and supports unary and server streaming only; or Connect, where the server itself speaks gRPC, gRPC-Web and the Connect protocol, so a browser calls it with fetch and the same handlers serve backend gRPC clients. Alternatively, keep a REST or GraphQL API for the browser and translate to gRPC in a gateway, which is often simplest for public products.
Q5How do you load-balance gRPC?
gRPC keeps long-lived HTTP/2 connections and multiplexes many calls over one. A layer-4 load balancer balances connections, not requests, so one client can send all its traffic to a single backend while others sit idle. Solutions are layer-7 proxies that understand HTTP/2 and balance per request, such as Envoy, NGINX with grpc_pass, or a service mesh like Istio or Linkerd; or client-side load balancing, where the client resolves all backend addresses through DNS or xDS and uses a round_robin policy. In Kubernetes, a headless Service plus client-side balancing or a mesh is the usual answer.
Q6How do you handle errors in gRPC?
Every call ends with a status code from a fixed set and an optional message. I map domain errors deliberately: validation failures to INVALID_ARGUMENT, missing records to NOT_FOUND, auth to UNAUTHENTICATED or PERMISSION_DENIED, rate limits to RESOURCE_EXHAUSTED, and transient failures to UNAVAILABLE so clients know they may retry. For structured detail, the richer error model attaches google.rpc.Status details like BadRequest with field violations or RetryInfo. I never leak stack traces in messages, and interceptors log errors with trace IDs so a client error can be followed across services.
Senior-level questions
S1How would you design API versioning and governance for 50 services using gRPC?
I would put all .proto files in a schema registry, either a dedicated repository or the Buf Schema Registry, with packages versioned in the name (billing.v1). CI runs buf lint for consistent style and buf breaking against the last release, so wire-incompatible changes cannot merge. Additive changes stay in v1; true breaking changes create v2 served side by side, with a deprecation window and metrics showing which clients still call v1. Generated SDKs are published as versioned packages so consumers upgrade deliberately. Ownership is explicit per package through CODEOWNERS. Shared types like Money or Address live in a small common package, kept stable, because every change there ripples everywhere.
S2Sidecar service mesh or proxyless gRPC: what are the trade-offs?
A sidecar mesh (Istio, Linkerd) gives mTLS, retries, traffic splitting and telemetry to every service regardless of language, with no code changes. The costs are an extra network hop per call, sidecar CPU and memory on every pod, and operational complexity. Proxyless gRPC uses xDS inside the gRPC library itself, so clients get endpoints, load balancing and routing straight from the control plane: lower latency and resource use, but only for gRPC traffic, only in languages with mature xDS support, and with mesh features tied to library versions. Ambient or node-level meshes are a middle ground. I would start with a sidecar or ambient mesh for uniformity, and use proxyless only for latency-critical hot paths.
S3How do you make retries safe across a chain of gRPC services?
First, limit retries to codes that mean the request probably was not processed or can be repeated, mainly UNAVAILABLE, and only for idempotent methods. For mutating methods, require an idempotency key in the request and store results keyed by it on the server, so a retried CreatePayment returns the original result. Second, avoid retry amplification: if three layers each retry three times, one user request can become 27 backend calls. Retry at one layer, ideally the edge or the client nearest the failure, use exponential backoff with jitter, and use gRPC's retry throttling, which disables retries when the failure ratio is high. Third, propagate deadlines so retries never exceed the original budget.
Glossary
- RPC
- Remote procedure call: calling a function that runs on another machine as if it were local.
- Protocol Buffers
- Google's schema language and compact binary encoding used by gRPC to define and send messages.
- Stub
- Generated client object whose methods serialise requests, send them over the network and return responses.
- Deadline
- The absolute time by which a call must finish; after it, the call is cancelled with DEADLINE_EXCEEDED.
- Metadata
- Key-value pairs sent with a call, similar to HTTP headers, used for auth tokens and trace IDs.
- Interceptor
- Middleware that wraps every call on client or server for logging, auth, metrics or retries.
- gRPC-Web
- A browser-compatible variant of gRPC that needs a proxy to translate to standard gRPC.
- Field number
- The numeric ID of a Protobuf field; it identifies the field on the wire and must never change.
- xDS
- Envoy's control-plane APIs; gRPC clients can use them to receive endpoints and routing without a sidecar.
Keep learning
Have a product in mind?Let's build it.
Tell me about your idea, your product or your team. I usually reply within one working day.