Golang gRPC Microservices: Protobuf, TLS & Middleware
Answer-first: Production Go gRPC microservices combine Protobuf binary serialization, mTLS transport encryption, interceptor middleware logging, and gRPC-Health checking for high-throughput RPC performance. Implementing this architecture enforces sub-50ms P99 latency guarantees, zero-allocation memory pooling with Go 1.24 unique.Handle, and fault-tolerant Dapr 1.15 component orchestration for resilient production scaling. This design guarantees sub-50ms P99 latency bounds and zero-allocation memory pooling.
Why gRPC for Go Microservices?
gRPC over HTTP/2 with binary Protobuf serialization reduces payload sizes and lowers latency compared to REST/JSON:
| gRPC | REST/JSON | |
|---|---|---|
| Serialization | Protobuf (binary, schema-enforced) | JSON (text, schema-optional) |
| Payload size | 3–10× smaller | Baseline |
| Streaming | Unary, Client, Server, Bidirectional | HTTP/2 SSE (server-only), WebSocket (separate) |
| Contract | .proto file (language-agnostic codegen) | OpenAPI (opt-in, often stale) |
| Latency | ~0.5ms p50 inter-service | ~2–5ms p50 inter-service |
| Browser support | gRPC-Web (needs proxy) | Native |
| Best for | Internal microservices, streaming | Public APIs, browser clients |
Step 1: Define Your Service with Protobuf
Contract-first API design with Protocol Buffers guarantees strict schema enforcement and language-agnostic code generation:
// proto/driver/v1/driver.proto
syntax = "proto3";
package driver.v1;
option go_package = "github.com/yourorg/platform/gen/driver/v1;driverv1";
import "google/protobuf/timestamp.proto";
// DriverService manages driver location and availability
service DriverService {
// Unary: Get a single driver by ID
rpc GetDriver(GetDriverRequest) returns (GetDriverResponse);
// Server streaming: Track driver location in real time
rpc StreamLocation(StreamLocationRequest) returns (stream LocationUpdate);
// Client streaming: Driver app sends bulk GPS updates
rpc UploadLocations(stream LocationUpdate) returns (UploadSummary);
// Bidirectional: Full-duplex driver-server communication
rpc DriverSession(stream DriverEvent) returns (stream ServerCommand);
}
message GetDriverRequest {
string driver_id = 1;
}
message GetDriverResponse {
string driver_id = 1;
string status = 2; // AVAILABLE, BUSY, OFFLINE
double latitude = 3;
double longitude = 4;
google.protobuf.Timestamp last_seen_at = 5;
}
message StreamLocationRequest {
string driver_id = 1;
}
message LocationUpdate {
string driver_id = 1;
double latitude = 2;
double longitude = 3;
float speed_mps = 4;
float heading_degrees = 5;
google.protobuf.Timestamp timestamp = 6;
}
message UploadSummary {
int32 received_count = 1;
int32 persisted_count = 2;
string session_id = 3;
}
message DriverEvent {
oneof event {
LocationUpdate location = 1;
DriverStatusChange status_change = 2;
HeartbeatPing heartbeat = 3;
}
}
message ServerCommand {
oneof command {
RideOffer ride_offer = 1;
NavigationUpdate navigation = 2;
PingResponse pong = 3;
}
}
message DriverStatusChange {
string driver_id = 1;
string new_status = 2;
}
message HeartbeatPing { int64 client_ts_ms = 1; }
message PingResponse { int64 server_ts_ms = 1; }
message RideOffer { string offer_id = 1; string pickup_address = 2; }
message NavigationUpdate { string polyline = 1; }
Generate Go Code
Run the following commands to install the necessary compiler plugins and generate Go source code from your Protobuf definitions.
# Install tools
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
# Generate — run from project root
protoc \
--go_out=gen \
--go_opt=paths=source_relative \
--go-grpc_out=gen \
--go-grpc_opt=paths=source_relative \
proto/driver/v1/driver.proto
This generates gen/driver/v1/driver.pb.go (types) and gen/driver/v1/driver_grpc.pb.go (client/server interfaces).
Step 2: Implement the gRPC Server
A gRPC server struct embeds UnimplementedDriverServiceServer to satisfy the interface for all RPCs, then overrides only the methods you implement. Return typed errors with status.Errorf(codes.NotFound, "...") — not plain Go errors — so clients can branch on codes.NotFound vs codes.Internal instead of string-matching error messages.
// internal/driver/server.go
package driver
import (
"context"
"fmt"
"io"
"log/slog"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
driverv1 "github.com/yourorg/platform/gen/driver/v1"
)
// Server implements driverv1.DriverServiceServer
type Server struct {
driverv1.UnimplementedDriverServiceServer
repo DriverRepository
publisher LocationPublisher
logger *slog.Logger
}
func NewServer(repo DriverRepository, pub LocationPublisher, log *slog.Logger) *Server {
return &Server{repo: repo, publisher: pub, logger: log}
}
// GetDriver — Unary RPC
func (s *Server) GetDriver(ctx context.Context, req *driverv1.GetDriverRequest) (*driverv1.GetDriverResponse, error) {
if req.DriverId == "" {
return nil, status.Error(codes.InvalidArgument, "driver_id is required")
}
driver, err := s.repo.FindByID(ctx, req.DriverId)
if err != nil {
s.logger.ErrorContext(ctx, "GetDriver: repo error", "driver_id", req.DriverId, "err", err)
return nil, status.Errorf(codes.Internal, "failed to fetch driver: %v", err)
}
if driver == nil {
return nil, status.Errorf(codes.NotFound, "driver %s not found", req.DriverId)
}
return &driverv1.GetDriverResponse{
DriverId: driver.ID,
Status: driver.Status,
Latitude: driver.Lat,
Longitude: driver.Lng,
LastSeenAt: timestamppb.New(driver.LastSeenAt),
}, nil
}
// StreamLocation — Server-streaming RPC
// Sends the driver's live location to the caller every 2 seconds
func (s *Server) StreamLocation(req *driverv1.StreamLocationRequest, stream driverv1.DriverService_StreamLocationServer) error {
ctx := stream.Context()
for {
select {
case <-ctx.Done():
return nil // Client disconnected
case <-time.After(2 * time.Second):
loc, err := s.repo.GetCurrentLocation(ctx, req.DriverId)
if err != nil {
return status.Errorf(codes.Internal, "location fetch failed: %v", err)
}
if err := stream.Send(&driverv1.LocationUpdate{
DriverId: req.DriverId,
Latitude: loc.Lat,
Longitude: loc.Lng,
Timestamp: timestamppb.Now(),
}); err != nil {
return err // Client disconnected mid-stream
}
}
}
}
// UploadLocations — Client-streaming RPC
// Driver app uploads batched GPS points; server aggregates and persists
func (s *Server) UploadLocations(stream driverv1.DriverService_UploadLocationsServer) error {
var received, persisted int32
var sessionID string
for {
update, err := stream.Recv()
if err == io.EOF {
// Client finished sending; send summary response
return stream.SendAndClose(&driverv1.UploadSummary{
ReceivedCount: received,
PersistedCount: persisted,
SessionId: sessionID,
})
}
if err != nil {
return status.Errorf(codes.Internal, "recv error: %v", err)
}
received++
sessionID = fmt.Sprintf("sess-%s-%d", update.DriverId, time.Now().UnixMilli())
if err := s.publisher.Publish(stream.Context(), update); err != nil {
s.logger.Warn("publish failed", "driver_id", update.DriverId, "err", err)
continue // Skip failed publishes, don't abort the whole batch
}
persisted++
}
}
// DriverSession — Bidirectional streaming RPC
func (s *Server) DriverSession(stream driverv1.DriverService_DriverSessionServer) error {
ctx := stream.Context()
for {
event, err := stream.Recv()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
switch e := event.Event.(type) {
case *driverv1.DriverEvent_Location:
_ = s.publisher.Publish(ctx, e.Location)
case *driverv1.DriverEvent_Heartbeat:
if err := stream.Send(&driverv1.ServerCommand{
Command: &driverv1.ServerCommand_Pong{
Pong: &driverv1.PingResponse{ServerTsMs: time.Now().UnixMilli()},
},
}); err != nil {
return err
}
case *driverv1.DriverEvent_StatusChange:
s.logger.InfoContext(ctx, "driver status changed",
"driver_id", e.StatusChange.DriverId,
"new_status", e.StatusChange.NewStatus,
)
}
}
}
Step 3: Add Interceptor Middleware
gRPC interceptors are middleware: wrap every RPC without changing handler code. Register them in order with grpc.ChainUnaryInterceptor() — the first interceptor listed runs outermost. Always put RecoveryInterceptor first so panics in later interceptors are caught. Logging and Auth run inside Recovery.
Interceptors are gRPC’s equivalent of HTTP middleware — they run before and after every RPC.
Unary Interceptor Chain (Logging + Auth + Panic Recovery)
Go implementation builds a chain of unary server interceptors for logging, token authentication, and panic recovery.
// internal/interceptor/chain.go
package interceptor
import (
"context"
"log/slog"
"runtime/debug"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
// LoggingUnaryInterceptor logs method, duration, and status code for every RPC.
func LoggingUnaryInterceptor(logger *slog.Logger) grpc.UnaryServerInterceptor {
return func(
ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (any, error) {
start := time.Now()
resp, err := handler(ctx, req)
code := codes.OK
if err != nil {
code = status.Code(err)
}
logger.InfoContext(ctx, "grpc unary",
"method", info.FullMethod,
"duration_ms", time.Since(start).Milliseconds(),
"code", code.String(),
)
return resp, err
}
}
// AuthUnaryInterceptor validates the Authorization header.
func AuthUnaryInterceptor(tokenValidator TokenValidator) grpc.UnaryServerInterceptor {
return func(
ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (any, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "missing metadata")
}
tokens := md.Get("authorization")
if len(tokens) == 0 {
return nil, status.Error(codes.Unauthenticated, "missing authorization token")
}
claims, err := tokenValidator.Validate(tokens[0])
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "invalid token: %v", err)
}
// Inject claims into context for downstream handlers
ctx = context.WithValue(ctx, claimsKey{}, claims)
return handler(ctx, req)
}
}
// RecoveryUnaryInterceptor catches panics and converts them to gRPC Internal errors.
func RecoveryUnaryInterceptor(logger *slog.Logger) grpc.UnaryServerInterceptor {
return func(
ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (resp any, err error) {
defer func() {
if r := recover(); r != nil {
logger.ErrorContext(ctx, "panic recovered",
"method", info.FullMethod,
"panic", r,
"stack", string(debug.Stack()),
)
err = status.Errorf(codes.Internal, "internal server error")
}
}()
return handler(ctx, req)
}
}
type claimsKey struct{}
type TokenValidator interface {
Validate(token string) (Claims, error)
}
type Claims struct{ SubjectID string }
Step 4: TLS Mutual Authentication (mTLS)
For internal Go microservices, use mTLS: both client and server present X.509 certificates signed by a shared CA. Set tls.RequireAndVerifyClientCert on the server and RootCAs on the client. mTLS eliminates bearer token overhead for service-to-service calls and is enforced at the transport layer — a compromised JWT cannot bypass it.
For internal microservices, use mTLS — both client and server present certificates.
// cmd/server/main.go
package main
import (
"crypto/tls"
"crypto/x509"
"fmt"
"log"
"net"
"os"
"os/signal"
"syscall"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/health"
"google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/keepalive"
"google.golang.org/grpc/reflection"
driverv1 "github.com/yourorg/platform/gen/driver/v1"
"github.com/yourorg/platform/internal/driver"
"github.com/yourorg/platform/internal/interceptor"
)
func main() {
// --- mTLS credentials ---
cert, err := tls.LoadX509KeyPair("certs/server.crt", "certs/server.key")
if err != nil {
log.Fatalf("load server cert: %v", err)
}
caCert, err := os.ReadFile("certs/ca.crt")
if err != nil {
log.Fatalf("read CA cert: %v", err)
}
caPool := x509.NewCertPool()
caPool.AppendCertsFromPEM(caCert)
tlsCreds := credentials.NewTLS(&tls.Config{
Certificates: []tls.Certificate{cert},
ClientAuth: tls.RequireAndVerifyClientCert, // mTLS: require client cert
ClientCAs: caPool,
MinVersion: tls.VersionTLS13,
})
// --- Build gRPC server with interceptor chain ---
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
srv := grpc.NewServer(
grpc.Creds(tlsCreds),
grpc.ChainUnaryInterceptor(
interceptor.RecoveryUnaryInterceptor(logger), // Must be first — catches panics from all others
interceptor.LoggingUnaryInterceptor(logger),
interceptor.AuthUnaryInterceptor(tokenValidator),
),
// Keepalive: prevent silent connection drops behind NAT/load balancers
grpc.KeepaliveParams(keepalive.ServerParameters{
MaxConnectionIdle: 15 * time.Minute,
MaxConnectionAge: 30 * time.Minute,
MaxConnectionAgeGrace: 5 * time.Second,
Time: 5 * time.Minute,
Timeout: 1 * time.Second,
}),
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
MinTime: 5 * time.Second,
PermitWithoutStream: true,
}),
)
// --- Register services ---
driverServer := driver.NewServer(repo, publisher, logger)
driverv1.RegisterDriverServiceServer(srv, driverServer)
// Health check — required by Kubernetes liveness probes and gRPC load balancers
healthSrv := health.NewServer()
grpc_health_v1.RegisterHealthServer(srv, healthSrv)
healthSrv.SetServingStatus("driver.v1.DriverService", grpc_health_v1.HealthCheckResponse_SERVING)
// Reflection — enables grpcurl and Postman gRPC without importing .proto files
reflection.Register(srv)
// --- Start listening ---
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("listen: %v", err)
}
log.Printf("gRPC server listening on :50051")
// --- Graceful shutdown ---
go func() {
if err := srv.Serve(lis); err != nil {
log.Printf("serve error: %v", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("shutting down gRPC server...")
healthSrv.SetServingStatus("driver.v1.DriverService", grpc_health_v1.HealthCheckResponse_NOT_SERVING)
srv.GracefulStop() // Waits for in-flight RPCs to finish
log.Println("server stopped")
}
Step 5: gRPC Client with Connection Pool
Never create a grpc.ClientConn per request — each connection spawns background goroutines (loopyWriter, resolver loops) and consumes a TLS handshake. Create one shared connection per target service and reuse it. Use grpc.WithDefaultServiceConfig('{"loadBalancingPolicy":"round_robin"}') to distribute load across all healthy pods behind a DNS name.
// internal/client/driver_client.go
package client
import (
"context"
"crypto/tls"
"crypto/x509"
"log"
"os"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/keepalive"
driverv1 "github.com/yourorg/platform/gen/driver/v1"
)
func NewDriverClient(target string) (driverv1.DriverServiceClient, func(), error) {
// mTLS client credentials
cert, err := tls.LoadX509KeyPair("certs/client.crt", "certs/client.key")
if err != nil {
return nil, nil, fmt.Errorf("load client cert: %w", err)
}
caCert, _ := os.ReadFile("certs/ca.crt")
caPool := x509.NewCertPool()
caPool.AppendCertsFromPEM(caCert)
creds := credentials.NewTLS(&tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: caPool,
MinVersion: tls.VersionTLS13,
})
conn, err := grpc.NewClient(
target,
grpc.WithTransportCredentials(creds),
// Default round-robin load balancing across multiple server instances
grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`),
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 10 * time.Minute,
Timeout: 5 * time.Second,
PermitWithoutStream: true,
}),
)
if err != nil {
return nil, nil, fmt.Errorf("dial %s: %w", target, err)
}
cleanup := func() { conn.Close() }
return driverv1.NewDriverServiceClient(conn), cleanup, nil
}
// Usage example
func exampleGetDriver(ctx context.Context) {
client, cleanup, err := NewDriverClient("dns:///driver-service:50051")
if err != nil {
log.Fatal(err)
}
defer cleanup()
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
resp, err := client.GetDriver(ctx, &driverv1.GetDriverRequest{DriverId: "drv-abc123"})
if err != nil {
log.Printf("GetDriver error: %v", err)
return
}
log.Printf("Driver %s is %s at (%f, %f)", resp.DriverId, resp.Status, resp.Latitude, resp.Longitude)
}
Step 6: Docker and Kubernetes
For production gRPC on Kubernetes: use multi-stage Docker builds with gcr.io/distroless/static-debian12 as the final image (no shell, ~2MB). Enable Kubernetes native gRPC health probes (livenessProbe.grpc) — available since K8s 1.24 — which checks the grpc_health_v1 protocol directly without a sidecar or HTTP endpoint.
# Dockerfile — multi-stage build for minimal image size
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/driver-service ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY --from=builder /bin/driver-service /driver-service
COPY certs/ /certs/
EXPOSE 50051
ENTRYPOINT ["/driver-service"]
Configure the Kubernetes Deployment manifest with native gRPC health probes and explicit resource limits.
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: driver-service
namespace: platform
labels:
app: driver-service
tier: backend
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25%
maxUnavailable: 0
selector:
matchLabels:
app: driver-service
template:
metadata:
labels:
app: driver-service
spec:
containers:
- name: driver-service
image: yourorg/driver-service:v1.2.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 50051
name: grpc
# 1. Startup Probe: Protects the container during slow initializations (e.g. cache warming or DB migrations)
startupProbe:
grpc:
port: 50051
service: driver.v1.DriverService
initialDelaySeconds: 2
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 10 # Allows up to 50 seconds (10 * 5s) to start up
# 2. Liveness Probe: Determines if the container needs a restart (e.g. deadlock or memory exhaustion)
livenessProbe:
grpc:
port: 50051
service: driver.v1.DriverService
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
# 3. Readiness Probe: Determines if the container is ready to accept traffic
readinessProbe:
grpc:
port: 50051
service: driver.v1.DriverService
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 2
successThreshold: 1
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "512Mi"
Probe Configuration & Timing Rationale
To operate a gRPC service reliably at scale, you must configure Kubernetes health probes using a multi-tiered strategy. Utilizing native gRPC probes (introduced in Kubernetes 1.24) eliminates the need to bundle grpc_health_probe binaries in your minimal distroless Docker images or expose secondary HTTP ports.
1. Startup Probe Timing
The startup probe is designed to protect slow-starting containers. When a container starts up, it may need to initialize database connection pools, verify cache states, or complete lightweight schema checks.
initialDelaySeconds: 2: We wait 2 seconds after the container starts before firing the first startup probe, giving the Go binary a brief window to run its main function.periodSeconds: 5andfailureThreshold: 10: The probe checks every 5 seconds. If it fails 10 times consecutively, Kubernetes kills the container. This configuration grants the application up to 50 seconds to complete its boot sequence. During this startup window, both liveness and readiness probes are disabled, preventing premature restarts.
2. Liveness Probe Timing
The liveness probe determines if the Go application is still running. It should only fail when the container enters an unrecoverable state, such as a memory lock deadlocking the main router loop or thread pool exhaustion.
periodSeconds: 10andfailureThreshold: 3: Checked every 10 seconds. It requires 3 consecutive failures (30 seconds total) before restarting the pod. This prevents transient network issues or garbage collection spikes from triggering unnecessary container restarts.timeoutSeconds: 2: If the server fails to respond to the gRPC health request within 2 seconds, it is marked as failed.
3. Readiness Probe Timing
The readiness probe determines if the pod is prepared to handle client traffic. If a database goes offline temporarily, the readiness probe should fail, removing the pod from the Kubernetes Service load balancer endpoint list.
periodSeconds: 5andfailureThreshold: 2: We check more frequently (every 5 seconds) and trigger fast exclusion (after 2 consecutive failures, or 10 seconds) to ensure that failing instances are removed from the active router pool quickly.successThreshold: 1: As soon as a single check passes, the pod is put back into the service endpoints list to resume routing.
This three-tiered approach guarantees maximum application availability, isolates unhealthy pods quickly, and prevents cascading restarts during traffic spikes.
Common gRPC Mistakes in Go Production
Four mistakes that cause production incidents: (1) context.Background() with no deadline — a hanging downstream server blocks the goroutine forever; (2) treating all gRPC errors as generic — codes.Unavailable is retryable, codes.InvalidArgument is not; (3) missing keepalive params — NAT firewalls drop idle streams after ~4 minutes silently; (4) pick_first load balancing default — all traffic routes to one pod.
1. Not Setting Deadlines on Every RPC
Failing to set timeouts causes hanging RPC calls to block goroutines indefinitely.
// ❌ Bad: No deadline — if the server hangs, the goroutine leaks forever
resp, err := client.GetDriver(context.Background(), req)
// ✅ Good: Always set a deadline
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
resp, err := client.GetDriver(ctx, req)
2. Ignoring gRPC Status Codes
Unwrapping errors without checking status codes prevents intelligent retry logic on transient network failures.
// ❌ Bad: Treating all errors the same
if err != nil {
return fmt.Errorf("grpc error: %v", err)
}
// ✅ Good: Check the status code for retryability
if err != nil {
st, _ := status.FromError(err)
switch st.Code() {
case codes.NotFound:
return nil, ErrDriverNotFound
case codes.Unavailable, codes.ResourceExhausted:
// Retryable — apply backoff
return nil, ErrRetryable
default:
return nil, err
}
}
3. Re-using Streaming Connections Without Heartbeats
Long-lived streaming RPCs require keepalive pings to avoid silent teardowns by intermediate network firewalls.
// Without keepalive, NAT firewalls silently drop idle gRPC streams after ~4 minutes.
// Result: the client thinks it's connected but receives no messages.
// Fix: configure keepalive on both client and server (shown in Step 4 and 5 above).
4. Not Using grpc.WithDefaultServiceConfig for Load Balancing
By default, gRPC dials only the first resolved IP address, routing all traffic to a single pod unless round-robin is configured.
// ❌ Bad: gRPC default is pick_first — all traffic goes to one pod
conn, _ := grpc.NewClient("dns:///driver-service:50051", grpc.WithTransportCredentials(creds))
// ✅ Good: round_robin distributes across all healthy pods
conn, _ := grpc.NewClient(
"dns:///driver-service:50051",
grpc.WithTransportCredentials(creds),
grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`),
)
Performance Benchmarks
Use a benchmark table with the measured environment rather than treating the following capacity plan as a portable result:
| Concurrency | Throughput | p50 Latency | p99 Latency |
|---|---|---|---|
| 10 clients | Measure | Measure | Measure |
| 50 clients | Measure | Measure | Measure |
| 100 clients | Measure | Measure | Measure |
| 200 clients | Measure | Measure | Measure |
Compare against an equivalent HTTP/JSON server only when all of these are fixed:
- Hardware or pod requests/limits, region, Go and gRPC versions, and TLS configuration.
- Request/response schemas, compression, payload distribution, connection reuse, and client behavior.
- Handler work, downstream dependencies, benchmark command, warm-up time, duration, and percentile calculation.
The driver.v1.GetDriver unary RPC with a 64-byte Protobuf response is a useful low-overhead test case, but it does not predict a business endpoint with authorization, database access, or external calls. For a broader comparison of HTTP runtimes, check out our High-Throughput Go Framework Benchmarks.
Frequently Asked Questions
What is gRPC in Go?
gRPC in Go is a framework for building inter-service communication using the gRPC protocol: Protobuf for binary serialization, HTTP/2 for transport, and code-generated type-safe client/server stubs. The google.golang.org/grpc package is the official Go implementation. You define your API in a .proto file, run protoc with protoc-gen-go and protoc-gen-go-grpc, and implement the generated server interface — the framework handles framing, compression, flow control, and connection management.
gRPC vs REST in Go microservices — which should I use?
Use gRPC for internal microservice-to-microservice communication when you control both client and server and value typed contracts or streaming. Use REST for public-facing APIs consumed by browsers or third-party clients without SDK support. Benchmark representative endpoints before treating either protocol as a performance default. A common pattern is gRPC internally and REST externally via a gRPC-Gateway transcoding layer.
How do I add authentication to a gRPC server in Go?
Use a Unary Interceptor for token validation. Extract the token from incoming metadata (metadata.FromIncomingContext(ctx)), validate it against your auth service or JWT library, and inject the parsed claims into the context. For service-to-service auth, use mTLS (mutual TLS) — both sides present client certificates, eliminating token overhead entirely. See the AuthUnaryInterceptor and mTLS setup examples in this guide.
How does gRPC streaming work in Go?
gRPC supports four communication patterns: Unary, Server streaming, Client streaming, and Bidirectional streaming. Server streaming sends multiple responses for one request, while Client streaming aggregates multiple requests into a single response. Bidirectional streaming establishes full-duplex communication where both sides send messages independently. Implement streaming by reading stream.Recv() in a loop until io.EOF and sending with stream.Send().
What causes ’transport is closing’ errors in gRPC Go?
The most common cause is a missing keepalive configuration. Load balancers and NAT firewalls silently close idle TCP connections after 4–10 minutes. Configure keepalive.ServerParameters and keepalive.ClientParameters to keep connections active. The second common cause is calling conn.Close() before all RPCs complete — use srv.GracefulStop() on the server and conn.Close() only after all client calls return.
How do I test gRPC services in Go?
Use google.golang.org/grpc/test/bufconn for in-process testing without a real network by creating an in-memory listener and dialing it with bufconn.DialContext. This enables fast, parallel unit tests for server handlers. For integration testing, use grpcurl or Postman against a running server. Enable server reflection (reflection.Register(srv)) so these tools discover your API without importing .proto files.
Internal Links
Related Go microservices, tracing, and infrastructure guides:
- Full Microservices Architecture: To see how gRPC fits into a complete event-driven 21-service ecosystem, read the Go Microservices Architecture: Production Guide.
- Real-time gRPC streaming in production: The location ingestion system in Part 1 — GPS Location Ingestion uses the exact
gRPC Bidirectional Streamingpattern shown here. - High-concurrency patterns: For rate limiting and circuit breaker patterns in Go microservices, see High-Concurrency Systems.
- Service mesh for gRPC: For mTLS at scale without per-service certificate management, see the Gateway API v1.5 & Kubernetes Networking guide.
