Key Takeaways

  • Unified API: Serve both gRPC and REST from single proto definitions
  • Performance: 100K+ requests/day with sub-10ms p99 latency
  • Auto-validation: Built-in request validation from proto annotations
  • Type safety: Generated code ensures API consistency

We needed to expose our gRPC microservices to web clients that couldn't handle gRPC directly. Our solution: a production gRPC Gateway that serves both REST and gRPC traffic from the same codebase. Here's how we built it.

Why gRPC Gateway?

Three problems led us to gRPC Gateway:

  1. Web browsers can't speak gRPC (well, not without gRPC-Web)
  2. Third-party integrations expect REST
  3. Maintaining two APIs is expensive

The traditional solution: build two separate APIs. The smart solution: generate REST from gRPC definitions.

Architecture Overview

// user.proto
syntax = "proto3";

package user.v1;
option go_package = "github.com/example/api/user/v1";

import "google/api/annotations.proto";
import "google/protobuf/timestamp.proto";
import "validate/validate.proto";

service UserService {
  rpc GetUser(GetUserRequest) returns (GetUserResponse) {
    option (google.api.http) = {
      get: "/v1/users/{user_id}"
    };
  }
  
  rpc CreateUser(CreateUserRequest) returns (CreateUserResponse) {
    option (google.api.http) = {
      post: "/v1/users"
      body: "*"
    };
  }
  
  rpc ListUsers(ListUsersRequest) returns (ListUsersResponse) {
    option (google.api.http) = {
      get: "/v1/users"
    };
  }
}

message GetUserRequest {
  string user_id = 1 [(validate.rules).string.min_len = 1];
}

message GetUserResponse {
  User user = 1;
}

message CreateUserRequest {
  string name = 1 [(validate.rules).string.min_len = 1];
  string email = 2 [(validate.rules).string.email = true];
}

message CreateUserResponse {
  User user = 1;
}

message ListUsersRequest {
  int32 page_size = 1;
  string page_token = 2;
}

message ListUsersResponse {
  repeated User users = 1;
  string next_page_token = 2;
}

message User {
  string id = 1;
  string name = 2;
  string email = 3;
  google.protobuf.Timestamp created_at = 4;
}

Code Generation Setup

The magic starts with proper code generation. Here's our Makefile:

BUF_VERSION := 1.28.1
PROTOC_GEN_GO_VERSION := 1.31.0
PROTOC_GEN_GO_GRPC_VERSION := 1.3.0
PROTOC_GEN_GRPC_GATEWAY_VERSION := 2.18.1
PROTOC_GEN_VALIDATE_VERSION := 1.0.2

.PHONY: generate
generate:
	buf generate

.PHONY: install-tools
install-tools:
	go install github.com/bufbuild/buf/cmd/buf@v$(BUF_VERSION)
	go install google.golang.org/protobuf/cmd/protoc-gen-go@v$(PROTOC_GEN_GO_VERSION)
	go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v$(PROTOC_GEN_GO_GRPC_VERSION)
	go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@v$(PROTOC_GEN_GRPC_GATEWAY_VERSION)
	go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@v$(PROTOC_GEN_GRPC_GATEWAY_VERSION)
	go install github.com/envoyproxy/protoc-gen-validate@v$(PROTOC_GEN_VALIDATE_VERSION)

buf.gen.yaml configuration:

version: v1
plugins:
  - plugin: buf.build/protocolbuffers/go
    out: .
    opt:
      - paths=source_relative
  - plugin: buf.build/grpc/go
    out: .
    opt:
      - paths=source_relative
  - plugin: buf.build/grpc-ecosystem/grpc-gateway
    out: .
    opt:
      - paths=source_relative
  - plugin: buf.build/grpc-ecosystem/openapiv2
    out: ./docs
  - plugin: buf.build/envoyproxy/validate
    out: .
    opt:
      - paths=source_relative
      - lang=go

Production Gateway Implementation

package main

import (
    "context"
    "crypto/tls"
    "fmt"
    "log"
    "net"
    "net/http"
    "os"
    "os/signal"
    "sync"
    "syscall"
    "time"

    "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
    "github.com/rs/cors"
    "golang.org/x/net/http2"
    "golang.org/x/net/http2/h2c"
    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
    "google.golang.org/grpc/health"
    "google.golang.org/grpc/health/grpc_health_v1"
    "google.golang.org/grpc/reflection"

    userv1 "github.com/example/api/user/v1"
    "github.com/example/internal/service"
)

type Gateway struct {
    grpcServer   *grpc.Server
    httpServer   *http.Server
    userService  *service.UserService
    port         string
}

func NewGateway(port string) *Gateway {
    return &Gateway{
        port:        port,
        userService: service.NewUserService(),
    }
}

func (g *Gateway) Start(ctx context.Context) error {
    lis, err := net.Listen("tcp", ":"+g.port)
    if err != nil {
        return fmt.Errorf("failed to listen: %w", err)
    }

    // Create gRPC server
    g.grpcServer = grpc.NewServer(
        grpc.UnaryInterceptor(g.unaryInterceptor),
        grpc.StreamInterceptor(g.streamInterceptor),
    )

    // Register services
    userv1.RegisterUserServiceServer(g.grpcServer, g.userService)
    
    // Register health check
    healthServer := health.NewServer()
    grpc_health_v1.RegisterHealthServer(g.grpcServer, healthServer)
    healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING)
    
    // Enable reflection for development
    reflection.Register(g.grpcServer)

    // Create HTTP mux
    mux := runtime.NewServeMux(
        runtime.WithMarshalerOption(runtime.MIMEWildcard, &runtime.JSONPb{}),
        runtime.WithErrorHandler(g.customErrorHandler),
        runtime.WithMetadata(g.annotateContext),
    )

    // Register gRPC-Gateway handlers
    opts := []grpc.DialOption{
        grpc.WithTransportCredentials(insecure.NewCredentials()),
    }
    
    if err := userv1.RegisterUserServiceHandlerFromEndpoint(
        ctx, mux, ":"+g.port, opts,
    ); err != nil {
        return fmt.Errorf("failed to register gateway: %w", err)
    }

    // Add custom routes
    g.addCustomRoutes(mux)

    // CORS configuration
    c := cors.New(cors.Options{
        AllowedOrigins:   []string{"*"}, // Configure for production
        AllowedMethods:   []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
        AllowedHeaders:   []string{"*"},
        ExposedHeaders:   []string{"*"},
        AllowCredentials: true,
    })

    // Create unified handler that serves both gRPC and HTTP
    handler := g.grpcHandlerFunc(g.grpcServer, c.Handler(mux))
    
    g.httpServer = &http.Server{
        Handler: h2c.NewHandler(handler, &http2.Server{}),
        Addr:    ":" + g.port,
    }

    log.Printf("Starting server on port %s", g.port)
    return g.httpServer.Serve(lis)
}

// grpcHandlerFunc returns an http.Handler that delegates to grpcServer on incoming gRPC
// connections or httpHandler otherwise
func (g *Gateway) grpcHandlerFunc(grpcServer *grpc.Server, httpHandler http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.ProtoMajor == 2 && strings.HasPrefix(
            r.Header.Get("Content-Type"), "application/grpc") {
            grpcServer.ServeHTTP(w, r)
        } else {
            httpHandler.ServeHTTP(w, r)
        }
    })
}

Authentication and Authorization

// JWT middleware for both gRPC and HTTP
func (g *Gateway) unaryInterceptor(
    ctx context.Context,
    req interface{},
    info *grpc.UnaryServerInfo,
    handler grpc.UnaryHandler,
) (interface{}, error) {
    // Skip auth for health checks
    if info.FullMethod == "/grpc.health.v1.Health/Check" {
        return handler(ctx, req)
    }

    // Extract token from metadata
    md, ok := metadata.FromIncomingContext(ctx)
    if !ok {
        return nil, status.Errorf(codes.Unauthenticated, "missing metadata")
    }

    token := extractToken(md)
    if token == "" {
        return nil, status.Errorf(codes.Unauthenticated, "missing token")
    }

    // Validate token
    claims, err := g.validateToken(token)
    if err != nil {
        return nil, status.Errorf(codes.Unauthenticated, "invalid token: %v", err)
    }

    // Add user info to context
    ctx = context.WithValue(ctx, "user_id", claims.UserID)
    ctx = context.WithValue(ctx, "user_role", claims.Role)

    return handler(ctx, req)
}

// Custom metadata annotation for HTTP requests
func (g *Gateway) annotateContext(ctx context.Context, req *http.Request) metadata.MD {
    md := metadata.MD{}
    
    // Forward auth header as gRPC metadata
    if auth := req.Header.Get("Authorization"); auth != "" {
        md.Set("authorization", auth)
    }
    
    // Forward request ID
    if reqID := req.Header.Get("X-Request-ID"); reqID != "" {
        md.Set("x-request-id", reqID)
    }
    
    return md
}

Error Handling and Validation

func (g *Gateway) customErrorHandler(
    ctx context.Context,
    mux *runtime.ServeMux,
    marshaler runtime.Marshaler,
    w http.ResponseWriter,
    r *http.Request,
    err error,
) {
    // Custom error response format
    type ErrorResponse struct {
        Error struct {
            Code    string            `json:"code"`
            Message string            `json:"message"`
            Details map[string]string `json:"details,omitempty"`
        } `json:"error"`
    }

    var httpStatus int
    var code string
    var details map[string]string

    // Extract gRPC status
    if s := status.Convert(err); s != nil {
        switch s.Code() {
        case codes.NotFound:
            httpStatus = http.StatusNotFound
            code = "NOT_FOUND"
        case codes.InvalidArgument:
            httpStatus = http.StatusBadRequest
            code = "INVALID_ARGUMENT"
            details = extractValidationErrors(s)
        case codes.Unauthenticated:
            httpStatus = http.StatusUnauthorized
            code = "UNAUTHENTICATED"
        case codes.PermissionDenied:
            httpStatus = http.StatusForbidden
            code = "PERMISSION_DENIED"
        default:
            httpStatus = http.StatusInternalServerError
            code = "INTERNAL_ERROR"
        }
    } else {
        httpStatus = http.StatusInternalServerError
        code = "INTERNAL_ERROR"
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(httpStatus)

    response := ErrorResponse{}
    response.Error.Code = code
    response.Error.Message = err.Error()
    response.Error.Details = details

    json.NewEncoder(w).Encode(response)
}

func extractValidationErrors(s *status.Status) map[string]string {
    details := make(map[string]string)
    
    for _, detail := range s.Details() {
        if violations, ok := detail.(*errdetails.BadRequest); ok {
            for _, violation := range violations.FieldViolations {
                details[violation.Field] = violation.Description
            }
        }
    }
    
    return details
}

Performance Optimizations

Connection Pooling

// Connection pool for downstream services
type ConnectionPool struct {
    pools map[string]*grpc.ClientConn
    mu    sync.RWMutex
}

func NewConnectionPool() *ConnectionPool {
    return &ConnectionPool{
        pools: make(map[string]*grpc.ClientConn),
    }
}

func (cp *ConnectionPool) GetConnection(target string) (*grpc.ClientConn, error) {
    cp.mu.RLock()
    if conn, exists := cp.pools[target]; exists {
        cp.mu.RUnlock()
        return conn, nil
    }
    cp.mu.RUnlock()

    cp.mu.Lock()
    defer cp.mu.Unlock()

    // Double-check pattern
    if conn, exists := cp.pools[target]; exists {
        return conn, nil
    }

    conn, err := grpc.Dial(target,
        grpc.WithTransportCredentials(insecure.NewCredentials()),
        grpc.WithKeepaliveParams(keepalive.ClientParameters{
            Time:                10 * time.Second,
            Timeout:             3 * time.Second,
            PermitWithoutStream: true,
        }),
        grpc.WithDefaultCallOptions(
            grpc.MaxCallRecvMsgSize(4*1024*1024), // 4MB
            grpc.MaxCallSendMsgSize(4*1024*1024), // 4MB
        ),
    )

    if err != nil {
        return nil, err
    }

    cp.pools[target] = conn
    return conn, nil
}

Response Caching

// Cache middleware for read operations
func (g *Gateway) cacheMiddleware(
    ctx context.Context,
    req interface{},
    info *grpc.UnaryServerInfo,
    handler grpc.UnaryHandler,
) (interface{}, error) {
    // Only cache GET operations
    if !strings.Contains(info.FullMethod, "Get") &&
       !strings.Contains(info.FullMethod, "List") {
        return handler(ctx, req)
    }

    // Generate cache key
    key := g.generateCacheKey(info.FullMethod, req)
    
    // Try cache first
    if cached, found := g.cache.Get(key); found {
        return cached, nil
    }

    // Execute request
    resp, err := handler(ctx, req)
    if err != nil {
        return resp, err
    }

    // Cache successful responses
    g.cache.SetWithTTL(key, resp, 5*time.Minute)
    
    return resp, err
}

Monitoring and Observability

// Metrics collection
var (
    requestDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name: "grpc_gateway_request_duration_seconds",
            Help: "Request duration in seconds",
        },
        []string{"method", "status"},
    )
    
    requestsTotal = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "grpc_gateway_requests_total",
            Help: "Total number of requests",
        },
        []string{"method", "status"},
    )
)

func init() {
    prometheus.MustRegister(requestDuration)
    prometheus.MustRegister(requestsTotal)
}

// Metrics interceptor
func metricsInterceptor(
    ctx context.Context,
    req interface{},
    info *grpc.UnaryServerInfo,
    handler grpc.UnaryHandler,
) (interface{}, error) {
    start := time.Now()
    
    resp, err := handler(ctx, req)
    
    status := "success"
    if err != nil {
        status = "error"
    }
    
    requestDuration.WithLabelValues(info.FullMethod, status).
        Observe(time.Since(start).Seconds())
    requestsTotal.WithLabelValues(info.FullMethod, status).Inc()
    
    return resp, err
}

Production Deployment

# Multi-stage Docker build
FROM golang:1.21-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o gateway ./cmd/gateway

FROM alpine:3.18
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /root/

COPY --from=builder /app/gateway .
COPY --from=builder /app/configs ./configs

EXPOSE 8080
CMD ["./gateway"]

Kubernetes deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: grpc-gateway
spec:
  replicas: 3
  selector:
    matchLabels:
      app: grpc-gateway
  template:
    metadata:
      labels:
        app: grpc-gateway
    spec:
      containers:
      - name: gateway
        image: grpc-gateway:latest
        ports:
        - containerPort: 8080
        env:
        - name: PORT
          value: "8080"
        resources:
          requests:
            memory: "128Mi"
            cpu: "100m"
          limits:
            memory: "512Mi"
            cpu: "500m"
        livenessProbe:
          grpc:
            port: 8080
          initialDelaySeconds: 30
        readinessProbe:
          grpc:
            port: 8080
          initialDelaySeconds: 5
---
apiVersion: v1
kind: Service
metadata:
  name: grpc-gateway-service
spec:
  selector:
    app: grpc-gateway
  ports:
  - protocol: TCP
    port: 80
    targetPort: 8080
  type: LoadBalancer

Production Lessons

What Worked Well

  1. Single binary deployment — Both REST and gRPC from one process
  2. Automatic validation — protoc-gen-validate catches errors early
  3. OpenAPI generation — Documentation stays in sync
  4. Type safety — Shared protobuf definitions prevent mismatches

Pain Points

  1. Complex field mapping — Some REST patterns don't map well to gRPC
  2. Limited query parameters — Complex filtering needs custom handling
  3. Binary compatibility — Protobuf changes can break clients
  4. Debugging complexity — Two protocols, one codebase

Performance Results

Our production metrics after 6 months:

  • 100K+ requests/day handled by 3 gateway instances
  • 95th percentile latency: 50ms for simple CRUD operations
  • Memory usage: 150MB per instance under normal load
  • 99.9% uptime with proper health checks and retries

Best Practices Summary

  1. Design APIs protobuf-first — REST follows gRPC patterns
  2. Use buf for schema management — Breaking changes detection
  3. Implement proper error mapping — HTTP status codes matter
  4. Cache aggressively — Same data, different protocols
  5. Monitor everything — Two protocols, double the metrics
  6. Test both interfaces — Same logic, different edge cases

Conclusion

gRPC Gateway isn't a silver bullet, but it's a powerful pattern for unified API layers. The key is embracing protobuf-first design and understanding that you're building a bridge, not trying to make REST look exactly like traditional REST APIs.

The benefits — single codebase, type safety, automatic documentation — outweigh the complexity in most cases. Just make sure you're solving the right problem: exposing gRPC services to HTTP clients, not retrofitting REST onto existing gRPC designs.