gRPC in 2026 is settled tech for service-to-service communication. The patterns are well-known but easy to skip when starting out. This post is the working playbook.
Proto file basics
syntax = "proto3";
package myservice.v1;
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse);
rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
rpc StreamEvents(StreamEventsRequest) returns (stream Event);
}
message User {
string id = 1;
string email = 2;
string display_name = 3;
google.protobuf.Timestamp created_at = 4;
}
message GetUserRequest {
string id = 1;
}
message GetUserResponse {
User user = 1;
}
Field numbers are forever. Don’t reuse them. Don’t renumber.
Proto evolution rules
- Add new fields: safe; old clients ignore.
- Add new RPC methods: safe.
- Remove fields: don’t reuse the number; use
reservedinstead. - Rename fields: source-breaking; wire-compatible if number unchanged.
- Change types: dangerous. Don’t.
- Mark old fields
[deprecated = true]: signals client to migrate.
message User {
reserved 5, 7;
reserved "old_field_name";
}
Once shipped, treat the schema as carved in stone for those numbers.
Code generation
buf generate
# or
protoc --go_out=. --go-grpc_out=. proto/user.proto
Buf is the modern toolchain (lint, format, breaking-change detection, generate).
# buf.yaml
version: v2
breaking:
use: [FILE]
lint:
use: [DEFAULT]
buf breaking in CI catches accidental breaking changes.
Server (Go)
type userServer struct {
pb.UnimplementedUserServiceServer
repo *UserRepo
}
func (s *userServer) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
if req.Id == "" {
return nil, status.Error(codes.InvalidArgument, "id required")
}
user, err := s.repo.Get(ctx, req.Id)
if errors.Is(err, ErrNotFound) {
return nil, status.Error(codes.NotFound, "user not found")
}
if err != nil {
return nil, status.Error(codes.Internal, "internal error")
}
return &pb.GetUserResponse{User: toProto(user)}, nil
}
Use gRPC status codes; don’t return raw errors.
Client
conn, _ := grpc.NewClient("user-service:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultServiceConfig(`{"loadBalancingConfig": [{"round_robin":{}}]}`),
)
client := pb.NewUserServiceClient(conn)
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
resp, err := client.GetUser(ctx, &pb.GetUserRequest{Id: "123"})
Always set deadlines. Without them, hung calls take down your service.
Streaming RPCs
Four kinds:
- Unary — request → response.
- Server streaming — request → stream of responses.
- Client streaming — stream of requests → response.
- Bidi streaming — both directions stream.
rpc StreamLogs(LogRequest) returns (stream LogLine);
rpc UploadChunks(stream Chunk) returns (UploadResponse);
rpc Chat(stream ChatMessage) returns (stream ChatMessage);
Use streaming for naturally streaming data: logs, file uploads, chat. Don’t use it as a unary call workaround.
See gRPC streaming + SSE .
Deadlines
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
Propagated automatically across calls. If service A has 5s, calls service B with 2s remaining, B knows.
Server side:
if err := ctx.Err(); err != nil {
return nil, status.Error(codes.DeadlineExceeded, "")
}
Retries
In service config:
{
"methodConfig": [{
"name": [{"service": "myservice.v1.UserService"}],
"retryPolicy": {
"maxAttempts": 3,
"initialBackoff": "0.1s",
"maxBackoff": "1s",
"backoffMultiplier": 2,
"retryableStatusCodes": ["UNAVAILABLE", "DEADLINE_EXCEEDED"]
}
}]
}
Built-in retry. No client code needed. Make sure RPCs are idempotent. See Idempotency .
Interceptors
Server-side middleware:
func loggingInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
start := time.Now()
resp, err := handler(ctx, req)
log.Printf("%s took %v err=%v", info.FullMethod, time.Since(start), err)
return resp, err
}
server := grpc.NewServer(
grpc.UnaryInterceptor(loggingInterceptor),
)
For auth, logging, metrics, tracing — interceptors. Both unary and stream variants.
Observability
import "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
server := grpc.NewServer(
grpc.StatsHandler(otelgrpc.NewServerHandler()),
)
Out-of-box OTEL spans on every RPC. See Distributed Tracing .
Connect protocol
buf generate # produces connect-go code
Connect speaks gRPC AND HTTP+JSON. Same server; both clients work. Browsers can call without grpc-web proxies.
// Browser
import { createClient } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web";
const client = createClient(UserService, createConnectTransport({ baseUrl: "/api" }));
const resp = await client.getUser({ id: "123" });
Best of both for new public APIs.
Auth
md, _ := metadata.FromIncomingContext(ctx)
authHeader := md.Get("authorization")
user, err := verify(authHeader)
Pass JWT / OAuth tokens via metadata. Interceptor extracts and validates.
Common mistakes
1. No deadlines
Hung RPC; goroutines pile up; OOM. Always context.WithTimeout.
2. Reusing field numbers
A removed field’s number gets new meaning; old clients decode as garbage. Use reserved.
3. Returning raw errors
Client gets Internal: connection refused. Use proper gRPC codes.
4. Streaming as unary
10MB request via client streaming because it “felt right.” Just use unary with the message size limit raised.
5. No max message size
Huge incoming message OOMs the server. Set grpc.MaxRecvMsgSize.
What I’d ship today
For a new service:
- Buf for proto management; CI breaking-change check.
- gRPC + Connect if any browser/REST client needed.
- OTEL interceptors server and client.
- Service config with retries on transient errors.
- Deadlines everywhere.
- Auth interceptor.
- Health checks (grpc.health.v1).
Read this next
If you want my Go gRPC service starter (Connect + OTEL + auth), 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 .