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 reserved instead.
  • 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 .