gRPC & Protobuf
How services call each other's functions over the network: writing the contract in a .proto file, what protobuf bytes look like, changing schemas without breaking anyone, streaming over HTTP/2, deadlines that travel across services, and what it takes to run gRPC in production.
The 30-second version
Big systems are usually split into many small programs, called microservices: one for products, one for orders, one for payments. They constantly need to ask each other things: "what does product 7 cost?", "is it in stock?". gRPC is one popular way to ask.
Three pieces make gRPC work. You'll meet each one in its own section:
A contract (.proto file)
Compact binary messages
HTTP/2 underneath
Here's what one call looks like from the outside. On the client it really is just a method call:
// ctx carries the time limit for the call (§08); shopv1 is the package generated from the contract.
// &shopv1.GetProductRequest{Id: 7} builds the request message and passes a pointer to it.
resp, err := client.GetProduct(ctx, &shopv1.GetProductRequest{Id: 7})But a lot happens between those parentheses:
That middle box, the network, is the reason this topic exists. It's also why §02 starts with a warning: a remote call only looks like a local call. Most of what makes gRPC good in production is about handling that honestly: deadlines (§08), status codes and retries (§09), and load balancing (§11).
What you'll be able to do after this topic
- Write a
.protocontract and generate Go and JavaScript code from it. - Read protobuf bytes by hand, and know which schema changes are safe to deploy.
- Build a gRPC server and client in Go, and call it from Node.
- Use all four call types: one request and one reply, or streams in either direction.
- Set deadlines that travel across services, return proper errors, and retry safely.
- Avoid the classic production traps: load balancers that send everything to one server, giant messages, and schema changes that silently corrupt data.
Calling a function on another machine
The idea: a function call that crosses machines
Inside one program, calling a function is simple. You jump to its code, it runs, it returns. Now imagine GetProduct lives in a different program, on a different machine, maybe written in a different language. To "call" it you have to:
- turn the arguments into bytes (Serialization),
- send those bytes over the network to the right machine,
- have that machine turn the bytes back into arguments and run the real function,
- turn the result into bytes, send them back, and turn them back into a value.
RPC frameworks generate the code for all four steps. The generated pieces are called stubs. You call a method on the client stub; the server stub calls your handler. That's the whole trick, and it dates back to the 1980s. gRPC is Google's modern, open-source version (released in 2015).
Why the illusion is dangerous
A local function call makes three promises the network can't keep:
It's fast
It either runs or it doesn't
It always comes back
Here's what that means in practice. Both of these lines look the same; only one of them can hang, half-succeed, or fail with a network error:
p := catalog.Lookup(7) // local: nanoseconds, can't "half happen"
resp, err := client.GetProduct(ctx, req) // remote: ms to ∞, may fail, may have run anywaySo good RPC frameworks don't hide the network completely. gRPC makes you pass a ctx into every call, which can carry a deadline (setting one is your job — §08), returns a typed error code that says what kind of failure happened (§09), and lets you configure retries for only the safe cases.
REST + JSON vs gRPC + protobuf, honestly
You've probably built REST APIs: URLs like GET /products/7, HTTP methods as verbs, and JSON bodies. gRPC is not "REST but better". It's a different set of trade-offs:
| REST + JSON | gRPC + protobuf | |
|---|---|---|
| Contract | Optional (OpenAPI docs, often out of date) | Required: the .proto file is the API, and code is generated from it |
| Data format | Text: easy to read, larger, slower to parse | Binary: compact and fast, but not human-readable without tools |
| Typing | Whatever the JSON happens to contain | Every field has a declared type; the generated code enforces it |
| Streaming | Awkward (you need extra tech like Server-sent events or WebSockets) | Built in, in both directions (§07) |
| Browsers | Work everywhere, natively | Need gRPC-Web or Connect plus a translation layer (§11) |
| Debugging | curl and your browser's dev tools | Special tools like grpcurl (§11) |
| Caching, CDNs | HTTP caching works out of the box | Mostly not; every call is an HTTP POST, which caches don't store (§07) |
| Deadlines & cancellation | You build it yourself | Built in and passed along across services (§08) |
Protobuf: writing the contract
.proto file is the contract: it lists every message (data shape) and every function (RPC) a service offers. A code generator turns it into Go and JavaScript, so both sides agree by construction.The .proto file is written in protobuf's interface definition language. It only describes things; there's no logic in it. Here's the whole contract for the product catalog we'll use throughout this topic:
syntax = "proto3"; // which protobuf version (always proto3 for new code)
package shop.v1; // a namespace, so names don't clash with other teams'
option go_package = "example.com/shop/gen/shop/v1;shopv1"; // where the generated Go code lives
message Product {
int64 id = 1; // type, name, FIELD NUMBER
string name = 2;
int64 price_cents = 3; // 1999 = $19.99 (never use float for money)
repeated string tags = 4; // repeated = a list of strings
Category category = 5;
oneof discount { // at most ONE of these is set
int32 percent_off = 6;
int64 cents_off = 7;
}
optional string description = 8; // can tell "not set" apart from ""
}
enum Category {
CATEGORY_UNSPECIFIED = 0; // the first value must be 0
CATEGORY_BOOKS = 1;
CATEGORY_TOYS = 2;
}
message GetProductRequest { int64 id = 1; }
message GetProductResponse { Product product = 1; } // a message inside a message
service CatalogService {
rpc GetProduct(GetProductRequest) returns (GetProductResponse);
}Why requests and responses get their own wrapper messages
GetProduct could have returned Product directly. Instead it returns GetProductResponse, which contains a Product. That looks like extra ceremony, but it buys you room to grow. Next year you might want to add bool from_cache = 2; to the response. With a wrapper you just add a field. Without one, you'd have to change the RPC's return type, and that breaks every client. The linter in buf (below) enforces this habit.
The zero-value rule (and why optional exists)
In proto3, every field has a default: 0, "", false, an empty list, or the enum's 0 value. A field holding its default isn't sent at all, and the reader fills the default back in. That keeps messages small, but it means a plain int32 percent_off can't tell "0% off" from "nobody set this".
Mark a field optional when that difference matters. Go then generates a Pointer (*string, where nil means "not set"), and JS gets a field that's simply absent. Fields inside a oneof, and fields whose type is another message, already track presence.
Generating code
The .proto file isn't used directly by Go. A code generator reads it and writes ordinary Go files (Code generation). The tools you'll meet are protoc or buf, plus two plugins:
protoc-gen-gowrites the message types (Productstruct,Categoryconstants).protoc-gen-go-grpcwrites the client and server stubs for eachservice.
# run from the project root (the folder with buf.yaml)
# one-time: install buf (e.g. npm install -g @bufbuild/buf) and the two Go plugins
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
buf lint # style + design checks (e.g. "use wrapper messages")
buf generate # reads buf.gen.yaml, writes gen/shop/v1/*.pb.goThe modules: path: proto line matters. Without a buf.yaml, buf treats the whole project folder as the root, so the file is called proto/shop/v1/catalog.proto. We tried it: the code lands in gen/proto/shop/v1/ (not where go_package says), and buf lint complains:
proto/shop/v1/catalog.proto:3:1:Files with package "shop.v1" must be within a directory "shop/v1" relative to root but were in directory "proto/shop/v1".With the buf.yaml above, buf lint prints nothing (that means no problems) and buf generate writes exactly two files: gen/shop/v1/catalog.pb.go and gen/shop/v1/catalog_grpc.pb.go.
What comes out is normal Go. Here's a trimmed view of the generated message type:
type Product struct {
Id int64
Name string
PriceCents int64
Tags []string
Category Category
Discount isProduct_Discount // the oneof: holds a *Product_PercentOff or *Product_CentsOff
Description *string // optional → pointer; nil = "not set"
// …plus hidden internal fields
}
func (x *Product) GetName() string {
if x != nil { // ← getters are safe to call on a nil message
return x.Name
}
return ""
}Here's how each line of the Product message turns up in Go, and in JavaScript (using the Node loader you'll meet at the end of this section):
Setting and reading oneof and optional in Go
These two are where beginners get stuck, because you can't just write Discount: 10 or Description: "x":
p := &shopv1.Product{
Id: 7,
Name: "Go Book",
Discount: &shopv1.Product_PercentOff{PercentOff: 10}, // oneof: wrap the value in its "case" type
Description: proto.String("A book about Go"), // optional: needs a pointer to a string
}
// Reading a oneof: ask which case is set.
switch d := p.GetDiscount().(type) {
case *shopv1.Product_PercentOff:
fmt.Println("percent off:", d.PercentOff) // percent off: 10
case *shopv1.Product_CentsOff:
fmt.Println("cents off:", d.CentsOff)
case nil:
fmt.Println("no discount")
}
fmt.Println(p.GetCentsOff()) // 0 — getters are safe even when another case is set
// Reading an optional: was it set at all?
if p.Description != nil {
fmt.Println("description:", p.GetDescription()) // description: A book about Go
}And in JavaScript?
Node has two styles. The simplest, used in this topic, is dynamic loading with @grpc/proto-loader. It reads the .proto file when your program starts, so there's no build step. The other style is code generation (for example protobuf-es or ts-proto), which gives you TypeScript types. Teams that use TypeScript usually prefer that.
// npm install @grpc/grpc-js @grpc/proto-loader
import * as grpc from "@grpc/grpc-js";
import * as protoLoader from "@grpc/proto-loader";
const def = protoLoader.loadSync("proto/shop/v1/catalog.proto", {
longs: Number, // int64 → JS number (see the warning below)
enums: String, // "CATEGORY_BOOKS" instead of 1
defaults: true, // missing fields show up as 0 / "" / []
oneofs: true, // adds product.discount = "percentOff" | "centsOff"
});
const shop = grpc.loadPackageDefinition(def).shop.v1; // ← follows "package shop.v1;"Field names become lowerCamelCase in JS: price_cents becomes priceCents.
On the wire: tags and varints
You won't write these bytes by hand. But knowing what they look like explains almost every rule in the next section: why names don't matter, why numbers are forever, and why old code can skip fields it doesn't know.
Reading bytes in hex
Bytes are usually written in hexadecimal: two characters per Byte, from 00 (0) to ff (255). So 0f = 15 and cf = 207. That's all the notation you need.
One field, three parts
Every encoded field is built the same way:
Tag
(field_number << 3) | wire_type. For field 1 holding a number, that's (1 << 3) | 0 = 8 = 08. That's the field's Tag.Length (only for wire type 2)
Value
<< 3 means "shift left by 3 bits", which is the same as multiplying by 8. | wire_type then puts the wire type (0–7) in the 3 low bits that the shift left empty. So the reader can split a tag back apart: tag >> 3 is the field number, and tag & 7 is the wire type.
Varints: small numbers, small bytes
Most numbers in real data are small: IDs, counts, enum values. A varint uses 1 byte for 0–127, 2 bytes up to 16,383, and so on. Each byte holds 7 bits of the number. The 8th (top) bit is a flag: 1 means "another byte follows", 0 means "this is the last one". The lowest 7 bits come first.
A worked example: 1999 in binary is 111 1100 1111 (11 bits). That's more than 7 bits, so it needs two bytes:
- the low 7 bits are
100 1111; there's more to come, so set the top bit:1100 1111=cf - the remaining bits are
1111, and nothing follows:0000 1111=0f
So price_cents = 1999 (field 3) encodes as tag 18 (that's 3 << 3 | 0 = 24 = 0x18), then cf 0f.
Try it
The encoder below produces the same bytes Go's proto.Marshal does. Things to try:
- Set
price_centsto0. The field disappears from the bytes. That's the proto3 zero-value rule from §03. - Make
idbigger than 127, then bigger than 16,383, and watch it grow from 1 to 2 to 3 bytes. - Set
idto-1. It takes 10 bytes. Computers store −1 as all 64 bits set to 1 (two's complement), so read as a varint it looks like the huge number 2⁶⁴ − 1. If negatives are common, use the typesint64. It uses a trick called "ZigZag" so that small negative numbers stay small too. - Tick "field number 16". The tag now needs 2 bytes (
80 01). Field numbers 1–15 cost one tag byte, so give them to your most frequent fields. Noticeidalso moves to the end: Go writes fields in field-number order, not the order you declared them. (Readers must accept any order, though.)
The default example, byte by byte
Product{id: 7, name: "Go Book", price_cents: 1999, tags: ["go", "book"], category: BOOKS} becomes 26 bytes:
08 07 field 1 (varint) = 7
12 07 47 6f 20 42 6f 6f 6b field 2 (length 7) = "Go Book"
18 cf 0f field 3 (varint) = 1999
22 02 67 6f field 4 (length 2) = "go" ← repeated: one entry per item
22 04 62 6f 6f 6b field 4 (length 4) = "book"
28 01 field 5 (varint) = 1 (CATEGORY_BOOKS)The same data as compact JSON is 92 bytes, about 3.5× larger. Most of the difference is the key names, "priceCents": and friends, repeated in every single message. Protobuf replaces each name with a one-byte tag.
You can check this yourself in a few lines:
import (
"fmt"
shopv1 "example.com/shop/gen/shop/v1"
"google.golang.org/protobuf/proto"
)
func main() {
p := &shopv1.Product{Id: 7, Name: "Go Book", PriceCents: 1999,
Tags: []string{"go", "book"}, Category: shopv1.Category_CATEGORY_BOOKS}
b, err := proto.Marshal(p) // struct → bytes
if err != nil {
panic(err)
}
fmt.Printf("% x\n", b) // 08 07 12 07 47 6f 20 42 6f 6f 6b 18 cf 0f 22 02 …
fmt.Println(len(b)) // 26
var back shopv1.Product
_ = proto.Unmarshal(b, &back) // bytes → struct (error ignored only for brevity)
fmt.Println(back.GetName()) // Go Book
}Changing the contract safely
Why old and new code always overlap
You can't update every program at the same moment. During a rolling deploy, servers are replaced one by one, so for a few minutes some run v1 and some run v2. Mobile apps are worse: some users never update, so a v1 app may call your servers for years. This overlap is called Version skew.
So every change has to work in two directions (backward and forward compatibility):
- v2 server → v1 client: old code reads bytes written by new code.
- v1 client → v2 server: new code reads bytes written by old code.
What makes it possible: skipping unknown fields
Recall from §04 that every field starts with a tag: a field number plus a wire type. When a reader sees a field number its schema doesn't know, the wire type still tells it how many bytes to skip. A varint ends at the first byte without the top bit; a length-prefixed field says its length. So the old reader skips the field and carries on. No error, no crash. These skipped fields are called Unknown fields, and Go even keeps their bytes so they survive if the message is passed on.
That one mechanism makes adding fields safe. It also explains why some other changes go badly wrong. Try them:
message Product {int64 id = 1;string name = 2;int64 price_cents = 3;}
message Product {int64 id = 1;string name = 2;int64 price_cents = 3;+ string description = 4;}
The rules, from the simulator
| Change | Safe? | Why |
|---|---|---|
| Add a field with a new number | ✅ | Old readers skip it; new readers see the default when old writers leave it out. |
| Rename a field (same number) | ✅ binary, ❌ JSON | Names aren't in the bytes. But the JSON mapping uses names, so JSON clients break. |
Delete a field, reserved its number and name | ⚠️ | Safe on the wire, but readers that still use it now see 0 / "". Delete only once nobody reads it. |
| Reuse an old number for a new field | 💥 | Old data gets read as the new field. Silent corruption. |
| Change a field's type | 💥 (mostly) | The wire type may not match; Go drops the value as unknown. A few pairs are compatible (e.g. int32 ↔ int64 for small values), but don't rely on it. |
| Change a field's number | 💥 | Same as delete + add: the old number stops arriving. |
| Remove an enum value / an RPC | 💥 | Old clients still send it or call it. Reserve enum numbers just like fields. |
reserved: a tombstone for dead fields
When you delete a field, leave a marker so nobody reuses its number or name by accident later:
message Product {
reserved 3; // price_cents used to live here — never reuse 3
reserved "price_cents"; // and keep the name free, for the JSON mapping
int64 id = 1;
string name = 2;
int64 price_in_cents = 9; // the replacement gets a brand-new number
}Let a machine check it: buf breaking
People forget rules; machines don't. buf breaking compares your .proto files with an older version, for example the main branch in git, and fails if a change would break existing clients. Run it in CI:
buf breaking --against '.git#branch=main'Here's exactly what it printed (buf 1.75, default settings) when we changed line 10 from int64 price_cents = 3; to int64 stock_count = 3;, the reuse from the simulator. The exit code was 100, which fails a CI job:
proto/shop/v1/catalog.proto:10:3:Field "3" with name "stock_count" on message "Product" changed option "json_name" from "priceCents" to "stockCount".
proto/shop/v1/catalog.proto:10:9:Field "3" on message "Product" changed name from "price_cents" to "stock_count".Notice what it says: "changed name". buf can't know that you meant a brand-new field. To a tool, keeping number 3 and type int64 but changing the name looks like a rename. The rename warnings are what save you here. They come from the default rules, which also protect the JSON mapping and your generated code.
Presence and evolution
Adding a plain int32 percent_off = 6; creates a quiet ambiguity. When a new server reads 0, it might mean "0% off" or it might mean "an old client that's never heard of this field". If that difference matters, declare it optional int32 percent_off = 6;. Go then generates a pointer, and an old client's message shows up as nil ("not sent") rather than 0. That's field presence, from §03, paying off.
Your first service: server and client
.proto file from §03 is the only thing they share.We'll build the simplest kind of call first: a unary RPC, meaning one request in and one response out. GetProduct is unary. Streaming comes in §07.
The project, and how to run it
Here's every file involved, and which one uses which. You write the blue ones; buf generate writes the purple ones; Node reads the .proto file directly:
gen/shop/v1 by the path go.mod + folder. The Node client skips code generation and reads the .proto directly. Never edit files in gen/: the next buf generate overwrites them.From an empty folder, these are the steps we ran (Go 1.26, Node 22):
go mod init example.com/shop # creates go.mod; the module path matches go_package
go get google.golang.org/grpc google.golang.org/protobuf # the two Go libraries
# write proto/shop/v1/catalog.proto, buf.yaml, buf.gen.yaml (all from §03)
buf generate # → gen/shop/v1/catalog.pb.go, catalog_grpc.pb.go
# write cmd/server/main.go and cmd/client/main.go (below)
go run ./cmd/server # terminal 1: prints "listening on :50051" and keeps running
go run ./cmd/client # terminal 2: prints "Go Book 1999", then the NotFound error
npm install @grpc/grpc-js @grpc/proto-loader
node client.mjs # the Node client, against the same Go serverThe server
The generated code gives you a Go Interface called CatalogServiceServer, with one method per rpc in the .proto file. Your job is to write a type that has those methods, then register it.
package main
import (
"context"
"log"
"net"
shopv1 "example.com/shop/gen/shop/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// catalogServer implements the CatalogServiceServer interface from the generated code.
type catalogServer struct {
shopv1.UnimplementedCatalogServiceServer // ← embedded: "not implemented" for every RPC we skip
products map[int64]*shopv1.Product
}
func (s *catalogServer) GetProduct(ctx context.Context, req *shopv1.GetProductRequest) (*shopv1.GetProductResponse, error) {
p, ok := s.products[req.GetId()]
if !ok {
return nil, status.Errorf(codes.NotFound, "product %d not found", req.GetId()) // ← a gRPC error (§09)
}
return &shopv1.GetProductResponse{Product: p}, nil
}
func main() {
lis, err := net.Listen("tcp", ":50051") // accept TCP connections on port 50051
if err != nil {
log.Fatal(err)
}
s := grpc.NewServer()
shopv1.RegisterCatalogServiceServer(s, &catalogServer{products: map[int64]*shopv1.Product{
7: {Id: 7, Name: "Go Book", PriceCents: 1999},
}})
log.Println("listening on :50051")
log.Fatal(s.Serve(lis)) // blocks forever, serving calls
}Every handler has the same shape: it takes a ctx and the request message, and returns the response message or an error. The ctx carries the caller's deadline and cancellation (§08) and its metadata, gRPC's version of headers (§10).
The Go client
package main
import (
"context"
"fmt"
"log"
"time"
shopv1 "example.com/shop/gen/shop/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure" // ← the "no TLS" option lives in its own package
)
func main() {
// Create the connection ONCE and reuse it for every call.
conn, err := grpc.NewClient("localhost:50051",
grpc.WithTransportCredentials(insecure.NewCredentials())) // no TLS: local demo only
if err != nil {
log.Fatal(err)
}
defer conn.Close()
client := shopv1.NewCatalogServiceClient(conn) // the generated client stub
ctx, cancel := context.WithTimeout(context.Background(), time.Second) // ← deadline (§08)
defer cancel()
resp, err := client.GetProduct(ctx, &shopv1.GetProductRequest{Id: 7})
if err != nil {
log.Fatal(err)
}
fmt.Println(resp.GetProduct().GetName(), resp.GetProduct().GetPriceCents()) // Go Book 1999
_, err = client.GetProduct(ctx, &shopv1.GetProductRequest{Id: 42})
fmt.Println(err) // rpc error: code = NotFound desc = product 42 not found
}The Node client
The Node version calls the same Go server. It loads the same .proto file at runtime, as shown in §03:
import * as grpc from "@grpc/grpc-js";
import * as protoLoader from "@grpc/proto-loader";
const def = protoLoader.loadSync("proto/shop/v1/catalog.proto",
{ longs: Number, enums: String, defaults: true, oneofs: true });
const shop = grpc.loadPackageDefinition(def).shop.v1;
// One client object = one connection. Create it once, reuse it.
const client = new shop.CatalogService("localhost:50051", grpc.credentials.createInsecure());
// grpc-js uses callbacks; wrap the call in a Promise so we can await it.
function getProduct(id) {
return new Promise((resolve, reject) => {
const deadline = Date.now() + 1000; // ← an absolute time, not a duration
client.getProduct({ id }, { deadline }, (err, res) => (err ? reject(err) : resolve(res)));
});
}
const res = await getProduct(7);
console.log(res.product.name, res.product.priceCents); // Go Book 1999
try {
await getProduct(42);
} catch (err) {
console.log(err.code === grpc.status.NOT_FOUND, err.details); // true product 42 not found
}
client.close();HTTP/2 underneath, and the four call types
HTTP/2 in two minutes
gRPC doesn't invent its own network protocol. It runs on top of HTTP/2. The HTTP topic covers HTTP/2 properly; here's the part you need.
With HTTP/1.1, one connection carries one request at a time. A second request has to wait, or open another connection. HTTP/2 instead cuts every request and response into small frames. Each frame is labelled with a stream number, and frames from many streams are interleaved on one connection (Multiplexing).
Here's that picture moving. Three calls share one connection; their frames are interleaved on the way in and sorted back into separate calls on the other side. Cancel one and see what happens to the others:
Cancelling sends an RST_STREAM frame for that one stream number. That's how a cancelled ctx (§08) stops a single call without disturbing anything else on the connection.
What one call looks like on the wire
We pointed the Go client at a server that just prints whatever arrives. Here's the real request for GetProduct(id: 7):
POST /shop.v1.CatalogService/GetProduct ← :path = /<package>.<Service>/<Method>
content-type: application/grpc
te: trailers ← "I can handle trailers" (required by gRPC)
grpc-timeout: 999042u ← the deadline: 999,042 µs left (§08)
authorization: Bearer secret ← your metadata (§10)
user-agent: grpc-go/1.85.0
body: 00 00 00 00 02 08 07A few things stand out:
- Every call is an HTTP
POSTto a path built from the.protonames. HTTP/2 has no separate "first line" like HTTP/1.1'sPOST /path HTTP/1.1; it sends the method and path as special headers named:methodand:path(pseudo-headers). The capture tool printed them as a familiar first line. - Metadata is just HTTP headers. Anything you attach in code shows up here as a header.
- The body isn't one bare protobuf message. Each message gets a 5-byte prefix:
The response comes back on the same stream: headers (:status 200), the response message(s) with the same 5-byte prefix, and finally trailers, headers sent after the body:
grpc-status: 5
grpc-message: product 42 not foundFour ways to call
Since a stream can carry any number of messages, gRPC lets each direction be either "one message" or "a stream of messages". You choose per RPC by writing the word stream in the .proto file:
service CatalogService {
rpc GetProduct(GetProductRequest) returns (GetProductResponse); // unary
rpc ListProducts(ListProductsRequest) returns (stream ListProductsResponse); // server streaming
rpc UpdateStock(stream UpdateStockRequest) returns (UpdateStockResponse); // client streaming
rpc WatchPrices(stream WatchPricesRequest) returns (stream WatchPricesResponse); // bidirectional
}
// The new messages used below (Product, Category and GetProduct… are from §03):
message ListProductsRequest { Category category = 1; } // CATEGORY_UNSPECIFIED = all
message ListProductsResponse { Product product = 1; }
message UpdateStockRequest { int64 product_id = 1; int32 delta = 2; } // +5 arrived, -1 sold
message UpdateStockResponse { int32 updates_applied = 1; }
message WatchPricesRequest { int64 product_id = 1; }
message WatchPricesResponse { int64 product_id = 1; int64 price_cents = 2; }Watch each one on the wire. Blue = headers, purple = messages, green = trailers. When a side marks its last frame with END_STREAM, that's a half-close: "I'm done sending, but I'm still listening."
resp, err := client.GetProduct(ctx, &shopv1.GetProductRequest{Id: 7})The streaming server, in Go
Streaming handlers don't take a request and return a response. Instead they get a stream object to Send and Recv on:
// Server streaming: one request in, Send as many responses as you like.
func (s *catalogServer) ListProducts(req *shopv1.ListProductsRequest,
stream grpc.ServerStreamingServer[shopv1.ListProductsResponse]) error {
for _, p := range s.products {
if err := stream.Send(&shopv1.ListProductsResponse{Product: p}); err != nil {
return err // the client went away
}
}
return nil // returning ends the stream; status OK goes in the trailers
}
// Client streaming: Recv until io.EOF, then answer once.
func (s *catalogServer) UpdateStock(stream grpc.ClientStreamingServer[shopv1.UpdateStockRequest, shopv1.UpdateStockResponse]) error {
var n int32
for {
_, err := stream.Recv()
if errors.Is(err, io.EOF) { // the client half-closed: no more messages
return stream.SendAndClose(&shopv1.UpdateStockResponse{UpdatesApplied: n})
}
if err != nil {
return err
}
n++
}
}
// Bidirectional: Recv and Send independently, in any order.
func (s *catalogServer) WatchPrices(stream grpc.BidiStreamingServer[shopv1.WatchPricesRequest, shopv1.WatchPricesResponse]) error {
for {
req, err := stream.Recv()
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return err
}
p := s.products[req.GetProductId()]
if err := stream.Send(&shopv1.WatchPricesResponse{
ProductId: req.GetProductId(), PriceCents: p.GetPriceCents(),
}); err != nil {
return err
}
}
}Streaming clients, Go and Node
Reading a server stream looks the same in both languages: loop until the stream ends.
stream, err := client.ListProducts(ctx, &shopv1.ListProductsRequest{})
if err != nil {
return err
}
for {
msg, err := stream.Recv()
if errors.Is(err, io.EOF) {
break // server finished: trailers said OK
}
if err != nil {
return err // a real failure, with a status code
}
fmt.Println("listed:", msg.GetProduct().GetName())
}For a client stream, send several messages, then half-close and wait for the single answer:
up, err := client.UpdateStock(ctx)
if err != nil {
return err
}
for _, d := range []int32{5, -1, -2} {
if err := up.Send(&shopv1.UpdateStockRequest{ProductId: 7, Delta: d}); err != nil {
return err
}
}
sum, err := up.CloseAndRecv() // half-close + wait for the one response
if err != nil {
return err
}
fmt.Println("applied:", sum.GetUpdatesApplied()) // applied: 3For bidirectional streams, sending and receiving must run at the same time. In Go that means a second goroutine; in Node, an event listener:
w, err := client.WatchPrices(ctx)
if err != nil {
return err
}
go func() { // send in the background…
for _, id := range []int64{7, 8} {
if err := w.Send(&shopv1.WatchPricesRequest{ProductId: id}); err != nil {
return // the stream broke; the Recv loop below will report the real error
}
}
w.CloseSend() // half-close: no more watch requests
}()
for { // …while receiving here
pc, err := w.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return err
}
fmt.Println("price:", pc.GetProductId(), pc.GetPriceCents())
}Deadlines and cancellation across services
ctx to every call you make while handling it. Then the whole chain of services shares one budget, and when the user gives up, everybody stops.Timeout vs deadline
A Timeout is a duration: "wait at most 2 seconds for this step". A deadline is a moment in time: "this whole request must be done by 12:00:01.000". The difference matters as soon as a request passes through several services.
Here's a worked example. A user's app allows 1 second for "show me my order". The gateway spends 200 ms on its own work, then calls orders. Orders spends 300 ms, then calls inventory. If each service set its own fresh 1-second timeout, the total could reach 200 + 300 + 1,000 ms = 1.5 s. The app would have given up at 1.0 s, and the last half second of work would be thrown away. With one shared deadline, each service only gets what's left: orders gets about 800 ms, and inventory about 500 ms.
gRPC carries the deadline for you
You saw this header in §07:
grpc-timeout: 999042u ← "999,042 microseconds left"When you call with a ctx that has a deadline, the client converts the remaining time into the grpc-timeout header. The server turns that header back into a deadline on the handler's ctx. If the handler passes that same ctx to its own outgoing calls, the next hop gets a grpc-timeout with whatever time is left, and so on down the chain. That's Deadline propagation.
We measured it with three real Go services and a 1-second client deadline:
gateway: 999ms left
orders: 798ms left ← gateway used ~200 ms
inventory: 497ms left ← orders used ~300 ms
client: rpc error: code = DeadlineExceeded desc = context deadline exceeded
inventory stopped: context canceledNow try it yourself. Press Send request with the default settings (inventory takes 1500 ms, so it can't fit in the budget). Then flip the toggle to context.Background() per hop and send again, to see what happens when services break the chain:
In code: pass ctx along, always
The rule is short: use the ctx you were given. Don't start a fresh one with context.Background() inside a handler.
func (s *checkoutServer) Checkout(ctx context.Context, req *shopv1.CheckoutRequest) (*shopv1.CheckoutResponse, error) {
// A brand-new context: forgets the caller's deadline
// AND won't be cancelled when the caller gives up.
c, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := s.catalog.GetProduct(c, &shopv1.GetProductRequest{Id: req.GetProductId()})
// …
}func (s *checkoutServer) Checkout(ctx context.Context, req *shopv1.CheckoutRequest) (*shopv1.CheckoutResponse, error) {
// ctx already carries the caller's deadline (gRPC read it from grpc-timeout).
if dl, ok := ctx.Deadline(); ok && time.Until(dl) < 50*time.Millisecond {
return nil, status.Error(codes.DeadlineExceeded, "not enough time left to try") // fail fast
}
resp, err := s.catalog.GetProduct(ctx, &shopv1.GetProductRequest{Id: req.GetProductId()}) // ← pass ctx on
if err != nil {
return nil, err
}
total := resp.GetProduct().GetPriceCents() * int64(req.GetQuantity())
return &shopv1.CheckoutResponse{TotalCents: total}, nil
}You can still tighten the budget for one step. context.WithTimeout(ctx, 200*time.Millisecond) gives a deadline of "200 ms from now or the parent's deadline, whichever comes first". A child context can never outlive its parent, so wrapping the incoming ctx is always safe. Only context.Background() cuts the chain.
Cancellation travels too
A deadline isn't the only way a call ends early. The client might give up: the user closed the page, or another call in a group already failed. When a Go client's ctx is cancelled, gRPC tells the server by sending an RST_STREAM frame: "abandon stream #N" (you saw it in the multiplexing widget in §07). The server's handler ctx is then cancelled, and so is everything that handler passed that ctx to.
That only helps if your code listens. gRPC calls and well-behaved libraries (database drivers, http.NewRequestWithContext) watch ctx.Done() already. Your own long loops should check it too:
for _, id := range ids {
if err := ctx.Err(); err != nil { // the caller gave up → stop now
return status.FromContextError(err).Err() // context error → DEADLINE_EXCEEDED or CANCELLED
}
// … expensive work for id …
}In Node: deadlines are absolute, and parent propagates
In grpc-js you pass deadline as a point in time (§06). To propagate the deadline from inside a Node server handler, pass the incoming call as parent:
server.addService(shop.CheckoutService.service, {
checkout(call, callback) {
// call.getDeadline() is the caller's deadline; { parent: call } passes it on,
// and cancels this downstream call if our caller cancels.
catalog.getProduct({ id: call.request.productId }, { parent: call }, (err, res) => {
if (err) return callback(err);
callback(null, { totalCents: res.product.priceCents * call.request.quantity });
});
},
});We tested this against the Go server: with a 300 ms client deadline, the downstream call ended after about 292 ms with Cancelled by parent call, and the Go server logged context canceled.
Status codes, error details and retries
The codes, with their HTTP cousins
A gRPC status code plays the same role as an HTTP status code: a short, standard answer to "how did it go?". gRPC's list is smaller and sharper. Here are the ones you'll actually use:
| gRPC code | Number | Means | Like HTTP | Retry? |
|---|---|---|---|---|
OK | 0 | It worked | 200 | — |
CANCELLED | 1 | The caller gave up | 499 (nginx's non-standard "client closed request") | No |
INVALID_ARGUMENT | 3 | The request is wrong (bad email, negative quantity) | 400 | No — fix the request |
DEADLINE_EXCEEDED | 4 | Ran out of time | 504 | Not within the same call — its budget is spent |
NOT_FOUND | 5 | No such thing | 404 | No |
ALREADY_EXISTS | 6 | You tried to create something that exists | 409 | No |
PERMISSION_DENIED | 7 | We know who you are; you may not do this | 403 | No |
RESOURCE_EXHAUSTED | 8 | Quota or Rate limit hit, or a message too big | 429 | Later, with backoff |
FAILED_PRECONDITION | 9 | Not allowed in the current state (cancel a shipped order) | 400 | No |
ABORTED | 10 | Lost a race with a concurrent change (a conflict) | 409 | Yes, from the top (re-read, then retry) |
UNIMPLEMENTED | 12 | This server doesn't have that method | 501 | No |
INTERNAL | 13 | A bug or broken invariant on the server | 500 | No |
UNAVAILABLE | 14 | Temporarily can't serve: restarting, overloaded, connection dropped | 503 | Yes |
UNAUTHENTICATED | 16 | Missing or invalid credentials | 401 | No — fix credentials |
(The other three are UNKNOWN (2), OUT_OF_RANGE (11) and DATA_LOSS (15).)
Returning and reading errors
import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// Server: return a status error instead of a plain error.
return nil, status.Errorf(codes.NotFound, "product %d not found", id)
// Client: get the code back out.
resp, err := client.GetProduct(ctx, req)
if err != nil {
switch status.Code(err) { // codes.OK if err is nil
case codes.NotFound:
return showEmptyState()
case codes.DeadlineExceeded, codes.Unavailable:
return showTryAgain()
default:
return err
}
}
// Or get code + message together:
if st, ok := status.FromError(err); ok {
log.Println(st.Code(), st.Message()) // NotFound product 42 not found
}Rich error details
Sometimes a code and a sentence aren't enough. A form with five bad fields wants to know which five. gRPC lets you attach typed protobuf messages to an error. Google publishes standard ones in the errdetails package (BadRequest, RetryInfo, QuotaFailure, …):
import "google.golang.org/genproto/googleapis/rpc/errdetails"
// Server
// WithDetails only fails if a detail can't be encoded — not a worry with these standard types.
st, _ := status.New(codes.InvalidArgument, "bad request").WithDetails(&errdetails.BadRequest{
FieldViolations: []*errdetails.BadRequest_FieldViolation{
{Field: "id", Description: "must be > 0"},
},
})
return nil, st.Err()
// Client
for _, d := range status.Convert(err).Details() {
if br, ok := d.(*errdetails.BadRequest); ok {
for _, v := range br.GetFieldViolations() {
fmt.Println("bad field:", v.GetField(), v.GetDescription()) // bad field: id must be > 0
}
}
}Retries: let gRPC do it, carefully
Networks drop connections and servers restart, so some calls fail for reasons that a second attempt fixes. The retry rules from Async / Await → Retry with backoff and jitter apply unchanged: back off exponentially, add Jitter, and cap the total. With gRPC, you usually don't write that loop yourself. You declare a retry policy in the Service config, and the client library runs it for every matching call:
const serviceConfig = `{
"methodConfig": [{
"name": [{ "service": "shop.v1.CatalogService" }],
"retryPolicy": {
"maxAttempts": 4,
"initialBackoff": "0.1s",
"maxBackoff": "1s",
"backoffMultiplier": 2,
"retryableStatusCodes": ["UNAVAILABLE"]
}
}],
"retryThrottling": { "maxTokens": 10, "tokenRatio": 0.1 }
}`
conn, err := grpc.NewClient(target,
grpc.WithTransportCredentials(insecure.NewCredentials()), // local demo; use TLS in production (§11)
grpc.WithDefaultServiceConfig(serviceConfig))We ran this against a server that answers UNAVAILABLE twice and then succeeds. The client's call simply returned the product, and the server logged 3 attempts. grpc-js (1.14) accepts the same JSON through the channel option "grpc.service_config".
Interceptors: middleware for RPCs
If you've read Structural patterns → HTTP middleware, this is the same idea with a different function signature. An interceptor gets the request and a handler function (the "rest of the chain"). It can do something before, call handler, do something after, or refuse to call it at all.
auth returns Unauthenticated without ever calling the handler.Two server interceptors: logging and auth
// Logging: runs around every unary call.
func logging(ctx context.Context, req any, info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (any, error) {
start := time.Now()
resp, err := handler(ctx, req) // ← call the next layer (eventually, your method)
slog.Info("rpc", "method", info.FullMethod, "code", status.Code(err), "took", time.Since(start))
return resp, err
}
// Auth: checks a token in the metadata, and stops the call if it's wrong.
func auth(ctx context.Context, req any, info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (any, error) {
md, _ := metadata.FromIncomingContext(ctx)
if vals := md.Get("authorization"); len(vals) == 0 || vals[0] != "Bearer secret" {
return nil, status.Error(codes.Unauthenticated, "missing or bad token") // handler never runs
}
return handler(ctx, req)
}
s := grpc.NewServer(grpc.ChainUnaryInterceptor(logging, auth)) // first listed = outermostRunning the client from §06 against this server logs:
INFO rpc method=/shop.v1.CatalogService/GetProduct code=OK took=9.211µs
INFO rpc method=/shop.v1.CatalogService/GetProduct code=NotFound took=34.427µs
INFO rpc method=/shop.v1.CatalogService/GetProduct code=Unauthenticated took=4.709µsOrder matters. ChainUnaryInterceptor(logging, auth) makes logging the outer layer. So the rejected call above still got logged, with its Unauthenticated code and how long it took. Swap the order, and rejected calls would vanish from your logs.
Streaming RPCs use a second kind, grpc.StreamServerInterceptor, installed with grpc.ChainStreamInterceptor(…). It wraps the whole stream instead of one request/response pair. A unary interceptor does nothing for streaming methods, so if you write auth, write both. The stream version has a different signature, but the same idea:
func authStream(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo,
handler grpc.StreamHandler) error {
md, _ := metadata.FromIncomingContext(ss.Context()) // a stream's ctx comes from ss.Context()
if vals := md.Get("authorization"); len(vals) == 0 || vals[0] != "Bearer secret" {
return status.Error(codes.Unauthenticated, "missing or bad token") // the stream never starts
}
return handler(srv, ss)
}
s := grpc.NewServer(
grpc.ChainUnaryInterceptor(logging, auth),
grpc.ChainStreamInterceptor(authStream),
)We tested it: ListProducts without a token now fails with Unauthenticated, and with the token it streams as before. It also caught us out once more. Server reflection (§11) is a streaming service, so grpcurl … list failed with Unauthenticated too. Exempt /grpc.reflection. methods as well as the health check (see the warning below), or serve them only on an internal port.
Sending metadata from the client
The client attaches metadata to the outgoing ctx:
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer secret")
resp, err := client.GetProduct(ctx, req) // sent as an HTTP/2 headerClients can have interceptors too. Use them to add the token to every call, record client-side Latency, or start a tracing span. Here's the Node version (tested against our Go server):
const authInterceptor = (options, nextCall) =>
new grpc.InterceptingCall(nextCall(options), {
start(metadata, listener, next) {
metadata.set("authorization", "Bearer secret"); // added to every call
next(metadata, listener);
},
});
const client = new shop.CatalogService("localhost:50051", grpc.credentials.createInsecure(),
{ interceptors: [authInterceptor] });In Go, the equivalent is grpc.WithChainUnaryInterceptor(…) on NewClient. For auth tokens specifically there's a purpose-built option, grpc.WithPerRPCCredentials(…). Its credentials can insist on an encrypted connection (the standard OAuth ones do), so a token is never sent in plain text by accident.
Running gRPC in production
1 · Load balancing: the connection is the problem
A Load balancer spreads work across servers. The classic, cheap kind is a Layer 4 (L4) balancer: it picks a backend once per TCP connection, then just copies bytes. That worked for HTTP/1.1, where clients open many short connections. But a gRPC client opens one HTTP/2 connection and sends every RPC down it for hours. So an L4 balancer sends 100% of that client's traffic to one backend, and a backend you add later gets nothing. (The Load Balancing topic covers L4 vs L7 in depth.)
MaxConnectionAge) so clients reconnect and spread out.There are three standard fixes. They're often combined:
Client-side balancing
An L7 proxy
Max connection age
The simplest client-side setup in Go is DNS plus round_robin. In Kubernetes, point it at a headless service, whose DNS name returns every pod's address instead of one shared virtual address:
conn, err := grpc.NewClient("dns:///catalog.shop.svc.cluster.local:50051",
grpc.WithTransportCredentials(creds), // TLS credentials: see part 4 below
grpc.WithDefaultServiceConfig(`{"loadBalancingConfig": [{"round_robin": {}}]}`))2 · Keepalive: noticing dead connections
A long-lived connection can die silently. A NAT router or Firewall in between forgets it after a few idle minutes, or a server machine disappears without closing anything. The client only finds out when a request times out. HTTP/2 keepalive pings detect this early. Servers enforce a minimum ping interval, so the two sides must agree:
// Server
grpc.NewServer(
grpc.KeepaliveParams(keepalive.ServerParameters{
MaxConnectionAge: 5 * time.Minute, // re-balance (fix 3 above)
MaxConnectionAgeGrace: 30 * time.Second, // let in-flight calls finish first
}),
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
MinTime: 10 * time.Second, // clients may ping at most every 10 s
PermitWithoutStream: true,
}),
)
// Client
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 30 * time.Second, // ping after 30 s of silence
Timeout: 10 * time.Second, // no answer in 10 s → treat the connection as dead
PermitWithoutStream: true,
})3 · Message size limits
By default a grpc-go server and client refuse to receive any message larger than 4 MiB (4 × 1,048,576 = 4,194,304 bytes; a MiB is 1,024 × 1,024 bytes). We sent a 5 MiB response and got:
rpc error: code = ResourceExhausted desc = grpc: received message larger than max (5242890 vs. 4194304)You can raise the limit (grpc.MaxRecvMsgSize(…) on the server, grpc.MaxCallRecvMsgSize(…) per call on the client). But usually the error is telling you something. A whole message is held in memory, on both sides, before your code sees any of it. For big results, stream them in pages (§07). For files, stream chunks of ~64 KB, or upload to object storage and pass a URL.
4 · Encryption and identity: TLS and mTLS
Between machines, always use TLS. Inside a company it's common to go further with mutual TLS: the client also presents a Certificate, so the server knows which service is calling. The TLS topic explains certificates and handshakes. In gRPC it's a few lines of config. Every service's certificate is signed by your company's own certificate authority (CA), and each side trusts certificates signed by it:
cert, err := tls.LoadX509KeyPair("server.crt", "server.key") // this server's certificate + private key
// … handle err …
caPEM, err := os.ReadFile("ca.crt") // the company CA (certificate authority) that signs every service's certificate
// … handle err …
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caPEM) { // false = no valid certificate in the file
log.Fatal("ca.crt: no certificates found")
}
creds := credentials.NewTLS(&tls.Config{
Certificates: []tls.Certificate{cert},
ClientCAs: pool,
ClientAuth: tls.RequireAndVerifyClientCert, // ← the "m" in mTLS: clients must show a cert too
MinVersion: tls.VersionTLS13,
})
s := grpc.NewServer(grpc.Creds(creds))
// Client side: grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{
// Certificates: []tls.Certificate{clientCert}, RootCAs: pool }))In a service mesh, the sidecars usually do mTLS for you, and your app speaks plain gRPC to its local sidecar.
5 · Browsers: gRPC-Web and Connect
Browser JavaScript can't read HTTP/2 trailers, and gRPC puts its status there (§07). So browsers can't speak gRPC directly. There are two common answers:
- gRPC-Web: a browser-friendly variant that moves the trailers into the body. Usually a proxy (Envoy) translates it to real gRPC for your servers.
- Connect: libraries (Go, TypeScript and more) whose servers speak gRPC, gRPC-Web and a simple HTTP+JSON protocol on the same port. From the browser you can
fetcha method with a JSON body, no proxy needed, while your backend services still use gRPC with each other.
Either way, the .proto file stays the single source of truth.
6 · Debugging: reflection and grpcurl
Protobuf bytes are unreadable without the schema (§04), so curl is no help. grpcurl is curl for gRPC. It needs the .proto files, or a server with server reflection turned on (reflection.Register(s) in Go). Real output from our server:
$ grpcurl -plaintext localhost:50051 list
grpc.health.v1.Health
grpc.reflection.v1.ServerReflection
grpc.reflection.v1alpha.ServerReflection
shop.v1.CatalogService
$ grpcurl -plaintext -H 'authorization: Bearer secret' -d '{"id": 7}' \
localhost:50051 shop.v1.CatalogService/GetProduct
{
"product": {
"id": "7",
"name": "Go Book",
"priceCents": "1999",
"tags": ["go", "book"],
"category": "CATEGORY_BOOKS"
}
}grpcurl shows messages in the protobuf JSON mapping, which is why the int64 values appear as strings ("7"). -plaintext means no TLS, and -d is the request as JSON. Many teams enable reflection only on internal ports, since it hands your whole API description to anyone who can connect. For a GUI, Postman and similar tools speak gRPC too.
7 · Health checking
Load balancers and Kubernetes need to ask "can you take traffic?". gRPC has a standard answer, the gRPC health checking protocol, and grpc-go ships an implementation:
hs := health.NewServer()
healthpb.RegisterHealthServer(s, hs)
hs.SetServingStatus("shop.v1.CatalogService", healthpb.HealthCheckResponse_SERVING)
// during shutdown: hs.Shutdown() → reports NOT_SERVING, so balancers drain you firstKubernetes can probe it natively (a grpc: probe, in Kubernetes 1.24+), and so can Envoy and cloud balancers. Remember the warning from §10: keep your auth interceptor away from the health service.
Pitfalls gallery
Each of these has caused real outages. Each one points back to the section that explains it.
No deadline on outgoing calls
A downstream service hangs. Without a deadline, every call to it waits forever, and each waiting call holds a goroutine, memory and a stream. At 500 requests/second, that's 30,000 stuck calls after one minute, and your service falls over too, even though nothing was wrong with it. Neither grpc-go nor grpc-js sets a default deadline. Fix: set a deadline where requests enter your system, and pass ctx everywhere (§08).
Reusing or renumbering a field
A teammate deletes price_cents = 3 and later adds stock_count = 3. Every old client now shows the stock count as the price, with no error anywhere (§05). Fix: reserved 3; reserved "price_cents"; on every deletion, and buf breaking in CI (with the WIRE_JSON rule set if you want deletions to pass; the default FILE set rejects every deletion — §05).
An L4 load balancer in front of gRPC
All of one client's RPCs share one HTTP/2 connection, so a connection-level balancer pins them all to one backend. You'll see one pod at 90% CPU next to two idle ones, and scaling up doesn't help (§11). Fix: client-side round_robin, an L7 proxy or mesh, and MaxConnectionAge on servers.
Giant messages
"Return all orders" works in testing, then fails in production with ResourceExhausted: received message larger than max once a customer has 50,000 orders. Raising the 4 MiB limit just moves the cliff, and makes every such call allocate a huge buffer (§11). Fix: paginate (page_size + page_token fields) or use server streaming.
A new connection for every request
func getProduct(id int64) (*shopv1.Product, error) {
conn, _ := grpc.NewClient(addr, opts...) // ✕ a new connection pool per call (and the error is ignored)
defer conn.Close()
// …
}Every call pays for DNS and TCP and TLS handshakes, and you throw away HTTP/2's main benefit. Fix: create one ClientConn at startup and share it; it's safe for concurrent use (§06).
context.Background() inside a handler
It cuts the deadline chain and stops cancellation from reaching downstream services. The client gives up, and your services keep working on its behalf (§08). Fix: always pass the handler's ctx. Wrap it with context.WithTimeout(ctx, …) if you want a tighter limit for one step.
Leaking a client stream
In Go, a client-side stream keeps a goroutine and resources alive until it ends. If you stop reading a server stream halfway (say, you found what you needed) and never cancel its ctx, it leaks. Fix: create a cancellable context for each stream (ctx, cancel := context.WithCancel(ctx); defer cancel()), or read until io.EOF or an error.
Retrying everything, everywhere
Retrying CreateOrder on UNAVAILABLE creates duplicate orders. Retrying at three layers multiplies traffic 27× during an outage (§09). Fix: retry policies only on idempotent methods (or with an idempotency key), only for UNAVAILABLE, at one layer, with retryThrottling.
Plain errors and leaked internals
return nil, err with a raw database error gives the client UNKNOWN: pq: relation "orders" does not exist. That's useless for deciding what to do, and it hands out details of your internals (§09). Fix: map errors to proper codes at the handler boundary, log the details, and return a safe message.
Auth only on unary methods
ChainUnaryInterceptor(auth) doesn't touch streaming RPCs. WatchPrices is wide open (§10). Fix: add a stream interceptor too, and test an unauthenticated call to every method. (And remember the opposite trap: don't block the health check.)
int64 IDs in JavaScript
With longs: Number, an ID above 2⁵³ − 1 silently changes in Node: 9007199254740993 becomes 9007199254740992. The client then asks for the wrong record (§03). Fix: longs: String for IDs, or use string IDs in the schema to begin with.
Check yourself
Predict the answer before you click. Each question names the section that covers it: Q1 and Q6 come from §03–§04, Q2 from §05, Q3 from §08, Q4 from §09, and Q5 from §11.
message Item { int64 id = 1; }
// Encode Item{id: 150}. 150 in binary is 1001 0110.// v1: int64 price_cents = 3;
// v2: (price_cents deleted) int64 stock_count = 3;
// A v2 server sends stock_count = 42 to a v1 app.// client: ctx with a 1 s timeout → gateway.
// gateway handler:
func (s *gw) GetOrder(ctx context.Context, r *Req) (*Resp, error) {
c, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return s.inventory.Check(c, r) // inventory needs 3 s today
}// 1 client app, 1 gRPC connection, 1,000 RPC/s
// → L4 (TCP) load balancer → backends A, B, C
// You add backend D.b, _ := proto.Marshal(&shopv1.Product{
Id: 0, Name: "", PriceCents: 0,
})
fmt.Println(len(b))Cheat sheet
buf generate makes the Go/JS code. Wrap requests and responses in their own messages.reserved deleted numbers and names; run buf breaking in CI.optional when "not set" ≠ 0.grpc-status. Metadata = headers.ctx everywhere. Never context.Background() inside a handler.INVALID_ARGUMENT), try later (UNAVAILABLE), give up (NOT_FOUND). Plain Go errors become UNKNOWN.retryPolicy, UNAVAILABLE only, idempotent methods only, one layer, retryThrottling on.round_robin, an L7 proxy/mesh, or MaxConnectionAge.| I want to… | Use |
|---|---|
| Create a client (Go) | grpc.NewClient(target, grpc.WithTransportCredentials(creds)), once, shared |
| Set a deadline | Go: context.WithTimeout(ctx, d) · Node: { deadline: Date.now() + ms } |
| Propagate a deadline from a handler | Go: pass the handler's ctx · Node: { parent: call } |
| Return an error | Go: status.Errorf(codes.NotFound, "…") · Node: callback({ code: grpc.status.NOT_FOUND, details: "…" }) |
| Read an error code | Go: status.Code(err) · Node: err.code |
| Send a token | Go: metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer …") · Node: new grpc.Metadata() |
| Wrap every call | grpc.ChainUnaryInterceptor(…) and grpc.ChainStreamInterceptor(…) |
| Retry safely | retryPolicy + retryThrottling in the service config |
| Balance across pods | dns:///name + {"loadBalancingConfig":[{"round_robin":{}}]}, or an L7 proxy |
| Poke a server by hand | grpcurl -plaintext host:port list (needs reflection or the .proto files) |
| Check a schema change | buf breaking --against '.git#branch=main' |