Skip to content
Software Atlas

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.

⏱ ~50 min read14 sections8 interactive widgetsGo · JavaScript · Protobuf
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentencegRPC lets a program call a function that lives in another program on another machine, as if it were a normal function call. Both sides share one contract file that says exactly which functions exist and what data they take.

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:

1

A contract (.proto file)

You describe the functions and the data in a small language called Protocol Buffers. A tool turns it into real Go and JavaScript code for both sides (§03).
2

Compact binary messages

Data travels as tight binary bytes, not text, so it's often several times smaller than the same JSON and faster to read (§04).
3

HTTP/2 underneath

Many calls share one connection at the same time, and either side can stream many messages instead of one (§07).

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:

What one “function call” really does
your code
client.GetProduct(ctx, req)
client stub
encode req → bytes
network
bytes travel (≈0.5 ms … seconds, or never)
server stub
bytes → decode req
your handler
GetProduct(ctx, req)
…and the reply makes the same trip backwards.
The two stubs are generated code. They make the call look local — but the middle box can be slow, lose your message, or lose the reply after the server already did the work.

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 .proto contract 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.
Foundations§02

Calling a function on another machine

In one sentenceAn RPC (remote procedure call) dresses up a network request as a function call. That's convenient — and dangerous, because the network breaks the promises a normal function call makes.

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:

  1. turn the arguments into bytes (Serialization),
  2. send those bytes over the network to the right machine,
  3. have that machine turn the bytes back into arguments and run the real function,
  4. 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:

1

It's fast

A local call takes a few nanoseconds. A call to another server in the same data center takes roughly half a millisecond — around 100,000× slower — and across continents, 100 ms or more. A loop of 1,000 remote calls is no longer free.
2

It either runs or it doesn't

Over a network there's a third outcome, Partial failure. Your request reached the server and the order was placed, then the reply got lost. From the client's side that looks exactly like "never arrived".
3

It always comes back

A remote server can hang forever: overloaded, stuck on a lock, or its machine unplugged. Without a Timeout, your code waits forever too.

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 anyway

So 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 + JSONgRPC + protobuf
ContractOptional (OpenAPI docs, often out of date)Required: the .proto file is the API, and code is generated from it
Data formatText: easy to read, larger, slower to parseBinary: compact and fast, but not human-readable without tools
TypingWhatever the JSON happens to containEvery field has a declared type; the generated code enforces it
StreamingAwkward (you need extra tech like Server-sent events or WebSockets)Built in, in both directions (§07)
BrowsersWork everywhere, nativelyNeed gRPC-Web or Connect plus a translation layer (§11)
Debuggingcurl and your browser's dev toolsSpecial tools like grpcurl (§11)
Caching, CDNsHTTP caching works out of the boxMostly not; every call is an HTTP POST, which caches don't store (§07)
Deadlines & cancellationYou build it yourselfBuilt in and passed along across services (§08)
Foundations§03

Protobuf: writing the contract

In one sentenceA .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:

proto/shop/v1/catalog.proto
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-go writes the message types (Product struct, Category constants).
  • protoc-gen-go-grpc writes the client and server stubs for each service.
generate the Go code
# 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.go

The 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:

gen/shop/v1/catalog.pb.go (generated — never edit by hand)
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):

.proto → Go → JavaScript
Hover (or tap) a line of the contract to see what each language gets.
int64 id = 1;
Id int64
id: 7
string name = 2;
int64 price_cents = 3;
repeated string tags = 4;
Category category = 5;
oneof discount { int32 percent_off = 6; int64 cents_off = 7; }
optional string description = 8;
Names change case: snake_case in .proto, CamelCase in Go, lowerCamelCase in JS. int64 → JS number here because of longs: Number.

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.

client.mjs — loading the contract at runtime
// 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.

Practitioner§04

On the wire: tags and varints

In one sentenceProtobuf bytes are a list of (field number, value) pairs. Each field starts with a small tag saying "field 3, read a number next", so the reader never needs the field names — or even to know every field.

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:

1

Tag

Field number and wire type packed into one number: (field_number << 3) | wire_type. For field 1 holding a number, that's (1 << 3) | 0 = 8 = 08. That's the field's Tag.
2

Length (only for wire type 2)

Strings, bytes, lists and nested messages use wire type 2, "length-prefixed". A length comes next, so the reader knows how many bytes belong to this field.
3

Value

Numbers are written as a varint (below). Strings are written as their UTF-8 bytes.

<< 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_cents to 0. The field disappears from the bytes. That's the proto3 zero-value rule from §03.
  • Make id bigger than 127, then bigger than 16,383, and watch it grow from 1 to 2 to 3 bytes.
  • Set id to -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 type sint64. 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. Notice id also moves to the end: Go writes fields in field-number order, not the order you declared them. (Readers must accept any order, though.)
Protobuf encoder
Edit the product. The bytes are the real proto3 encoding; hover a byte to see which field it belongs to.
Protobuf bytes (hex)26 bytes
08071207476f20426f6f6b18cf0f2202676f2204626f6f6b2801
Same data as JSON92 bytes
{"id":7,"name":"Go Book","priceCents":1999,"tags":["go","book"],"category":"CATEGORY_BOOKS"}
protobuf
26 B
JSON
92 B
idtag 08 = (1 << 3) | 0 → field 1, wire type 0 (varint); value 07 = 7
nametag 12 = (2 << 3) | 2 → field 2, wire type 2 (length-prefixed); length 07 = 7 bytes; then the UTF-8 text "Go Book"
price_centstag 18 = (3 << 3) | 0 → field 3, wire type 0 (varint); value cf 0f = 1999
tagstag 22 = (4 << 3) | 2 → field 4, wire type 2 (length-prefixed); length 02 = 2 bytes; then the UTF-8 text "go"
tagstag 22 = (4 << 3) | 2 → field 4, wire type 2 (length-prefixed); length 04 = 4 bytes; then the UTF-8 text "book"
categorytag 28 = (5 << 3) | 0 → field 5, wire type 0 (varint); value 01 = 1 (CATEGORY_BOOKS)
How 1999 became cf 0f: write it in binary, cut it into 7-bit groups from the right, and emit the lowest group first. Every byte except the last gets its top bit set (the "+0x80"), meaning "more bytes follow".
11001111 = cf00001111 = 0f

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:

encode a message
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
}
Practitioner§05

Changing the contract safely

In one sentenceDuring every deploy, old and new versions of your code talk to each other. Protobuf makes that work if you follow one rule above all: a field number, once used, means that field forever.

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:

Deploy simulator: old client vs new server
Pick a change to the schema. The server is redeployed with v2; the client still runs v1. Results match what real Go protobuf does.
v1 — the client's copy
message Product {
int64 id = 1;
string name = 2;
int64 price_cents = 3;
}
v2 — the server's copy
message Product {
int64 id = 1;
string name = 2;
int64 price_cents = 3;
+ string description = 4;
}
v2 server sends
1· id = 7
2· name = "Go Book"
3· price_cents = 1999
4· description = "A book"
On the wire22 bytes
08071207476f20426f6f6b18cf0f22064120626f6f6b
Only numbers, wire types and values travel. No field names.
v1 client decodes
id = 7
name = "Go Book"
price_cents = 1999
unknown: field 4 (8 bytes) kept as unknown
✅ Safe. The old client has never heard of field 4, so it skips it (it reads the length and jumps over the bytes). Go even keeps those bytes as unknown fields and writes them back out if the message is forwarded, so nothing is lost on the way through.

The rules, from the simulator

ChangeSafe?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, ❌ JSONNames 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.

Practitioner§06

Your first service: server and client

In one sentenceA gRPC server is a Go type with one method per RPC; the client is a generated stub you call like an object. The .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:

The whole project on one screen
configgo.mod
module example.com/shop — the start of every Go import path
configbuf.yaml
modules: path: proto — where .proto paths start
configbuf.gen.yaml
which plugins to run, and where output goes (gen/)
you write proto/shop/v1/catalog.proto
the contract — the only shared file
generated gen/shop/v1/catalog.pb.go
Product, requests, responses (protoc-gen-go)
generated gen/shop/v1/catalog_grpc.pb.go
CatalogServiceClient / Server stubs (protoc-gen-go-grpc)
you write cmd/server/main.go
imports example.com/shop/gen/shop/v1 → go run ./cmd/server
you write cmd/client/main.go
imports example.com/shop/gen/shop/v1 → go run ./cmd/client
you writeclient.mjs
reads proto/…/catalog.proto at runtime → node client.mjs
The arrow of dependencies: catalog.proto → buf generate → gen/, and both Go programs import 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 server

The 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.

cmd/server/main.go
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

cmd/client/main.go
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:

client.mjs
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();
Practitioner§07

HTTP/2 underneath, and the four call types

In one sentenceEvery gRPC call is one HTTP/2 stream: request headers, then any number of length-prefixed messages in each direction, then trailers carrying the final status. Allowing "any number" of messages is what gives gRPC its 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:

Three calls, one connection
Frames from three streams take turns on one TCP connection, then get sorted back into separate calls by their stream number.
The wire (one connection)0 frames in flight
press ▶ to start the three calls
Oldest frame first: the leftmost one arrives next, and new frames join on the right. Each frame carries its stream number (#1, #3, #5), and TCP keeps them in order.
#1 GetProduct · unary0/5
#3 ListProducts · server stream0/8
#5 WatchPrices · bidi stream0/7
Cancel #3 while it's still running.

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 07

A few things stand out:

  • Every call is an HTTP POST to a path built from the .proto names. HTTP/2 has no separate "first line" like HTTP/1.1's POST /path HTTP/1.1; it sends the method and path as special headers named :method and :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 body of a real GetProduct request (captured from grpc-go)
00
compressed?
1 byte: 0 = no
00 00 00 02
length
4 bytes: message is 2 bytes
08 07
protobuf message
field 1 = 7 (id: 7)
Every gRPC message is sent with a 5-byte prefix, so the receiver knows where one message ends and the next begins — that's what makes streams of many messages possible.

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 found

Four 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."

The four call types, on one HTTP/2 stream
Every call — even a long stream — is one HTTP/2 stream: headers, any number of messages each way, then trailers with the status.
rpc GetProduct(GetProductRequest) returns (GetProductResponse);
clientserver
0 / 5 framesOne request, one response — like a normal function call.
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.go — the three streaming RPCs
// 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.

server-streaming client
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:

client-streaming client
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: 3

For bidirectional streams, sending and receiving must run at the same time. In Go that means a second goroutine; in Node, an event listener:

bidi client
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())
}
Practitioner§08

Deadlines and cancellation across services

In one sentenceGive every call a deadline, and pass the incoming 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 canceled

Now 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:

Deadline propagation, three services deep
The client allows a total budget. Watch what each service does when time runs out.
gateway
gets ≈1.0s
orders
gets ≈800ms
inventory
gets ≈500ms
0s1s2s3s
deadline
t = 0 msworkingwaiting for the next servicework nobody will use
Press “Send request”. Try inventory at 1500 ms with each setting of the toggle.

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.

✕ Breaks the chain
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()})
    // …
}
✓ Shares the caller's budget
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:

a Node server that calls another service
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.

Practitioner§09

Status codes, error details and retries

In one sentenceEvery gRPC call ends with one of 17 status codes. Pick the code that tells the caller what to do next: fix the request, wait and retry, or give up. Retry automatically only on temporary failures, only for safe methods, and only within a budget.

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 codeNumberMeansLike HTTPRetry?
OK0It worked200—
CANCELLED1The caller gave up499 (nginx's non-standard "client closed request")No
INVALID_ARGUMENT3The request is wrong (bad email, negative quantity)400No — fix the request
DEADLINE_EXCEEDED4Ran out of time504Not within the same call — its budget is spent
NOT_FOUND5No such thing404No
ALREADY_EXISTS6You tried to create something that exists409No
PERMISSION_DENIED7We know who you are; you may not do this403No
RESOURCE_EXHAUSTED8Quota or Rate limit hit, or a message too big429Later, with backoff
FAILED_PRECONDITION9Not allowed in the current state (cancel a shipped order)400No
ABORTED10Lost a race with a concurrent change (a conflict)409Yes, from the top (re-read, then retry)
UNIMPLEMENTED12This server doesn't have that method501No
INTERNAL13A bug or broken invariant on the server500No
UNAVAILABLE14Temporarily can't serve: restarting, overloaded, connection dropped503Yes
UNAUTHENTICATED16Missing or invalid credentials401No — fix credentials

(The other three are UNKNOWN (2), OUT_OF_RANGE (11) and DATA_LOSS (15).)

Returning and reading errors

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".

Practitioner§10

Interceptors: middleware for RPCs

In one sentenceAn interceptor is gRPC's version of HTTP middleware: a function that wraps every call, so logging, auth and metrics live in one place instead of in every handler.

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.

Interceptors wrap the handler like layers of an onion
→ beforeloggingafter ←
→ beforeauthafter ←
→ beforemetricsafter ←
your GetProduct handler
A request goes in through every layer (top to bottom), the handler runs, and the response comes back out through the same layers in reverse. Any layer can stop early — 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 = outermost

Running 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µs

Order 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:

client metadata
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer secret")
resp, err := client.GetProduct(ctx, req) // sent as an HTTP/2 header

Clients 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):

a grpc-js client interceptor
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.

Staff-level§11

Running gRPC in production

In one sentencegRPC's strengths — one long-lived connection, binary messages, streams — are also what trips up infrastructure built for HTTP/1.1 and JSON. Balance per request, keep connections honest, cap message sizes, encrypt everything, and give your tools a way in.

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.)

One connection, many RPCs: who gets the load?
One client app sends RPCs over a single long-lived HTTP/2 connection. Compare a connection-level (L4) balancer with per-request balancing.
client app
1 TCP connection to the balancer's address
The L4 balancer chose a backend once, when the connection opened. It can't see the RPCs inside — just bytes.
backend A
0
backend B
0
backend C
0
Every RPC lands on backend B, and a new backend D gets nothing — the connection is already open and never moves. With many clients it evens out only roughly, and new servers stay idle until clients reconnect. Fixes: balance per request (toggle), or have servers close connections now and then (MaxConnectionAge) so clients reconnect and spread out.

There are three standard fixes. They're often combined:

1

Client-side balancing

The client gets all backend addresses (from DNS or a central config server), connects to each one, and picks a backend per RPC (Client-side load balancing). No proxy hop. Needs a smart client.
2

An L7 proxy

A proxy that understands HTTP/2 — Envoy, Linkerd, nginx with gRPC support, or a cloud L7 balancer — receives each RPC and forwards it to the next backend (Layer 7 (L7) load balancer). Often deployed as a Service mesh Sidecar.
3

Max connection age

The server closes each connection after a few minutes (gracefully: Max connection age). Clients reconnect and land somewhere new. A cheap patch that makes even L4 balancing roughly even over time.

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 fetch a 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 first

Kubernetes 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.

Practitioner§12

Pitfalls gallery

Each of these has caused real outages. Each one points back to the section that explains it.

#1

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).

#2

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).

#3

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.

#4

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.

#5

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).

#6

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.

#7

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.

#8

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.

#9

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.

#10

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.)

#11

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.

Practitioner§13

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.

QUESTION 1 / 6
Encode it by hand
message Item { int64 id = 1; }
// Encode Item{id: 150}. 150 in binary is 1001 0110.
QUESTION 2 / 6
A teammate's "cleanup"
// v1:  int64 price_cents = 3;
// v2:  (price_cents deleted)  int64 stock_count = 3;
// A v2 server sends stock_count = 42 to a v1 app.
QUESTION 3 / 6
Who's still working?
// 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
}
QUESTION 4 / 6
What to retry automatically
QUESTION 5 / 6
Scaling out didn't help
// 1 client app, 1 gRPC connection, 1,000 RPC/s
//   → L4 (TCP) load balancer → backends A, B, C
// You add backend D.
QUESTION 6 / 6
How big is it?
b, _ := proto.Marshal(&shopv1.Product{
    Id: 0, Name: "", PriceCents: 0,
})
fmt.Println(len(b))
Answered 0 / 6
Foundations§14

Cheat sheet

📞
Remote ≠ localAn RPC can be slow, fail, or succeed with its reply lost. Design for all three.
📜
The .proto is the contractMessages + services in one file; buf generate makes the Go/JS code. Wrap requests and responses in their own messages.
🔢
Field numbers are foreverOnly numbers travel. Never reuse, renumber or retype; reserved deleted numbers and names; run buf breaking in CI.
0️⃣
Defaults aren't sent0, "", false and empty lists cost zero bytes, and read back as defaults. Use optional when "not set" ≠ 0.
🛣️
One call = one HTTP/2 streamHeaders → length-prefixed messages → trailers with grpc-status. Metadata = headers.
⏱
Deadline on every callSet it at the edge, pass ctx everywhere. Never context.Background() inside a handler.
🚦
Codes tell callers what to doFix the request (INVALID_ARGUMENT), try later (UNAVAILABLE), give up (NOT_FOUND). Plain Go errors become UNKNOWN.
🔁
Retry narrowlyService-config retryPolicy, UNAVAILABLE only, idempotent methods only, one layer, retryThrottling on.
⚖️
Balance per requestL4 balancers pin a connection. Use round_robin, an L7 proxy/mesh, or MaxConnectionAge.
🧰
Production basicsmTLS, keepalive set on both sides, stream big data instead of raising the 4 MiB limit, health service + reflection + grpcurl.
I want to…Use
Create a client (Go)grpc.NewClient(target, grpc.WithTransportCredentials(creds)), once, shared
Set a deadlineGo: context.WithTimeout(ctx, d) · Node: { deadline: Date.now() + ms }
Propagate a deadline from a handlerGo: pass the handler's ctx · Node: { parent: call }
Return an errorGo: status.Errorf(codes.NotFound, "…") · Node: callback({ code: grpc.status.NOT_FOUND, details: "…" })
Read an error codeGo: status.Code(err) · Node: err.code
Send a tokenGo: metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer …") · Node: new grpc.Metadata()
Wrap every callgrpc.ChainUnaryInterceptor(…) and grpc.ChainStreamInterceptor(…)
Retry safelyretryPolicy + retryThrottling in the service config
Balance across podsdns:///name + {"loadBalancingConfig":[{"round_robin":{}}]}, or an L7 proxy
Poke a server by handgrpcurl -plaintext host:port list (needs reflection or the .proto files)
Check a schema changebuf breaking --against '.git#branch=main'
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in NetworkingLoad Balancing
Read →