gRPC and Protocol Buffers
RPC Moves the API Surface
RPC stands for remote procedure call. A client calls a named operation on a remote service and sends it a typed request message. The service sends back a typed response message, or a stream of them.
The word "procedure" sounds older than the code most people write today, but the model is current. GetOrder, CreateInvoice, ReserveInventory, ListOrderEvents. Every one of those operation names is public API surface, along with the request and response message types behind them.
gRPC is an RPC system built on Protocol Buffers, generated client and server code, HTTP/2 transport, metadata, status codes, deadlines, and a handful of streaming call types. Node services usually use @grpc/grpc-js, the pure JavaScript gRPC implementation, paired with either code generated by protoc or runtime descriptors loaded through @grpc/proto-loader.
The resource model from Chapter 12.1 starts from a different set of questions. It asks which resources exist, which representations expose them, and which HTTP operations read or change their state. gRPC starts somewhere else. It asks which service holds each operation, what request message reaches it, and what response message comes back.
Both models can describe the same backend behavior.
HTTP API: GET /orders/{orderId}
gRPC API: OrdersService.GetOrder(GetOrderRequest) -> Order
GraphQL: query { order(id: "...") { id totalCents } }The design pressure lands in a different place each time. An HTTP API puts most of the meaning in the route and method. In GraphQL it moves to the selection set. With gRPC, the .proto file carries it, and the generated code pushes that meaning out into clients and servers.
gRPC is a good fit for service-to-service APIs where both sides can share generated artifacts and speak HTTP/2 cleanly. The friction shows up elsewhere. Public browser APIs, quick debugging by hand, and any team that wants people to read payloads with curl and plain logs all push back against it. gRPC-Web covers the browser case, but it pulls in its own proxy and runtime constraints, so treat that as a separate decision.
An RPC method can still map onto a resource. GetOrder reads one order, ListOrders pages through a collection, and CancelOrder changes an order's state. What changes is how the contract gets written down. The method name is the operation, the message types are the input and output schemas, and the outcome comes back as a gRPC status code in trailers instead of ordinary HTTP status handling.
gRPC calls almost always travel over HTTP/2, but the code you write deals in gRPC concepts rather than HTTP/2 ones. Underneath, the transport is moving HTTP/2 streams, headers, DATA, flow control, and trailers, and Chapter 11 covers those mechanics. This chapter stays with how gRPC maps service methods and protobuf messages onto that transport.
The .proto File Is the Source Artifact
Everything in a gRPC service starts from a .proto file. The file names a package, defines message types, and can define gRPC services with their RPC methods. Proto3 is the protobuf language version behind most current gRPC APIs, and you spot it at the top of the file as syntax = "proto3";.
syntax = "proto3";
package orders.v1;
message GetOrderRequest {
string order_id = 1;
}GetOrderRequest is a message type, which is a name for a structured payload made of fields. Each field has a type, a name, and a field number. In that snippet, order_id is a string field carrying field number 1.
The field number is part of the wire contract, while the name belongs to the source contract and the generated code. JSON has no equivalent split, and this one drives almost every protobuf rule you will meet later.
The package name gives .proto symbols a protobuf namespace. For services, it also feeds the fully qualified service name. A package like orders.v1 carries versioning intent right there in the source. Some toolchains map it into generated namespaces or package objects, and gRPC libraries use it in the HTTP/2 :path value they derive for a call. Import paths are a separate thing, coming from file layout, .proto import declarations, --proto_path, and generator-specific output settings.
Now add an order response.
message Order {
string id = 1;
int64 total_cents = 2;
string currency = 3;
repeated string item_ids = 4;
}Order has four fields. repeated means the field can appear zero or more times, exposed as a list value in the generated object model. int64 needs extra care in JavaScript, because the safe-integer limit is smaller than a signed 64-bit integer. Depending on the loader or code-generator options, Node code might see 64-bit fields as strings, Long objects, BigInts, or plain numbers. That decision lives in the generated-code configuration and the API contract review, since changing it can break callers even when the .proto file never moves.
A 64-bit field decoded to a plain JavaScript number loses precision above 2^53, so large IDs and *_cents amounts corrupt without ever throwing. Pick longs: String (or BigInt) on purpose and treat that pick as part of the wire contract.
Changing the representation later breaks every caller that compares IDs, hashes them, or writes them to JSON logs, even though the .proto file never moved.
A service definition groups RPC methods together.
service OrdersService {
rpc GetOrder(GetOrderRequest) returns (Order);
rpc ListOrderEvents(ListOrderEventsRequest)
returns (stream OrderEvent);
}OrdersService is the service definition, and GetOrder and ListOrderEvents are its RPC methods. GetOrder takes one request message and returns one response message. ListOrderEvents takes one request message but returns a server-side response stream. That stream keyword changes the kind of call at the gRPC layer, which also changes the Node object you get in the handler or the client.
Plain protobuf serialization needs only the message definitions. gRPC adds the service definitions on top, and those are what turn a set of messages into an actual RPC API.
Add the event messages.
message ListOrderEventsRequest {
string order_id = 1;
}
message OrderEvent {
string id = 1;
string type = 2;
}That is a small schema, and it is already a real contract. A caller can read the service name, the method names, the request and response types, the field names and numbers, and which methods stream. All of that is readable before anyone writes a line of Node.
Request and Response Messages Have Lifetimes
Every gRPC method takes exactly one request message type and returns exactly one response message type, or a response stream type. Even a method whose only obvious input is an ID still gets a request message of its own.
message GetOrderRequest {
string order_id = 1;
}That wrapper leaves room for the method to grow later. A future caller might want include_items, read_mask, tenant_id, or a consistency hint. You add field 2 to GetOrderRequest, newer callers get that control, and the method contract stays stable for everyone else.
Resist sharing request messages across methods just because the fields match today.
message OrderIdRequest {
string order_id = 1;
}That looks tidy on day one. Soon GetOrder needs include_items, CancelOrder needs a reason, and ListOrderEvents needs an after_event_id. One shared OrderIdRequest now drags three unrelated methods through a single message type. Their fields happen to line up today. The three methods do not make the same promise, and a shared message hides that difference.
A message scoped to one method sidesteps all of that.
message CancelOrderRequest {
string order_id = 1;
string reason = 2;
}That message belongs to one RPC method, and its fields can change as that method's semantics change. The generated call site also shows the intent directly, cancelOrder({ order_id, reason }), with input that is local to the method.
Response messages need the same discipline. Returning the domain object directly is fine for simple reads, but write methods often want a response envelope with commit state, server-generated IDs, or normalized output.
message CancelOrderResponse {
Order order = 1;
string cancellation_id = 2;
}The response message carries the result of the operation, and the status code stays focused on the RPC outcome. A status of OK tells the caller the RPC itself succeeded. The response fields describe which domain state actually changed.
Field naming carries generated-code consequences too. Protobuf style is usually snake_case field names. JavaScript loaders often hand you lower-camel properties instead, unless you configure them to keep case. So order_id can surface as orderId in one generated client and order_id in another. Pick the loader settings once per codebase and publish them with the client package.
Scalar choices deserve the same review. Use string id = 1 when an ID should stay opaque. Use bytes payload = 1 for binary data, which usually arrives as a Buffer in Node. An int64 field needs an explicit JavaScript representation before you can trust it. And double brings floating-point rounding into any money field someone uses it for, so for backend APIs an int64 total_cents or a decimal string is almost always easier to reason about than a float.
Repeated fields set caller expectations too. A repeated response field tends to make callers assume the whole collection fits in memory. A server-streaming RPC delivers the response as a sequence. Both can hold the same item type and still be different API contracts. Use repeated for collections you know are bounded, and switch to streaming once size, latency, or producer timing makes a single response impractical.
Message design is the part gRPC will not do for you. The protobuf compiler generates code for almost any legal schema you give it. Splitting methods sensibly, deciding how long each message lives, handling presence, choosing the JavaScript numeric type, these are author decisions, and they belong in API review.
Generated code comes out of that file either way. A static workflow runs protoc and its plugins to emit JavaScript or TypeScript artifacts before runtime. A runtime-loaded workflow has Node read the .proto at startup and build descriptors and stubs from it in memory. Both bind caller code and handler code to the same contract. They differ only in when and where the build happens. The API model is identical either way.
Static generation tends to suit services that publish typed packages, pin schema artifacts, and want compile-time help from TypeScript. Runtime loading fits smaller services, examples, internal tools, and teams that would rather keep generated files out of the repository. Even then the behavior is still generated. It just gets built in memory from descriptors rather than checked in as source.
From the Node program's point of view, the loader options are part of the contract. @grpc/proto-loader can change casing, enum representation, 64-bit integer representation, default-field materialization, and oneof handling. Two clients can read the exact same .proto and still expose different JavaScript structures when their loader options differ.
const def = protoLoader.loadSync("orders.proto", {
keepCase: true,
longs: String,
enums: String,
defaults: true
});That configuration tells the loader to keep protobuf field names like order_id, turn int64 values into strings, turn enums into strings, and include default values in decoded objects. Those choices leak straight into caller code. Switching longs: String to BigInt output later can break code that compares IDs, serializes responses, or writes values into JSON logs.
The .proto file is the contract across languages, and the generated JavaScript structure is the contract inside your own process. When a Node service publishes a gRPC client, both need review.
Proto3 also gives fields default behavior on top of all that. A scalar field can be absent on the wire and still read as its default in generated code, which is "" for a string, 0 for numeric scalars, and false for a bool. Modern proto3 lets you mark a field optional for the cases where presence itself carries meaning. Presence is an API decision, and it causes the most trouble in PATCH-style commands, sparse updates, and anywhere 0 is a real domain value.
Field numbers, presence, scalar choices, and generated-code options all outlive the first implementation. A .proto change can break clients that already shipped, so it deserves careful, deliberate review before it lands.
Field Numbers Are the Wire Contract
Protocol Buffers encode messages as binary records. Each record carries a field number, a wire type, and a payload. The field name never appears in the binary. The decoder leans on the message type definition from the .proto file to turn those field numbers back into names and generated object properties.
Here is a small message.
message GetOrderRequest {
string order_id = 1;
}If order_id is "ord_123", protobuf writes a record for field number 1. The text order_id lives in the schema and the generated code, nowhere on the wire. The wire type says how the payload is framed. A string is length-delimited, length first and then the bytes. The decoder knows field 1 is order_id because it already holds the GetOrderRequest definition.
A wire type is the small protobuf encoding category that tells a decoder how to read the next value. The common ones are varint for integers and booleans, fixed 32-bit, fixed 64-bit, and length-delimited for strings, bytes, nested messages, and packed repeated fields. The wire type is also what lets a decoder skip over a field it does not recognize.
The tag packs the field number and the wire type together.
tag = (field_number << 3) | wire_typeFor string order_id = 1, the field number is 1 and the wire type is 2, the code for length-delimited. The tag works out to (1 << 3) | 2, which is 10, or 0x0a in a single byte. After that byte comes the length of the string, then the UTF-8 bytes.
0a 07 6f 72 64 5f 31 32 33Read that as field 1, length-delimited, length 7, then the bytes for ord_123. The name order_id lives only in the .proto file, and the wire carries just the number.
That one detail drives almost every protobuf compatibility rule. Rename order_id to id but keep field number 1, and only the generated code and the source readability change. Old wire payloads still decode into field 1. Change the field number from 1 to 2 and the story is different. That 2 is a brand new wire field. Old decoders no longer recognize the record you send, and new decoders no longer find the data old senders wrote under field 1. The wire contract itself has changed.
JSON has a different failure mode. A JSON object puts its property names right in the payload.
{ "order_id": "ord_123" }Rename the property and the wire payload changes, because in JSON the name is the key. Protobuf uses the field numbers as its wire keys instead. The names still matter for people, for generated code, for documentation, and for text formats, but binary compatibility depends only on the numbers.
Unknown fields come out of the same mechanism. A newer server might send field 5 in an Order. An older client whose .proto only knows fields 1 through 4 reads the four it knows and skips field 5, because the wire type tells it exactly where that field's payload ends. Runtime stacks differ in how much unknown-field data they keep and expose, especially across JavaScript loaders, so application code should treat an unknown field as data it cannot reach. Even so, the older client still parses the rest of the message without trouble.
Missing fields work a little differently. Omit currency, and a proto3 decoder still builds an Order object and fills in the default string value, unless the field is declared optional and the generated runtime exposes presence. Domain validation still lives in your service layer. Protobuf decoding only confirms that the binary payload matches the message structure. Confirming that "USD" is an accepted currency, or that total_cents fits the order's state, is work your own code has to do.
Repeated fields encode as repeated records, or as a packed representation for the scalar types that qualify. The generated object makes them look like arrays. The wire representation stays field-number based.
Nested messages are length-delimited payloads too. The outer record gives field N, length L, and then the nested message bytes fill those L bytes. The decoder steps into the nested message using the nested type definition, and the same structure repeats however deep the messages go. Everywhere, the wire holds numbers and wire types while the schema supplies the names and types.
The encoding is compact, which is good, but it is also much harder to read in ordinary logs. Binary payloads make protocol-aware tooling far more valuable. A proxy, logger, or traffic capture that prints JSON bodies cleanly may show gRPC as nothing but opaque DATA, unless it understands gRPC message framing and the protobuf schema. That is a genuine operations cost, and it hurts most during an incident, when the fastest debugging tool you have is usually whatever is already running on the host.
gRPC adds one more thing at the byte level, and this is the part that makes streaming possible. In front of each protobuf message it writes a five-byte prefix, one compression-flag byte plus four bytes of message length, with the protobuf payload right after. HTTP/2 DATA frames carry those bytes, but where one DATA frame ends has nothing to do with where one gRPC message ends. One protobuf message can spread across several DATA frames, and several small gRPC messages can sit inside a single DATA frame.
So the gRPC runtime is doing two parsing jobs below your handler. First it assembles bytes from the HTTP/2 stream into complete length-prefixed gRPC messages. Then it decodes each protobuf payload into the requested message type. When your server-streaming handler writes OrderEvent objects, the runtime serializes each object, prefixes each message, and hands the bytes to the HTTP/2 stream. The peer runs the same steps in reverse.
The layers stack up like this.
HTTP/2 DATA bytes
gRPC message prefix
protobuf message bytes
generated message object
handler argument or stream eventThe work splits cleanly across those layers. HTTP/2 handles stream flow control and frame delivery. On top of it, gRPC tracks message length, the compression flag, status, and trailers. Protobuf is responsible for field numbers, wire types, and decoding the message body. The generated code gives you the local object structure to work with, and your own application code is left with the one job none of the lower layers can do, deciding whether the data is actually valid.
Those same layers show up when something breaks. A malformed HTTP/2 stream is a transport error. A wrong gRPC message length is a framing error one level up. Invalid protobuf bytes are a decoding error above that. A valid protobuf message that arrives with an empty order_id is an application error, even though every layer below it did its job. A report that flattens all four into one generic bad request loses the most useful detail, which is the layer the fault came from.
One Unary Call on the Wire
client.getOrder() turns into an HTTP/2 request with gRPC-specific headers and a binary body. The route path is built from the fully qualified service name and the method name.
:method: POST
:path: /orders.v1.OrdersService/GetOrder
content-type: application/grpc+proto
te: trailers
grpc-timeout: 500mThe exact timeout value tracks whatever deadline the client library computed. gRPC puts a timeout duration on the wire. Your JavaScript Date object never leaves the client, and the server only sees how much time is left, delivered as protocol metadata alongside its own local call state.
After the headers, the request body carries exactly one length-prefixed protobuf message for a unary GetOrder. Even a request type with no fields goes across as a framed gRPC message. Most real request messages carry something, an ID, a correlation value, pagination fields, or filters. An empty request message is legal, and it still travels as one framed gRPC message like any other.
A successful unary response comes back as response headers, one length-prefixed protobuf Order, and then trailers.
:status: 200
content-type: application/grpc+proto
grpc-status: 0The gRPC status arrives at the very end. A unary response still needs trailers, because the runtime can detect an error after the response headers have already left. For the failure case, a server can send trailers-only, a single header block that closes the RPC and carries grpc-status with no response message at all.
This mismatch trips people up in real systems. An HTTP access log can record 200, because the HTTP/2 response really did carry the gRPC bytes successfully. The gRPC client, meanwhile, throws NOT_FOUND, because the final trailers carried grpc-status: 5. So for a gRPC service, success metrics have to read the gRPC status, the method name, and the deadline outcome. An HTTP 200 on its own only tells you the transport worked.
A gRPC failure often travels inside an HTTP/2 response with :status: 200. The real outcome is grpc-status in the trailers. Any access log, dashboard, or SLO that reads HTTP status alone shows success while clients receive NOT_FOUND or INTERNAL.
Build success metrics and alerts on the gRPC status code, the method name, and the deadline outcome, not on HTTP status.
The request path also shows how service and method names turn into routing keys. A gRPC server matches /orders.v1.OrdersService/GetOrder, decodes the request body as GetOrderRequest, and dispatches to the handler registered for that method. If you rename the service or the method, that is a breaking API change on its own, even when the request and response message types stay byte-for-byte identical.
Middleboxes along this path can interfere. A proxy has to preserve HTTP/2 behavior, trailers, and the content type. Real deployments vary a lot here, terminating TLS before the gRPC server, routing by :path, or enforcing header limits that count metadata against you. These are platform concerns, but they reach API authors anyway, since metadata size, deadline headers, and streaming behavior all travel across that same path.
Generated Stubs Connect Contract to Node
A generated client stub is the client-side object that exposes the service's RPC methods. It already holds the service name, the method names, the request and response serializers, which methods stream, and how the channel behaves. You hand it plain JavaScript objects, or generated message instances, depending on the toolchain. It turns them into gRPC calls.
const client = new orders.OrdersService(
"localhost:50051",
grpc.credentials.createInsecure()
);That object does more than group some functions together. It holds a target address and credentials, and there is a gRPC channel underneath it. In many Node codebases the generated client constructor is what creates or acquires that channel, so reusing the client and reusing the channel usually amount to the same advice.
The call itself is only a few lines.
client.getOrder({ order_id: "ord_123" }, (err, order) => {
if (err) throw err;
console.log(order.total_cents);
});Behind that one line, the stub validates the method contract, serializes the request message, starts the RPC, and decodes the response. Your callback receives one of two things, a gRPC error object or the decoded Order. Method casing depends on the code generator and the loader options, and many Node paths expose lower-camel methods even when the .proto declares GetOrder.
On the server side, the matching piece is the generated server stub, a descriptor that tells a gRPC server which methods exist and how to decode incoming calls. In runtime-loaded Node examples, you pass that descriptor to server.addService() along with an implementation object.
server.addService(orders.OrdersService.service, {
getOrder(call, callback) {
callback(null, loadOrder(call.request.order_id));
}
});The implementation function receives a call object. For a unary method, call.request holds the decoded request message, and the callback sends back either an error status or a response message. The gRPC library has already handled transport parsing and serialization by this point. What is left for your handler is the application semantics.
That division can hide validation gaps. By the time your handler runs, the protobuf decoder has already accepted the message. A missing order_id arrives as "", a total_cents that is too large arrives in whatever JavaScript representation your loader chose, and a syntactically valid enum value can still be one your business rule rejects. The schema validation from Chapter 12.2 has not gone away. It has split into two parts, protobuf parsing and the semantic checks your handler still has to run.
Server registration catches some contract drift early. Say the service descriptor declares getOrder but the implementation object never defines it. The server can fail at startup, or return unimplemented status for those calls, depending on the library path. And when a client calls a method the server schema does not have, the call comes back as an RPC failure instead of a route miss in some framework router.
The trip through Node moves through these stages.
client method -> serialize request
channel -> HTTP/2 stream
server descriptor -> decode request
handler -> serialize response
trailers -> client callbackEach arrow in that list holds some state. The client stub keeps the method metadata, the serializer produces the protobuf bytes, and the channel tracks the connection. A single HTTP/2 stream carries a single RPC from end to end. On the way back, the server descriptor selects the handler and the final trailers deliver the gRPC status.
Node developers often treat generated and runtime-loaded stubs as different things, because loading at startup seems less like code generation than a checked-in file does. The descriptor still comes from the .proto, and the runtime still builds method definitions, serializers, deserializers, and call handlers from it. A build step or a startup loader changes only the moment when that binding happens. Either way, gRPC ends up needing the same pieces on both sides.
Server startup adds a lower layer.
const server = new grpc.Server();
server.addService(orders.OrdersService.service, impl);
server.bindAsync(addr, creds, err => {
if (err) throw err;
});addService() connects the service descriptor to your handler functions. bindAsync() does something different, binding the server to an address with credentials, and in current @grpc/grpc-js a successful bindAsync() also moves the server onto the accepting path. Older examples still call start() afterward, though current versions mark that call deprecated.
Other toolchains give you an abstract base class or a typed registration helper, while the Node runtime-loaded path gives you a plain object map. The underlying contract is identical in both cases. Every RPC method needs an implementation that uses the right call pattern.
The call pattern decides the handler signature.
unary: call, callback
server streaming: call
client streaming: call, callback
bidirectional: callThe callback only appears when the server sends one final response message. Streaming calls read and write through the call object instead. A handler that uses the wrong pattern for its method is a contract bug right at the RPC layer. The runtime might reject registration, throw mid-call, or surface UNIMPLEMENTED or UNKNOWN, depending on where the mismatch lands.
Promise wrappers are everywhere in Node gRPC code. Many libraries, and plenty of hand-written helpers, turn callback-based unary calls into Promise-returning methods. That wrapper changes only the JavaScript ergonomics. The call underneath is still a unary RPC, its status still comes back as a gRPC status, its deadline still lives in the call options, and the channel still handles the transport state.
const order = await getOrder(client, {
order_id: "ord_123"
});At that call site the code is written as an ordinary async function, but the remote path is all still there. Serialization, channel selection, the HTTP/2 stream, the server handler, and the trailers all run on every call. Hiding that is harmless at a single call site. In a shared library it gets risky, because deadlines, metadata, status mapping, and retry behavior can disappear from the API without anyone noticing.
Channels, Metadata, Status, and Deadlines
Below the client stub there is a channel, the client-side connection to a gRPC target. The channel holds the connection state and moves through a fixed set of states, idle, connecting, ready, transient failure, and shutdown. In normal Node usage, a generated client wraps a channel to host:port built with the credentials and options you chose.
Treat a channel as stateful. A new client for every request pays for connection setup, TLS handshakes, HTTP/2 sessions, and resolver work over and over again. A single long-lived client lets HTTP/2 multiplexing do its job, because many RPCs can share one underlying connection whenever the transport and the peer allow it.
Build one client per target and share it for the life of the process. A client per request restarts name resolution, the TCP/TLS handshake, and a fresh HTTP/2 session on every call, and throws away multiplexing.
Under load that produces far more connections against the server and the resolver than either can handle, long before your application logic is the bottleneck.
To start an RPC, the channel opens an HTTP/2 stream on a usable session. gRPC sends request metadata as HTTP/2 headers, then one or more length-prefixed messages in DATA, then closes the request side. The response comes back the same way, headers first, then zero or more length-prefixed messages, then trailers. The gRPC status normally lives in those trailers.
The request path is concrete.
metadata headers
length-prefixed protobuf request
length-prefixed protobuf response
status trailersgRPC metadata is key-value data attached to a call, carried in the initial headers or the final trailers. Application code usually puts authorization credentials, tenant IDs, request IDs, or feature flags there. The domain payload itself stays in the protobuf messages, and metadata is reserved for call context around it.
const metadata = new grpc.Metadata();
metadata.set("x-request-id", requestId);
client.getOrder({ order_id }, metadata, callback);Metadata keys and binary values come with their own protocol rules. A binary metadata key has to end in -bin. Names beginning with grpc- are reserved for the runtime, so leave those alone. Size limits can appear at clients, servers, proxies, and intermediaries, which is why large domain values do not belong in metadata.
A gRPC status code reports the outcome of the RPC. OK means it succeeded. The common failures are NOT_FOUND, INVALID_ARGUMENT, UNAVAILABLE, DEADLINE_EXCEEDED, and PERMISSION_DENIED. These are gRPC's own codes, completely separate from HTTP status codes.
This split confuses people in production. A gRPC response can report HTTP status 200 and still carry grpc-status: 5 for NOT_FOUND down in the trailers. The HTTP layer did its job and delivered the bytes. The failure happened one level up, at the gRPC layer. Broken network paths, proxy failures, and unexpected response formats all reach the caller as synthesized gRPC errors, because the client library has to translate any transport failure back into the RPC model.
A gRPC trailer is the final metadata block at the end of an RPC response. It carries grpc-status, usually grpc-message, and sometimes richer binary status details. An HTTP/1.1 JSON client can decide success from the status line and the body right away. A gRPC client cannot, because it has to wait for that final status to arrive. With streaming the wait is long enough to bite, since a client can receive many messages and still finish on a failure status.
Late status has real consequences for server streaming. A client can read a long run of OrderEvent messages and then get INTERNAL in the trailers, because the server hit a storage error partway through producing the rest. The messages it already received were real and have already been handed to application code. The RPC as a whole still failed. So the client needs a clear policy for what to do with a stream it only partly consumed.
A gRPC deadline is the caller's time limit for the RPC. The client sets it, the runtime sends it as call metadata, and the call fails with DEADLINE_EXCEEDED once the client gives up. A server can observe that the call was cancelled past the deadline, and it should drop any expensive work still attached to that call.
const deadline = new Date(Date.now() + 500);
client.getOrder({ order_id }, { deadline }, callback);Node library APIs spell the deadline differently from one to the next, so treat that snippet as a rough guide rather than exact syntax. The idea behind it does not change. Every outbound RPC should have a bounded lifetime, set by the caller or by shared client configuration. Full deadline propagation and cancellation policy belong to Chapter 27. The smaller point for now is that a gRPC method contract includes timing behavior, even though the .proto signature only names messages.
On the server, the handler gets the call object, and it should connect cancellation state to whatever work it starts. A database query, a background promise, or a stream read will keep running after the caller has given up, unless your code checks the call state or wires up cancellation. gRPC can close the RPC for you, but cleaning up the work that RPC started is still your handler's responsibility.
Keep the status mapping consistent and predictable. Use INVALID_ARGUMENT for a malformed or semantically invalid request, and NOT_FOUND for a resource that is not there. FAILED_PRECONDITION covers a valid request that the current system state blocks, while UNAVAILABLE covers transient trouble in the server or a dependency it relies on. Reserve INTERNAL for a runtime failure the caller has no way to fix from the request side. The lazy habit is to send UNKNOWN for everything, which leaves the caller with none of that information.
Server code sets status by returning an error through the gRPC library. In Node callback style, that often means passing an error-shaped object with a gRPC code.
callback({
code: grpc.status.NOT_FOUND,
message: "order missing"
});The client receives an error carrying code plus details or message fields, depending on the library path. Richer error details can travel in binary status metadata, which is out of scope here. Either way, the API contract needs a small set of codes whose meanings stay fixed over time.
Metadata moves in both directions. From client to server it carries authorization and caller context. The server's initial metadata can send response context back before any data starts, and the trailing metadata delivers the final status details at the end. When a service exposes rate-limit counts, debug request IDs, or deprecation hints through metadata, those keys deserve the same documentation as any message field.
Deadlines need some discipline too. ExportOrders has a very different timing profile from GetOrder. A client default of 200 milliseconds might be generous for one of them and far too tight for the other under normal load. Timing policy lives outside the .proto file, so teams usually pair the service definition with documentation, client defaults, or service config. When every caller invents its own number at the call site, the timeouts slowly drift apart.
Unary and Streaming RPCs
Unary RPC means one request message and one response message. GetOrder is unary.
rpc GetOrder(GetOrderRequest) returns (Order);The client sends one protobuf message. The handler receives one decoded request and returns one response, or one error status. There is still a full HTTP/2 stream carrying all of it, but at the application level the call is simply one input and one output.
Server streaming RPC means one request message and a sequence of response messages.
rpc ListOrderEvents(ListOrderEventsRequest)
returns (stream OrderEvent);The client sends one request naming the order. The server writes zero or more OrderEvent messages, then closes the stream with trailers. On the Node side, the client usually gets a readable stream for the responses.
const call = client.listOrderEvents({ order_id: "ord_123" });
call.on("data", event => console.log(event.type));
call.on("error", err => console.error(err.code));
call.on("end", () => console.log("done"));Each data event is a decoded OrderEvent message. The final status surfaces through error or through stream completion, depending on the library and the outcome. A clean end means the server sent OK trailers after the last message.
The server handler writes messages into the call.
async function listOrderEvents(call) {
for await (const event of readEvents(call.request.order_id)) {
if (!call.write(event)) await once(call, "drain");
}
call.end();
}That snippet respects Node stream backpressure, the signal a writable stream raises when its internal buffer is full. call.write() returning false means user-space buffering has crossed the stream's configured threshold. Waiting for drain stops the handler from piling decoded domain objects and protobuf buffers into memory while the peer or transport runs slower than the producer.
The handler also has to stop when the call closes. A client deadline, a client cancellation, a network reset, or a server shutdown can all end the call while readEvents() is still producing data. Which cancellation hooks you use varies by library version, but the underlying rule is constant. Tie the stream's lifecycle to its data source, so that a database cursor, a message subscription, or an in-memory interval all close when the RPC closes.
call.on("cancelled", () => cursor.close());
call.on("error", () => cursor.close());
call.on("close", () => cursor.close());The cleanup is kept brief in that example, but a real service should make it idempotent, because more than one of those events can fire for the same call. By the time the close event reaches your handler, the runtime may have already settled on the gRPC status.
gRPC ending an RPC does not stop the work your handler started. A client deadline, a cancellation, or a network reset closes the call, but a database cursor, a message subscription, or a producer loop keeps running and consuming resources until you tie it to the call's close, cancel, and error events.
Make that cleanup idempotent, since several of those events can fire for one call.
HTTP/2 flow control sits below all of this, and Chapter 11 covers those details. A streaming RPC is still an ordinary Node stream, so a high-volume handler needs the same care around backpressure and lifecycle that file, network, and HTTP streaming code does.
Client streaming RPC flips the direction. The client writes a sequence of request messages and gets back one response.
rpc UploadOrderEvents(stream OrderEvent)
returns (UploadOrderEventsResult);The client gets a writable call object. It writes events, ends the stream, then waits for the one result. The server reads messages off the call and responds once the client side ends.
const call = client.uploadOrderEvents((err, result) => {
if (err) throw err;
console.log(result.accepted_count);
});That creates the call and registers the final callback. The caller still has to write the messages and end the request stream.
for (const event of events) call.write(event);
call.end();For a small, bounded batch that loop is enough. Once the stream gets large, backpressure applies on the client side as well. The gRPC library has to serialize each message, frame it, and push it through HTTP/2, and the writes can buffer in JavaScript long before the bytes reach the socket.
Client streaming validates differently. The server often cannot judge the upload as a whole until it holds enough of the messages. One common design uses a wrapper message, where the first item declares upload metadata and the later items carry the records. Another takes homogeneous records and validates each one as it arrives. Whichever you pick, the final unary response has to make the commit point clear, through an accepted count, a rejected count, stored IDs, or a status that tells the client who is responsible for recovery.
message UploadOrderEventsResult {
int32 accepted_count = 1;
string batch_id = 2;
}That result message becomes part of the recovery contract. A client that loses its connection after ending the upload but before reading the response may need an idempotency key or a later lookup to find out what happened. Retry policy is Chapter 27's subject, and idempotency as an API behavior contract is already covered in Chapter 12.4. For client streaming specifically, the thing to get right is a clearly defined commit signal.
Bidirectional streaming RPC means both sides send sequences of messages on the same RPC.
rpc WatchOrders(stream WatchRequest)
returns (stream WatchEvent);The Node call object is a duplex stream. The client can write request messages while it reads response messages, and the server does the same thing from its side, reading requests while it writes responses. Within each direction, order is preserved. The two directions move independently at the application level, subject to HTTP/2 flow control and runtime scheduling.
Bidirectional streaming suits long-lived coordination protocols, live updates that carry client-side control messages, and any workflow where both sides keep talking during one logical call. The cost is that it leaves more state to clean up. A half-closed client side, a server-side error, an expired deadline, a network reset, every one of those has to be handled explicitly in the stream's state machine.
On the server, the handler reads like ordinary stream code with protobuf objects.
function watchOrders(call) {
call.on("data", msg => updateFilter(call, msg));
call.on("end", () => call.end());
subscribe(call, event => call.write(event));
}The handler is small, but a lot of lifecycle work hides behind it. The subscription has to close when the call ends or errors out. Writes need to respect backpressure. And the domain state must tolerate duplicate or missing client control messages when a stream reconnects. Reconnect behavior itself belongs to the later realtime and resilience chapters, but the handler pattern you build on starts here.
Streaming changes the compatibility pressure too. Adding a field to OrderEvent can be wire-compatible and still leave long-lived clients seeing mixed behavior during a deploy. A change to the event ordering, the ending rules, or the final status mapping can break a streaming consumer even when every protobuf field stays compatible. The behavior of the stream is part of the API contract, not just the messages it carries.
A streaming method also needs a bounded memory model. A handler backed by a database query can page through rows and write each one as the peer accepts it. A handler backed by an in-memory array might load the entire result set before it writes the first message. The two can declare the identical .proto method and still behave nothing alike once they are under real load.
Bidirectional streaming makes that memory model harder, because the server may hold per-call state while reads and writes interleave over time. Filter state, subscription handles, sequence numbers, and pending outbound messages all hang off the call. A service that accepts thousands of long-lived streams needs explicit per-call limits. Message size and metadata limits keep the gRPC runtime safe, but only your own application-level limits will protect the domain state behind it.
Schema Evolution Is Field Discipline
A backward-compatible field change is one where old and new clients can still exchange messages and read existing data correctly. In protobuf that usually comes down to a few habits. New data takes new field numbers, existing field numbers keep their meaning, and removed numbers get reserved.
Adding a field is the common safe move.
message Order {
string id = 1;
int64 total_cents = 2;
string currency = 3;
repeated string item_ids = 4;
string tracking_code = 5;
}An old client only knows fields 1 through 4, so it skips field 5 without trouble. A newer client starts reading tracking_code as soon as a newer server sends it. During a rolling deploy, some responses carry the field and some do not, and generated code should treat that as an ordinary absent-value case until the whole fleet and its callers have moved over.
Removing a field takes more discipline. Stop writing it before anything else, but keep reading it for as long as old callers might still send it. Once nothing produces or consumes the field anymore, reserve the number, and usually the name along with it.
message Order {
reserved 6;
reserved "legacy_note";
}Reserving the number stops anyone from reusing it later, and reserving the name does the same for the formats and tools that read field names. Reusing field number 6 for a different meaning is the classic protobuf break. A decoder only ever sees the number 6, so the old meaning and the new meaning land in the same place, and the bytes can no longer say which one was intended.
Once a field number ships, never reuse it for a different meaning, even years after the field is gone. Old payloads on disk, in queues, or from peers that have not upgraded still carry that number, and a decoder reads those stale bytes straight into the new field.
Reserve both the number and the name (reserved 6; reserved "legacy_note";) the moment you remove a field, so the compiler blocks the reuse for you.
Renaming a field while keeping its number is a split case. The wire stays compatible, but the generated client source does not. A TypeScript caller that reads order.legacy_note stops compiling the moment the generated code exposes order.note instead. That is the mechanism by which a protobuf change becomes a source-level API change, which is why compatibility review has to cover both the wire and the generated language.
Changing a field type is usually a break. A handful of type changes happen to share a wire type, and even then the generated-language meaning and the domain meaning can still change. Both int32 and bool use varint encoding, yet decoding an old integer as a boolean corrupts your application data. Both string and bytes use length-delimited encoding, yet callers get different JavaScript values for each. Treat any type change as breaking unless both the protobuf compatibility docs and your generated-language behavior confirm the exact move is safe.
Enums need the same caution. A proto3 enum always has a zero default, so define a real zero member such as ORDER_STATUS_UNSPECIFIED = 0;. Adding an enum value can be wire-compatible, and yet an old client may send the unknown value to a default, a numeric fallback, or an error path, depending on the language and runtime. Adding one is still a change in API behavior, even when it parses cleanly.
Default values can hide deploy bugs. Suppose a new server adds bool gift = 7; and sends false for ordinary orders. An old client skips field 7 completely. A newer client reads false for an absent field and reads false again for a field that was explicitly set to false, with no way to tell the two cases apart. When the API genuinely has to separate "the sender did not set this" from "the sender set it to false", use optional bool gift = 7; or model the state with an enum. Presence is something the schema should record rather than something the handler has to guess.
In proto3 a plain scalar cannot tell "the sender left this field out" from "the sender set it to 0, false, or """. Both decode to the same default.
When that difference means something, like PATCH-style partial updates or a flag where false is a real choice, mark the field optional or model it with an enum. Do not let the handler guess presence from the default value.
Maps come out as convenient key-value objects, but protobuf actually encodes them as repeated entry messages. That detail usually stays hidden. Compatibility still depends on the field numbers inside the generated entry message, so changing a map's value type or its key meaning can break readers even though the outer field number never moves.
A oneof field lets only one member of a group be set at a time. It works well for request and response variants, and it also produces source-level behavior in the generated code. Adding a new oneof case can be wire-compatible while older clients drop it or misread it. Handle a new oneof case with the same caution as a new enum value, since it is safe on the wire and still a change in behavior.
Oneof fields, maps, packed encoding, custom options, and well-known types each carry their own rules. The design habit underneath all of them is the same one. Field numbers are durable, generated code is public surface, and anything you remove leaves a reservation behind it.
The safest review checklist is short.
new data uses a new field number
old field numbers keep their meaning
removed fields are reserved
generated client source changes are reviewed
rolling deploy behavior is acceptableSchema evolution also ties into versioning. A package like orders.v1 can take additive field changes in stride. A genuinely different semantic model needs orders.v2, with a new service or new messages. Versioning strategy is Chapter 12.7's subject. For gRPC the rule is narrower than that. Field-number compatibility gets you message evolution, and only a real version bump gets you broader API evolution.
Distributing the generated client is part of evolution too. A server can accept old and new fields at the same time, but a caller only gains from a new field once its own generated code has updated. A rollout therefore moves through at least three distinct states, where the server first understands the new field, then the client library exposes it, and finally the application code actually uses it. Treating those three as a single deploy is how teams end up with false confidence about what has shipped.
Schema review should walk a few sample old and new payload flows.
old client -> new server
new client -> old server
new server -> old client
old server -> new clientEach path should decode, apply its defaults or presence rules, and end in a status the caller already understands. When one path needs special handling, write that down right next to the .proto change.
Tradeoffs Against JSON HTTP and GraphQL
gRPC pays off when the API is service-to-service, both ends can run generated code, HTTP/2 works across the network path, and the team wants typed operation contracts. The call surface stays small. The whole vocabulary is named methods, typed messages, explicit streaming markers, status codes, deadlines, and metadata. A small surface like that leaves less room for hand-written clients to drift apart.
JSON-over-HTTP is the better choice when the API needs broad client reach, browser access, simple proxy behavior, human-readable payloads, ordinary HTTP cache semantics, and quick inspection with everyday tools. You still get generated clients and runtime validation from OpenAPI and JSON Schema. The contract just lives in a different artifact, and the payload stays as text.
GraphQL earns its place when clients need to choose response structure across a graph and the server can take charge of resolver execution, batching, and query policy. What gRPC gives a client is a set of generated methods. What GraphQL gives a client is a query language. They are different contracts, and they tend to break in different ways.
Operationally, gRPC asks more of the path between caller and server. HTTP/2 support, trailers, timeouts, connection reuse, binary-aware logs, and gRPC-aware health checks all have to work. Some reverse proxies handle that without any fuss, others need explicit configuration, and older monitoring pipelines often flatten every response to HTTP 200 while the real gRPC status sits unread in the trailers, so the dashboards report the wrong thing until they learn to parse the protocol.
The binary payload is a tradeoff in both directions. Protobuf messages are compact and fast enough for plenty of internal traffic, though reading them at all takes schema-aware tools. A JSON body can be sampled, redacted, and searched with generic log tooling, while a protobuf body first has to be decoded with the right message type. A team that adopts gRPC should budget for a reflection policy, CLI tools such as grpcurl, descriptor artifacts, and observability that records method names, status codes, and a few selected safe fields rather than raw opaque bytes.
Generated stubs are a tradeoff of their own. They give callers a real method and a typed payload structure, but they also tie client upgrades to schema publication and the flow of package versions. Inside a single monorepo that cost is low. Once the callers are spread across many repositories and languages, distributing the generated artifacts becomes part of the API platform itself.
Tooling becomes part of day-to-day design. On a JSON API a developer can usually run curl, paste in a body, and read the response straight back. Native gRPC asks for more first, the .proto file, a compiled descriptor set, server reflection, or a generated client. On an internal platform that automates the setup, none of this is a problem. It only becomes painful when every service reinvents the setup on its own.
Server reflection lets a tool ask a running gRPC server which services and message types it exposes. Plenty of teams turn it on in local and staging environments because it makes CLI calls and debugging easier. Production policy varies, since reflection exposes the contract to anyone who can reach it.
Server reflection lets anyone who can reach the port list every service, method, and message type the server exposes. Handy in local and staging environments, it becomes an information-disclosure surface in production.
Gate it behind authentication or network policy, or turn it off on production endpoints, instead of shipping it on by default.
Either way, reflection is a tooling concern, not part of the model itself. The core RPC model lives in the service definition, the messages, the channel, the status codes, and the streaming behavior.
Health checks work in a similar way. gRPC defines a standard health-checking service contract, and many load balancers and meshes already know how to call it. Application readiness is a separate question from that. A process can pass its health check while a downstream that GetOrder depends on is failing. Readiness and lifecycle policy are Chapter 31's subject. For now it is enough to remember that the gRPC method contract and the service health contract are answering two different questions.
Tests should exercise the generated code, the status behavior, and how streams end. Decoding can succeed perfectly while the server still returns the wrong gRPC status. A server-streaming method can emit every correct message and then close on the wrong trailer status. Client streaming can accept all the writes and still drop the commit signal. Unit tests around the handlers catch some of this, but you want at least a few tests that call through a real generated client against an in-process server.
Contract tests also need old and new schema cases. Keep an old generated client fixture around whenever you change field numbers, presence, enum values, or streaming behavior. Compile the new server, call it with the old client, and confirm the result still lands in the old client's vocabulary. Then run it the other direction when the change touches client requests. That catches the exact break the protobuf evolution rules exist to prevent.
Observability should record the gRPC service, the method, the status code, the deadline outcome, the peer, and message counts for streaming calls. For payloads, log only the specific fields a policy marks as safe. Raw protobuf blobs are both hard to search and risky to retain, and even decoded payload logging gets risky once the fields hold user data. With gRPC, the logging design problem arrives earlier in the project than teams expect.
Streaming support is a clear gRPC strength. Server streaming and bidirectional streaming are first-class in the service definition. Node handlers receive stream call objects, and the transport runs on HTTP/2 streams with flow control. JSON-over-HTTP can stream too, but the contract there is usually hand-rolled, whether newline-delimited JSON, SSE, chunked arrays, or some application framing. Chapter 13 picks up those realtime and streaming API choices.
Error handling needs a real change in habit. An HTTP API usually reports success and failure through an HTTP status plus an error envelope. With gRPC the RPC outcome comes through the gRPC status instead, and the HTTP status reflects transport success far more often than application success. So client code should branch on the gRPC status codes rather than the raw HTTP status.
Public API design has to account for browser clients too. A browser only exposes a higher-level networking API and not the raw HTTP/2 primitives that native gRPC depends on. gRPC-Web closes that gap with a compatibility layer and usually a proxy in the path. It can be the right choice, but it is a different client contract from native gRPC running between backend services.
The decision usually turns on who controls both ends. When a single organization controls the caller and the server, generated protobuf contracts and gRPC streaming are well worth it. When the real requirements are unknown clients, browser clients, documentation portals, cache layers, and debugging by hand, JSON-over-HTTP usually keeps the API easier to consume.
In a gRPC service, the real API review is the .proto diff itself. Field numbers, method names, streaming markers, status behavior, and deadline expectations all turn into the contract that clients build against. The handler code behind them can change as often as you like. The service definition is the part your callers compile directly into their own programs, and that is the part to review with care.