For service-to-service communication in 2026, gRPC + Protocol Buffers remains the best mix of typed contracts, performance, and ecosystem. With Connect as a modern adaptation that speaks both gRPC and HTTP/JSON, the old “but how do browsers use it?” objection is gone.
This post is the practical guide for Go: schema design, codegen, server/client patterns, streaming, observability, and the gotchas.
Why gRPC, why now
The case for gRPC over REST for backend-to-backend traffic:
- Typed contracts. A
.protofile generates client and server code in every language. No “I changed the API and forgot to update the client.” - Performance. Binary serialization. HTTP/2 multiplexing. ~2–5× lower latency than JSON-over-REST for similar workloads.
- Streaming. Server-streaming, client-streaming, bidi. First-class.
- Tooling.
buffor linting, breaking-change detection, generation. - Ecosystem. Every cloud-native infra speaks gRPC (Kubernetes, Envoy, Cilium, OpenTelemetry collectors).
Where REST/JSON still wins:
- Public APIs consumed by anonymous web clients.
- Anything where humans hand-curl the API.
- Cases where you want a thick browser client and don’t want a build step.
For everything between your services in a microservices fleet, gRPC pays.
Schema first
A protobuf schema for a small service:
// proto/orders/v1/orders.proto
syntax = "proto3";
package orders.v1;
option go_package = "github.com/example/api/orders/v1;ordersv1";
import "google/protobuf/timestamp.proto";
service OrderService {
rpc CreateOrder (CreateOrderRequest) returns (Order);
rpc GetOrder (GetOrderRequest) returns (Order);
rpc ListOrders (ListOrdersRequest) returns (stream Order); // server streaming
rpc WatchOrder (WatchOrderRequest) returns (stream OrderEvent);
}
message Order {
string id = 1;
string customer_email = 2;
int64 total_cents = 3;
string currency = 4;
google.protobuf.Timestamp created_at = 5;
}
message CreateOrderRequest {
string customer_email = 1;
int64 total_cents = 2;
string currency = 3;
// Idempotency key — see Idempotency post
string idempotency_key = 4;
}
message GetOrderRequest { string id = 1; }
message ListOrdersRequest {
string customer_email = 1;
int32 page_size = 2;
string page_token = 3;
}
message WatchOrderRequest { string id = 1; }
message OrderEvent {
string id = 1;
string event_type = 2; // "created", "shipped", "delivered"
google.protobuf.Timestamp ts = 3;
}
Three design rules I’d live by:
- Versioned package (
orders.v1). Breaking changes go inv2; both can be served simultaneously. - Field numbers are stable forever. Never reuse a number for a different field. Reservations:
reserved 5, 7;. - Avoid bool fields; use enums or oneof. Bools accumulate ambiguity ("
is_active=false" vs missing).
Use buf, not raw protoc
buf is the modern protobuf toolchain. It does linting, breaking-change detection, and codegen. Pin it.
# buf.yaml
version: v2
modules:
- path: proto
# buf.gen.yaml
version: v2
plugins:
- remote: buf.build/protocolbuffers/go
out: gen/go
opt:
- paths=source_relative
- remote: buf.build/grpc/go
out: gen/go
opt:
- paths=source_relative
- remote: buf.build/connectrpc/go
out: gen/go
opt:
- paths=source_relative
buf lint # style + best practices
buf breaking --against '.git#branch=main' # breaking-change check
buf generate # generate Go code
In CI, fail the build on buf breaking mismatches against main. This catches proto-incompatible PRs before they merge.
Server in Go
// internal/server/orders.go
package server
import (
"context"
"errors"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
ordersv1 "github.com/example/api/gen/go/orders/v1"
"github.com/example/api/gen/go/orders/v1/ordersv1grpc"
)
type OrderServer struct {
ordersv1grpc.UnimplementedOrderServiceServer
repo Repo
}
func (s *OrderServer) CreateOrder(ctx context.Context, req *ordersv1.CreateOrderRequest) (*ordersv1.Order, error) {
if req.GetCustomerEmail() == "" {
return nil, status.Error(codes.InvalidArgument, "customer_email required")
}
order, err := s.repo.Create(ctx, req)
if errors.Is(err, ErrConflict) {
return nil, status.Error(codes.AlreadyExists, "duplicate idempotency key")
}
if err != nil {
return nil, status.Error(codes.Internal, "create failed")
}
return order, nil
}
func (s *OrderServer) GetOrder(ctx context.Context, req *ordersv1.GetOrderRequest) (*ordersv1.Order, error) {
order, err := s.repo.Get(ctx, req.GetId())
if errors.Is(err, ErrNotFound) {
return nil, status.Error(codes.NotFound, "order not found")
}
return order, err
}
func (s *OrderServer) ListOrders(req *ordersv1.ListOrdersRequest, stream ordersv1grpc.OrderService_ListOrdersServer) error {
return s.repo.Stream(stream.Context(), req, func(o *ordersv1.Order) error {
return stream.Send(o)
})
}
Three production details:
- Embed
UnimplementedOrderServiceServerfor forward compatibility — adding a new RPC won’t break old servers. - Always wrap errors in
status.Error(code, msg)so the client sees real gRPC codes, notInternal: .... - Streaming RPCs end when the function returns; lifecycle is the function’s lifecycle.
Client in Go
import (
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
ordersv1 "github.com/example/api/gen/go/orders/v1"
"github.com/example/api/gen/go/orders/v1/ordersv1grpc"
)
func main() {
conn, err := grpc.NewClient(
"orders.svc.cluster.local:8080",
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultServiceConfig(`{
"loadBalancingConfig": [{"round_robin":{}}],
"methodConfig": [{
"name": [{"service": "orders.v1.OrderService"}],
"retryPolicy": {
"maxAttempts": 4,
"initialBackoff": "0.1s",
"maxBackoff": "1s",
"backoffMultiplier": 2,
"retryableStatusCodes": ["UNAVAILABLE","DEADLINE_EXCEEDED"]
}
}]
}`),
)
if err != nil { log.Fatal(err) }
defer conn.Close()
client := ordersv1grpc.NewOrderServiceClient(conn)
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
order, err := client.GetOrder(ctx, &ordersv1.GetOrderRequest{Id: "ord_123"})
// ...
}
A few notes:
grpc.NewClientis the modern API (replacing the deprecatedgrpc.Dial).- Service config in JSON inline — gives you retries, load balancing, deadline propagation in one place.
- Always set deadlines with
context.WithTimeout. A gRPC call with no deadline can hang forever and exhaust connections.
Streaming
Three streaming kinds:
- Server-streaming — client sends one request; server returns many responses.
- Client-streaming — client sends many; server returns one.
- Bidi-streaming — both sides stream independently.
Server-streaming consumer (e.g., listing all orders without pagination):
stream, err := client.ListOrders(ctx, &ordersv1.ListOrdersRequest{CustomerEmail: "[email protected]"})
for {
o, err := stream.Recv()
if errors.Is(err, io.EOF) { break }
if err != nil { return err }
process(o)
}
For bidi, both sides are goroutines that Send and Recv on the same stream:
stream, _ := client.WatchOrder(ctx)
go func() {
for {
ev, err := stream.Recv()
if err != nil { return }
// process event
}
}()
stream.Send(&ordersv1.WatchOrderRequest{Id: "ord_123"})
Stream lifecycle is the context lifecycle. Cancel the context to cancel the stream cleanly.
Interceptors — the middleware
Cross-cutting concerns (auth, logging, tracing, retries) go in interceptors:
func authInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
md, _ := metadata.FromIncomingContext(ctx)
tokens := md.Get("authorization")
if len(tokens) == 0 {
return nil, status.Error(codes.Unauthenticated, "no token")
}
user, err := verifyToken(tokens[0])
if err != nil {
return nil, status.Error(codes.Unauthenticated, "invalid token")
}
ctx = context.WithValue(ctx, userKey, user)
return handler(ctx, req)
}
s := grpc.NewServer(
grpc.UnaryInterceptor(authInterceptor),
grpc.StreamInterceptor(authStreamInterceptor),
)
For multiple interceptors, chain them with grpc-middleware/v2 library or wrap manually.
The standard set you’ll want:
- Auth (token / mTLS verification).
- Logging (structured request log per RPC).
- Tracing (OpenTelemetry; see OpenTelemetry End-to-End ).
- Recovery (recover from panics, return Internal).
- Rate limiting (per-method; see Design a Rate Limiter ).
Connect — gRPC for the modern web
Connect
is a protocol from Buf that speaks gRPC, gRPC-Web, and Connect’s own JSON-over-HTTP protocol on the same handler. The same .proto generates a server that accepts all three.
import (
"connectrpc.com/connect"
ordersv1 "github.com/example/api/gen/go/orders/v1"
"github.com/example/api/gen/go/orders/v1/ordersv1connect"
)
type ordersHandler struct{}
func (h *ordersHandler) GetOrder(ctx context.Context, req *connect.Request[ordersv1.GetOrderRequest]) (*connect.Response[ordersv1.Order], error) {
// ...
return connect.NewResponse(order), nil
}
func main() {
mux := http.NewServeMux()
path, handler := ordersv1connect.NewOrderServiceHandler(&ordersHandler{})
mux.Handle(path, handler)
http.ListenAndServe(":8080", h2c.NewHandler(mux, &http2.Server{}))
}
The browser can call this server with connect-web (or even plain fetch with JSON). Internal services call it as gRPC. One server, three protocols. For greenfield work in 2026, this is often the right call.
TLS, mTLS, and service mesh
For production:
- TLS at the edge. Always.
- mTLS service-to-service. Either via your own cert distribution or — preferably — a service mesh.
Cilium / eBPF handles mTLS without sidecars. Istio Ambient does the same. Either way, you get mutual auth between services with zero application changes.
If you’re not on a mesh, [grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig))] with cert rotation via cert-manager works.
Observability
import (
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
)
s := grpc.NewServer(
grpc.StatsHandler(otelgrpc.NewServerHandler()),
)
conn, _ := grpc.NewClient(addr, grpc.WithStatsHandler(otelgrpc.NewClientHandler()), ...)
Auto-instrumentation: every RPC becomes a span with method, status code, duration, peer service. Pair with the patterns in OpenTelemetry End-to-End .
For metrics: prometheus.NewMetricsCollector from grpc-ecosystem/go-grpc-middleware. For health: grpc.health.v1 is the standard.
Health checks
import "google.golang.org/grpc/health"
import healthv1 "google.golang.org/grpc/health/grpc_health_v1"
healthsvc := health.NewServer()
healthv1.RegisterHealthServer(s, healthsvc)
healthsvc.SetServingStatus("orders.v1.OrderService", healthv1.HealthCheckResponse_SERVING)
Kubernetes pulls this via grpc_health_probe. Set service-specific statuses; flip them when dependencies fail (DB unreachable, etc.).
Common mistakes
1. No deadlines
client.GetOrder(context.Background(), req) // ⛔ no deadline
This call hangs forever if the server is slow. Always use context.WithTimeout.
2. Reusing field numbers
// v1
message Order { string id = 1; }
// v2 — DON'T do this
message Order { int64 id = 1; } // breaking change!
Field numbers are part of the wire format. Reusing them silently breaks old clients reading new servers (and vice versa). Reserve old numbers when you remove a field.
3. Putting big payloads in messages
A 10 MB protobuf message is technically fine but operationally awful. Use streaming or store the blob elsewhere and send a reference.
4. Manually parsing metadata
Use metadata.FromIncomingContext. Don’t poke headers manually.
5. Missing UnimplementedOrderServiceServer
Without the embedding, adding a new RPC to the proto breaks compilation of every existing server. The embedding gives you safe forward compat.
6. Mixing grpc.WithBlock() with retries
WithBlock makes NewClient block until ready. With retries enabled in the service config, this can hide outages. Either fail fast or rely on connection retries; pick one.
When to use REST / OpenAPI instead
- Browser clients without a build step. Use Connect or REST.
- Public APIs. OpenAPI is the lingua franca; gRPC is internal.
- Webhook endpoints. Provider sends HTTP POSTs; you can’t ask them to speak gRPC.
- Quick experiments / debugging. Curl is your friend.
A good 2026 backend has both: gRPC service-to-service, REST/OpenAPI at the edge. Generate one from the other if your team values consistency (buf + grpc-gateway does this elegantly).
Read this next
- Distributed Systems Fundamentals
- Cilium and eBPF in Production — mTLS without sidecars.
- OpenTelemetry End-to-End — observability for gRPC.
- Designing REST APIs That Don’t Suck — when REST is the right call.
If you want a working Go gRPC + Connect + buf + OTel + sqlx starter, it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .