gRPC and Protocol Buffers in C++: From .proto to Production

Why gRPC Over REST for Service-to-Service?

REST with JSON works well for browser-facing APIs. For internal service-to-service communication, gRPC has practical advantages:

  • Strong typing: the .proto schema is the contract — no undocumented JSON shapes
  • Binary encoding: Protocol Buffers are usually several times smaller than equivalent JSON, and cheaper to parse, because field names are replaced by small numeric tags and integers are varint-encoded
  • HTTP/2 multiplexing: many RPCs share one connection with flow control
  • Built-in streaming: server streaming, client streaming, and bidirectional streaming are first-class
  • Code generation: client and server stubs in C++, Go, Python, Java, and others from one .proto file

The costs are equally concrete. Payloads are not human-readable, so you cannot debug with curl and a browser; you need grpcurl or server reflection. Browsers cannot speak native gRPC (they do not expose HTTP/2 trailers), so a web frontend needs gRPC-Web and a proxy such as Envoy. And in C++ specifically, gRPC is a large dependency — building it from source pulls in Abseil, protobuf, re2, c-ares, and BoringSSL — which is a real consideration for small projects. The trade is worth it when many services in several languages must agree on a contract and call each other at high volume; it is overkill for one service with a handful of endpoints.


Protocol Buffers Basics

Define messages and service interfaces in .proto files:

// user_service.proto
syntax = "proto3";

package myapp;

// Messages define the data structures
message GetUserRequest {
  int32 user_id = 1;  // field number = 1, must never change
}

message UserResponse {
  int32 user_id = 1;
  string name = 2;
  string email = 3;
  int64 created_at = 4;  // Unix timestamp
  repeated string roles = 5;  // array of strings
}

message CreateUserRequest {
  string name = 1;
  string email = 2;
}

message ListUsersRequest {
  int32 page_size = 1;
}

// Service defines the RPC methods
service UserService {
  rpc GetUser(GetUserRequest) returns (UserResponse);
  rpc CreateUser(CreateUserRequest) returns (UserResponse);

  // Streaming variants (covered later)
  rpc ListUsers(ListUsersRequest) returns (stream UserResponse);
}

Field numbers are permanent — the number (not the name) identifies a field in the binary encoding. Never reuse a field number for a different field; mark removed fields as reserved instead:

message UserResponse {
  int32 user_id = 1;
  string name = 2;
  reserved 3;  // was "phone", removed — prevents accidental reuse
  string email = 4;
}

You can also reserve the old name (reserved "phone";) so nobody reintroduces it with a different number, which would break the JSON representation. Numbers 1–15 take one byte on the wire together with the type, so assign them to the most frequently set fields.

A proto3 subtlety that affects API design: scalar fields have no presence by default. An int32 user_id that was never set and one explicitly set to 0 serialize identically (default values are not written at all), so the server cannot tell “not provided” from “zero”. That is why the server below treats user_id <= 0 as invalid rather than checking whether it was sent. When the distinction matters — a PATCH-style update where 0 is a legitimate value — declare the field optional int32 (supported again since protobuf 3.15), which generates a has_user_id() accessor, or use a wrapper type such as google.protobuf.Int32Value.

Generate C++ Code

# Install protoc and grpc_cpp_plugin (varies by platform)
# Ubuntu: apt install protobuf-compiler-grpc libgrpc++-dev
# macOS: brew install grpc

# Generate C++ stubs
protoc \
  --cpp_out=./generated \
  --grpc_out=./generated \
  --plugin=protoc-gen-grpc=$(which grpc_cpp_plugin) \
  user_service.proto

# Creates:
# generated/user_service.pb.h        — message classes
# generated/user_service.pb.cc
# generated/user_service.grpc.pb.h   — service/stub classes
# generated/user_service.grpc.pb.cc

The generated code must be compiled against the same protobuf version that protoc came from. Mixing a system protoc with headers from a vendored or package-manager protobuf is the most common build failure in C++ gRPC projects, and the message is explicit: This file was generated by a newer version of protoc which is incompatible with your Protocol Buffer headers (or “an older version”). In CMake projects, letting the build invoke protoc through find_package(Protobuf) / find_package(gRPC CONFIG) and protobuf_generate keeps the compiler and library in sync. Link errors mentioning absl:: symbols usually mean the same thing one level down: protobuf and gRPC were built against different Abseil versions.


gRPC C++ Server

// server.cc
#include <grpcpp/grpcpp.h>
#include "generated/user_service.grpc.pb.h"
#include <memory>
#include <iostream>

class UserServiceImpl final : public myapp::UserService::Service {
public:
    grpc::Status GetUser(
        grpc::ServerContext* ctx,
        const myapp::GetUserRequest* req,
        myapp::UserResponse* resp) override
    {
        // Validate input
        if (req->user_id() <= 0) {
            return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT,
                "user_id must be positive");
        }

        // Simulate database lookup
        if (req->user_id() == 1) {
            resp->set_user_id(1);
            resp->set_name("Alice");
            resp->set_email("[email protected]");
            resp->set_created_at(1700000000);
            resp->add_roles("admin");
            resp->add_roles("user");
            return grpc::Status::OK;
        }

        return grpc::Status(grpc::StatusCode::NOT_FOUND,
            "user " + std::to_string(req->user_id()) + " not found");
    }

    grpc::Status CreateUser(
        grpc::ServerContext* ctx,
        const myapp::CreateUserRequest* req,
        myapp::UserResponse* resp) override
    {
        if (req->name().empty() || req->email().empty()) {
            return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT,
                "name and email are required");
        }

        // Create user in database...
        resp->set_user_id(42);
        resp->set_name(req->name());
        resp->set_email(req->email());
        return grpc::Status::OK;
    }
};

void RunServer() {
    std::string address = "0.0.0.0:50051";
    UserServiceImpl service;

    grpc::ServerBuilder builder;
    builder.AddListeningPort(address, grpc::InsecureServerCredentials());
    builder.RegisterService(&service);

    std::unique_ptr<grpc::Server> server(builder.BuildAndStart());
    std::cout << "Server listening on " << address << '\n';
    server->Wait();
}

int main() {
    RunServer();
}

This is the synchronous server API. gRPC runs each incoming call on a thread from an internal pool, so several GetUser calls can execute in UserServiceImpl at the same time. The service object is shared across all of them; any member state (a cache, a counter, a DB connection) needs its own synchronization. The handlers here only touch the request and response, which are per-call, so they are safe as written.

Two practical traps in RunServer. If the port cannot be bound (already in use, or a privileged port without permissions), BuildAndStart() returns nullptr after logging an error, and server->Wait() then crashes with a null pointer dereference — check the pointer, or pass an int* selected_port to AddListeningPort and verify it is non-zero. And Wait() blocks forever; for a clean shutdown on SIGTERM (for example in Kubernetes), call server->Shutdown(deadline) from another thread, which stops accepting new calls and gives in-flight ones time to finish.

The error path uses grpc::Status rather than exceptions. An exception thrown out of a handler is caught by the library and reported to the client as UNKNOWN, losing your message, so convert failures to a status code explicitly. The callback API (CallbackService) and the older completion-queue async API exist for higher concurrency without a thread per in-flight call; start with the sync API unless profiling says otherwise.


gRPC C++ Client

// client.cc
#include <grpcpp/grpcpp.h>
#include "generated/user_service.grpc.pb.h"
#include <chrono>
#include <memory>
#include <iostream>
#include <optional>

class UserServiceClient {
    std::unique_ptr<myapp::UserService::Stub> stub_;

public:
    explicit UserServiceClient(std::shared_ptr<grpc::Channel> channel)
        : stub_(myapp::UserService::NewStub(channel)) {}

    std::optional<myapp::UserResponse> GetUser(int32_t userId) {
        myapp::GetUserRequest request;
        request.set_user_id(userId);

        myapp::UserResponse response;
        grpc::ClientContext ctx;

        // Set deadline — always set deadlines on client calls
        ctx.set_deadline(std::chrono::system_clock::now() + std::chrono::seconds(5));

        grpc::Status status = stub_->GetUser(&ctx, request, &response);

        if (status.ok()) {
            return response;
        }

        std::cerr << "GetUser failed: ["
            << status.error_code() << "] "
            << status.error_message() << '\n';
        return std::nullopt;
    }
};

int main() {
    // Create a channel — reuse it across calls, don't create per-call
    auto channel = grpc::CreateChannel("localhost:50051",
        grpc::InsecureChannelCredentials());

    UserServiceClient client(channel);

    auto user = client.GetUser(1);
    if (user) {
        std::cout << "User: " << user->name()
                  << " (" << user->email() << ")\n";
        for (const auto& role : user->roles()) {
            std::cout << "  role: " << role << '\n';
        }
    }
}

grpc::CreateChannel does not connect immediately; the channel connects lazily on the first call and reconnects on its own after failures. So a wrong address does not fail at construction — it fails as UNAVAILABLE (or DEADLINE_EXCEEDED, if the deadline expires while connecting) on the first RPC. A ClientContext, on the other hand, is single-use: reusing one for a second call is undefined behavior and usually aborts with an assertion, which is why the example creates it inside GetUser.

The deadline is also propagated: the server sees it through ctx->deadline(), and a well-behaved server passes the remaining time on to its own downstream calls. Without deadlines, one slow dependency makes callers pile up waiting, and each waiting call holds a thread on the synchronous server — the cascading failure pattern deadlines exist to prevent. In practice I treat a missing deadline as a bug in code review, the same way as a missing timeout on an HTTP client.


Streaming RPCs

gRPC has three streaming modes in addition to basic unary RPC:

Server Streaming (server sends multiple responses)

// Server sends a stream of UserResponse
rpc ListUsers(ListUsersRequest) returns (stream UserResponse);
// Server implementation
grpc::Status ListUsers(
    grpc::ServerContext* ctx,
    const myapp::ListUsersRequest* req,
    grpc::ServerWriter<myapp::UserResponse>* writer) override
{
    // Fetch and stream rows from database
    for (const auto& user : db.getAllUsers()) {
        myapp::UserResponse resp;
        resp.set_user_id(user.id);
        resp.set_name(user.name);

        if (!writer->Write(resp)) {
            break;  // client disconnected or cancelled
        }

        if (ctx->IsCancelled()) {
            return grpc::Status(grpc::StatusCode::CANCELLED, "cancelled");
        }
    }
    return grpc::Status::OK;
}

// Client usage
grpc::ClientContext ctx;
myapp::ListUsersRequest req;
auto reader = stub_->ListUsers(&ctx, req);

myapp::UserResponse resp;
while (reader->Read(&resp)) {
    std::cout << resp.name() << '\n';
}

grpc::Status status = reader->Finish();
if (!status.ok()) { /* handle error */ }

On the client, Read returning false only means the stream ended — it does not say whether it ended successfully. The actual outcome arrives with Finish(), so skipping it hides server errors: a stream that failed halfway looks like a short list. On the server, Write returning false means the stream is broken (client gone or call cancelled), so there is no point continuing to fetch rows from the database. Streaming calls usually need a longer deadline than unary ones, or no fixed deadline with an explicit cancellation path via ctx.TryCancel().

Client Streaming (client sends multiple messages)

// Client sends a stream of records to import
rpc BulkImport(stream ImportRecord) returns (ImportResult);
// Client usage
grpc::ClientContext ctx;
myapp::ImportResult result;
auto writer = stub_->BulkImport(&ctx, &result);

for (const auto& record : localData) {
    myapp::ImportRecord req;
    req.set_data(record.serialize());
    if (!writer->Write(req)) break;  // server closed early
}

writer->WritesDone();
grpc::Status status = writer->Finish();

WritesDone() half-closes the stream, telling the server that no more messages are coming; the server’s Read loop then returns false and it can compute and send the single ImportResult. Forgetting WritesDone() leaves the server waiting for more input and the call hangs until its deadline. If Write returns false, the server has already finished (often with an error), and Finish() tells you why.

The fourth mode, bidirectional streaming (rpc Chat(stream Msg) returns (stream Msg)), lets both sides send independently over one call, using ClientReaderWriter on the client. With the synchronous API, reading and writing concurrently requires two threads — one blocked in Read, one calling Write — and only one thread may write at a time. That complexity is the usual reason teams move to the callback API for bidirectional streams.


Error Handling

gRPC status codes map to common error categories:

Status codeMeaningExample use
OKSuccess—
INVALID_ARGUMENTBad client inputMissing required field
NOT_FOUNDResource doesn’t existUser ID not in database
ALREADY_EXISTSDuplicateEmail already registered
UNAUTHENTICATEDNo valid credentialsMissing or expired token
PERMISSION_DENIEDAuthenticated but not allowedNon-admin calling an admin RPC
UNAVAILABLEServer temporarily downDatabase connection failed
DEADLINE_EXCEEDEDTimeoutSlow query
RESOURCE_EXHAUSTEDRate limit or quotaToo many requests
// Server — return meaningful status codes
if (!db.userExists(req->user_id())) {
    return grpc::Status(grpc::StatusCode::NOT_FOUND,
        "user " + std::to_string(req->user_id()) + " not found");
}

// Client — check and handle each code
grpc::Status status = stub_->GetUser(&ctx, request, &response);
switch (status.error_code()) {
    case grpc::StatusCode::OK:
        break;
    case grpc::StatusCode::NOT_FOUND:
        std::cerr << "User not found\n";
        break;
    case grpc::StatusCode::DEADLINE_EXCEEDED:
        std::cerr << "Request timed out — retry?\n";
        break;
    default:
        std::cerr << "Error " << status.error_code()
                  << ": " << status.error_message() << '\n';
}

Choosing codes carefully matters because clients and proxies act on them. Retry policies typically retry UNAVAILABLE (the request probably never reached the handler) but not INVALID_ARGUMENT or NOT_FOUND, which will fail again. DEADLINE_EXCEEDED is ambiguous — the server may have completed the work — so only retry it for idempotent operations. PERMISSION_DENIED means the caller is known but not allowed; an invalid or missing token should be UNAUTHENTICATED. Returning INTERNAL or UNKNOWN for everything forces clients to parse message strings, which is exactly the loose coupling the .proto contract was meant to replace.


TLS for Production

Never use InsecureChannelCredentials in production. Use TLS:

// Server with TLS
grpc::SslServerCredentialsOptions ssl_opts;
ssl_opts.pem_key_cert_pairs.push_back({
    ReadFile("server.key"),
    ReadFile("server.crt"),
});
// Optionally add CA cert for mutual TLS
ssl_opts.pem_root_certs = ReadFile("ca.crt");

builder.AddListeningPort("0.0.0.0:443",
    grpc::SslServerCredentials(ssl_opts));

// Client with TLS
grpc::SslCredentialsOptions ssl_client_opts;
ssl_client_opts.pem_root_certs = ReadFile("ca.crt");  // CA to verify server

auto channel = grpc::CreateChannel("service.example.com:443",
    grpc::SslCredentials(ssl_client_opts));

ReadFile stands for any helper that loads a PEM file into a std::string. Setting pem_root_certs on the server only makes client certificates possible; to require them (mutual TLS), also set ssl_opts.client_certificate_request to GRPC_SSL_REQUEST_AND_REQUIRE_CLIENT_CERTIFICATE_AND_VERIFY. On the client, the server’s certificate must match the host name in the target string; connecting by IP address or through an internal alias fails the handshake with a “Peer name … is not in peer certificate” error. For testing against such a server, override the expected name with the channel argument GRPC_SSL_TARGET_NAME_OVERRIDE_ARG — never disable verification. In a service mesh (Istio, Linkerd) the sidecar often terminates mTLS, in which case the application itself talks plaintext to localhost.


Production Checklist

  • Always set deadlines: ctx.set_deadline(std::chrono::system_clock::now() + timeout) — missing deadlines cause requests to hang indefinitely
  • Reuse channels and stubs: creating a channel per request is expensive (TLS handshake, HTTP/2 setup) — create once and share
  • Check IsCancelled() in streaming handlers: clients may disconnect; don’t keep writing to a closed stream
  • Handle UNAVAILABLE with retry: transient failures (network blip, pod restart) should trigger retry with exponential backoff
  • Use reserved for removed fields: prevents accidental reuse of field numbers in future .proto changes
  • Health checks: implement the gRPC health checking protocol for load balancer integration
  • Message size limits: large payloads hit the default 4MB limit — set grpc.max_receive_message_length in ChannelArguments

gRPC rules for production clients and servers

  • .proto files define the schema — field numbers are permanent identifiers, not the names
  • protoc generates typed C++ classes; the client stub and server base class come from grpc_cpp_plugin
  • Every unary RPC returns grpc::Status — always check status.ok() before using the response
  • Streaming modes (server, client, bidi) are first-class in gRPC — use them for large payloads or live feeds
  • Always set deadlines on client calls — no deadline means the call may wait indefinitely
  • Reuse channels — they are expensive to create; create one per target service and share it
  • Use TLS (SslServerCredentials, SslCredentials) in any non-local environment

Frequently Asked Questions (FAQ)

Q. My client call hangs forever when the server is slow or down. What am I missing?

A. A gRPC call without a deadline waits indefinitely, so set one on every ClientContext with set_deadline, as in the client example above. When it expires the call returns DEADLINE_EXCEEDED, which you can handle or retry instead of blocking a thread forever. Also create the channel once and reuse it; building a new channel per call adds connection setup cost to every request.