gRPC-Connect Protocol: Go Microservice Unified Frontend-Backend Communication 2026

技术架构

Introduction: Why Your Microservice Frontend-Backend Communication Is Still a Mess

It's 2026. If your Go microservices are still using REST + JSON and manually maintaining two sets of API definitions (backend Protobuf + frontend TypeScript), you're deep in the following quagmire: backend changes a field and frontend doesn't know, Swagger docs are always outdated, streaming communication requires hacking with WebSocket, error codes are defined separately on each side...

The gRPC-Connect protocol ends all of this. It defines one set of services based on Protobuf, the backend uses gRPC for high-performance communication, and the frontend seamlessly calls using Connect-RPC's HTTP/JSON mode. One Proto, both sides work. The maturity of the Buf ecosystem makes this simpler than ever.

This article walks you through building 5 gRPC-Connect core patterns in Go, covering the full chain from service definition to production-grade gateway.

Core Concepts at a Glance

Concept Description Ecosystem Tools
gRPC-Connect HTTP/2-based RPC protocol supporting both gRPC and Connect protocols connect-go
Connect-RPC Go implementation of the Connect protocol, supporting gRPC/gRPC-Web/Connect modes connectrpc.com
Buf Protobuf build toolchain, replacing protoc buf.build
gRPC-Web Browser-side gRPC protocol connect-web
Protobuf Interface Definition Language google.golang.org/protobuf
Streaming Server/Client/Bidirectional Streaming connect-go
Interceptor Connect middleware mechanism connect-go

Five Pain Points: Why Traditional Frontend-Backend Communication Can't Keep Up

Pain Point 1: Dual API Definition Maintenance Nightmare. Backend defines gRPC services with Protobuf, frontend defines REST APIs with TypeScript interfaces — two sets of definitions that are never in sync.

Pain Point 2: Swagger Docs Are Useless. Manually maintained OpenAPI docs always lag behind actual code. Frontend developers constantly debug against outdated documentation.

Pain Point 3: Fragmented Streaming Solutions. gRPC Streaming isn't available in browsers, forcing WebSocket adoption and leading to protocol fragmentation.

Pain Point 4: Inconsistent Error Handling. gRPC uses Status Codes, REST uses HTTP Status Codes — frontend needs two sets of error handling logic.

Pain Point 5: Chaotic Code Generation Toolchain. protoc plugin version conflicts, inconsistent generated code styles, and difficult CI/CD integration.

Pattern 1: Connect-RPC Service Definition

Define services using Buf and Connect-RPC — one Proto generates both Go backend and TypeScript frontend code.

// Runtime: Buf v1.47+, connect-go v1.18+, protoc-gen-go v1.34+
// File: proto/order/v1/order.proto

syntax = "proto3";

package order.v1;

option go_package = "github.com/example/gen/order/v1;orderv1";

service OrderService {
  rpc CreateOrder(CreateOrderRequest) returns (CreateOrderResponse) {}
  rpc GetOrder(GetOrderRequest) returns (GetOrderResponse) {}
  rpc StreamOrders(StreamOrdersRequest) returns (stream Order) {}
  rpc UploadOrders(stream UploadOrderRequest) returns (UploadOrdersResponse) {}
  rpc OrderChat(stream ChatMessage) returns (stream ChatMessage) {}
}

message CreateOrderRequest {
  string user_id = 1;
  repeated OrderItem items = 2;
  string shipping_address = 3;
}

message CreateOrderResponse {
  string order_id = 1;
  string status = 2;
  int64 created_at = 3;
}

message GetOrderRequest {
  string order_id = 1;
}

message GetOrderResponse {
  Order order = 1;
}

message Order {
  string order_id = 1;
  string user_id = 2;
  repeated OrderItem items = 3;
  string status = 4;
  int64 created_at = 5;
  int64 updated_at = 6;
}

message OrderItem {
  string product_id = 1;
  string product_name = 2;
  int32 quantity = 3;
  double price = 4;
}

message StreamOrdersRequest {
  string user_id = 1;
}

message UploadOrderRequest {
  string user_id = 1;
  repeated OrderItem items = 2;
}

message UploadOrdersResponse {
  int32 total_created = 1;
  repeated string order_ids = 2;
}

message ChatMessage {
  string sender = 1;
  string message = 2;
  int64 timestamp = 3;
}
# File: buf.yaml
version: v2
modules:
  - path: proto
    name: buf.build/example/orders
lint:
  use:
    - STANDARD
breaking:
  use:
    - FILE
# File: buf.gen.yaml
version: v2
managed:
  enabled: true
  override:
    - file_option: go_package_prefix
      value: github.com/example/gen
plugins:
  - remote: buf.build/connectrpc/go:v1.18.0
    out: gen/go
    opt: paths=source_relative
  - remote: buf.build/protocolbuffers/go:v1.34.0
    out: gen/go
    opt: paths=source_relative
  - remote: buf.build/connectrpc/es:v2.0.0
    out: gen/ts
  - remote: buf.build/bufbuild/es:v2.0.0
    out: gen/ts
// Runtime: Go 1.22+, connect-go v1.18.0
// File: server/main.go
package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"time"

	"connectrpc.com/connect"
	"connectrpc.com/grpcreflect"
	"github.com/example/gen/order/v1/orderv1connect"

	orderpb "github.com/example/gen/order/v1"
)

// OrderServiceHandler implements the order service
type OrderServiceHandler struct {
	orders map[string]*orderpb.Order
}

// CreateOrder creates a new order
func (h *OrderServiceHandler) CreateOrder(
	ctx context.Context,
	req *connect.Request[orderpb.CreateOrderRequest],
) (*connect.Response[orderpb.CreateOrderResponse], error) {
	msg := req.Msg

	orderID := fmt.Sprintf("ord-%d", time.Now().UnixNano())
	order := &orderpb.Order{
		OrderId:    orderID,
		UserId:     msg.UserId,
		Items:      msg.Items,
		Status:     "CREATED",
		CreatedAt:  time.Now().Unix(),
		UpdatedAt:  time.Now().Unix(),
	}
	h.orders[orderID] = order

	resp := connect.NewResponse(&orderpb.CreateOrderResponse{
		OrderId:   orderID,
		Status:    "CREATED",
		CreatedAt: order.CreatedAt,
	})

	resp.Header().Set("X-Request-Id", fmt.Sprintf("req-%d", time.Now().UnixNano()))
	return resp, nil
}

// GetOrder retrieves an order by ID
func (h *OrderServiceHandler) GetOrder(
	ctx context.Context,
	req *connect.Request[orderpb.GetOrderRequest],
) (*connect.Response[orderpb.GetOrderResponse], error) {
	order, exists := h.orders[req.Msg.OrderId]
	if !exists {
		return nil, connect.NewError(connect.CodeNotFound,
			fmt.Errorf("order %s not found", req.Msg.OrderId))
	}

	resp := connect.NewResponse(&orderpb.GetOrderResponse{
		Order: order,
	})
	return resp, nil
}

// StreamOrders streams orders to the client
func (h *OrderServiceHandler) StreamOrders(
	ctx context.Context,
	req *connect.Request[orderpb.StreamOrdersRequest],
	stream *connect.ServerStream[orderpb.Order],
) error {
	for _, order := range h.orders {
		if order.UserId == req.Msg.UserId {
			if err := stream.Send(order); err != nil {
				return fmt.Errorf("failed to stream order: %w", err)
			}
			time.Sleep(100 * time.Millisecond)
		}
	}
	return nil
}

// UploadOrders handles client streaming upload
func (h *OrderServiceHandler) UploadOrders(
	ctx context.Context,
	stream *connect.ClientStream[orderpb.UploadOrderRequest],
) (*connect.Response[orderpb.UploadOrdersResponse], error) {
	var totalCreated int32
	var orderIDs []string

	for stream.Receive() {
		msg := stream.Msg()
		orderID := fmt.Sprintf("ord-%d", time.Now().UnixNano())
		order := &orderpb.Order{
			OrderId:   orderID,
			UserId:    msg.UserId,
			Items:     msg.Items,
			Status:    "CREATED",
			CreatedAt: time.Now().Unix(),
		}
		h.orders[orderID] = order
		totalCreated++
		orderIDs = append(orderIDs, orderID)
	}

	if stream.Err() != nil {
		return nil, connect.NewError(connect.CodeInternal, stream.Err())
	}

	resp := connect.NewResponse(&orderpb.UploadOrdersResponse{
		TotalCreated: totalCreated,
		OrderIds:     orderIDs,
	})
	return resp, nil
}

// OrderChat handles bidirectional streaming chat
func (h *OrderServiceHandler) OrderChat(
	ctx context.Context,
	stream *connect.BidiStream[orderpb.ChatMessage, orderpb.ChatMessage],
) error {
	for {
		msg, err := stream.Receive()
		if err != nil {
			return nil
		}

		reply := &orderpb.ChatMessage{
			Sender:    "system",
			Message:   fmt.Sprintf("Received from %s: %s", msg.Sender, msg.Message),
			Timestamp: time.Now().Unix(),
		}

		if err := stream.Send(reply); err != nil {
			return fmt.Errorf("failed to send reply: %w", err)
		}
	}
}

func main() {
	handler := &OrderServiceHandler{
		orders: make(map[string]*orderpb.Order),
	}

	mux := http.NewServeMux()

	path, orderHandler := orderv1connect.NewOrderServiceHandler(handler)
	mux.Handle(path, orderHandler)

	reflector := grpcreflect.NewStaticReflector(
		orderv1connect.OrderServiceName,
	)
	mux.Handle(grpcreflect.NewHandlerV1(reflector))
	mux.Handle(grpcreflect.NewHandlerV1Alpha(reflector))

	log.Println("Connect-RPC server starting on :8080")
	log.Println("Supported protocols: gRPC, gRPC-Web, Connect")
	log.Fatal(http.ListenAndServe(":8080", mux))
}

Pattern 2: Frontend gRPC-Web Calls

Use Connect-Web to call gRPC services directly from the frontend, eliminating the REST middleware layer.

// Runtime: TypeScript 5.5+, @connectrpc/connect-web v2.0.0
// File: frontend/src/client.ts

import { createConnectTransport } from "@connectrpc/connect-web";
import { createClient } from "@connectrpc/connect";
import { OrderService } from "../gen/ts/order/v1/order_pb";

// Create Connect transport (HTTP/1.1 compatible)
const connectTransport = createConnectTransport({
  baseUrl: "https://api.example.com",
});

// Create gRPC-Web transport
const grpcWebTransport = createConnectTransport({
  baseUrl: "https://api.example.com",
  httpVersion: "2",
});

// Create order service client
const orderClient = createClient(OrderService, connectTransport);

// === Unary call ===
async function createOrder() {
  try {
    const response = await orderClient.createOrder({
      userId: "user-123",
      items: [
        { productId: "prod-1", productName: "Go Programming", quantity: 1, price: 89.0 },
        { productId: "prod-2", productName: "Rust in Action", quantity: 2, price: 99.0 },
      ],
      shippingAddress: "123 Main St, San Francisco",
    });

    console.log("Order created:", response.orderId, response.status);
  } catch (err) {
    console.error("Failed to create order:", err.code, err.message);
  }
}

// === Get order ===
async function getOrder(orderId: string) {
  try {
    const response = await orderClient.getOrder({ orderId });
    console.log("Order details:", response.order);
  } catch (err: any) {
    if (err.code === "NOT_FOUND") {
      console.warn("Order not found");
    } else {
      console.error("Failed to get order:", err.message);
    }
  }
}

// === Server Streaming ===
async function streamOrders(userId: string) {
  try {
    for await (const order of orderClient.streamOrders({ userId })) {
      console.log("Real-time order update:", order.orderId, order.status);
      updateOrderInUI(order);
    }
  } catch (err) {
    console.error("Order stream interrupted:", err.message);
  }
}

// === Client Streaming ===
async function uploadOrders() {
  const orders = [
    { userId: "user-1", items: [{ productId: "p1", productName: "Item 1", quantity: 1, price: 10 }] },
    { userId: "user-2", items: [{ productId: "p2", productName: "Item 2", quantity: 2, price: 20 }] },
  ];

  try {
    const response = await orderClient.uploadOrders(orders);
    console.log(`Successfully uploaded ${response.totalCreated} orders`);
  } catch (err) {
    console.error("Failed to upload orders:", err.message);
  }
}

function useOrderService() {
  return { createOrder, getOrder, streamOrders, uploadOrders };
}

function updateOrderInUI(order: any) {}

Pattern 3: Error Handling and Retry

Connect-RPC provides a unified error handling mechanism — both frontend and backend use the same error codes and message format.

// Runtime: Go 1.22+, connect-go v1.18.0
// File: server/errors.go
package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"time"

	"connectrpc.com/connect"
)

// OrderError represents a business error
type OrderError struct {
	Code      connect.Code `json:"code"`
	Message   string       `json:"message"`
	Detail    string       `json:"detail,omitempty"`
	Retryable bool         `json:"retryable"`
}

func (e *OrderError) Error() string {
	return fmt.Sprintf("[%s] %s: %s", e.Code, e.Message, e.Detail)
}

// ToConnectError converts to a Connect error
func (e *OrderError) ToConnectError() *connect.Error {
	err := connect.NewError(e.Code, fmt.Errorf("%s: %s", e.Message, e.Detail))
	if e.Retryable {
		err.Meta().Set("Retry-After", "5")
		err.Meta().Set("X-Retryable", "true")
	}
	return err
}

var (
	ErrOrderNotFound = &OrderError{
		Code:      connect.CodeNotFound,
		Message:   "Order not found",
		Retryable: false,
	}
	ErrOrderAlreadyCancelled = &OrderError{
		Code:      connect.CodeFailedPrecondition,
		Message:   "Order already cancelled",
		Retryable: false,
	}
	ErrInsufficientStock = &OrderError{
		Code:      connect.CodeResourceExhausted,
		Message:   "Insufficient stock",
		Retryable: true,
	}
	ErrPaymentTimeout = &OrderError{
		Code:      connect.CodeDeadlineExceeded,
		Message:   "Payment timeout",
		Retryable: true,
	}
)

// RetryInterceptor implements client-side retry logic
type RetryInterceptor struct {
	maxRetries     int
	initialDelay   time.Duration
	maxDelay       time.Duration
	retryableCodes map[connect.Code]bool
}

func NewRetryInterceptor() *RetryInterceptor {
	return &RetryInterceptor{
		maxRetries:   3,
		initialDelay: 100 * time.Millisecond,
		maxDelay:     5 * time.Second,
		retryableCodes: map[connect.Code]bool{
			connect.CodeUnavailable:       true,
			connect.CodeResourceExhausted: true,
			connect.CodeDeadlineExceeded:  true,
			connect.CodeAborted:           true,
		},
	}
}

func (i *RetryInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc {
	return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
		var lastErr error

		for attempt := 0; attempt <= i.maxRetries; attempt++ {
			resp, err := next(ctx, req)
			if err == nil {
				return resp, nil
			}

			connectErr, ok := err.(*connect.Error)
			if !ok {
				return nil, err
			}

			if !i.retryableCodes[connectErr.Code()] {
				return nil, err
			}

			retryAfter := connectErr.Meta().Get("Retry-After")
			delay := i.calculateDelay(attempt, retryAfter)

			lastErr = err
			log.Printf("Retry %d/%d, delay %v, error: %v",
				attempt+1, i.maxRetries, delay, connectErr.Message())

			select {
			case <-time.After(delay):
			case <-ctx.Done():
				return nil, ctx.Err()
			}
		}

		return nil, lastErr
	}
}

func (i *RetryInterceptor) calculateDelay(attempt int, retryAfter string) time.Duration {
	if retryAfter != "" {
		if d, err := time.ParseDuration(retryAfter + "s"); err == nil {
			return d
		}
	}

	delay := i.initialDelay * time.Duration(1<<uint(attempt))
	if delay > i.maxDelay {
		delay = i.maxDelay
	}
	return delay
}

// RecoveryInterceptor recovers from panics in handlers
func RecoveryInterceptor() connect.UnaryInterceptorFunc {
	return func(next connect.UnaryFunc) connect.UnaryFunc {
		return func(ctx context.Context, req connect.AnyRequest) (resp connect.AnyResponse, err error) {
			defer func() {
				if r := recover(); r != nil {
					log.Printf("[PANIC] Handler panic: %v", r)
					err = connect.NewError(connect.CodeInternal,
						fmt.Errorf("internal server error, please retry later"))
				}
			}()
			return next(ctx, req)
		}
	}
}

// ErrorLoggingInterceptor logs errors
func ErrorLoggingInterceptor() connect.UnaryInterceptorFunc {
	return func(next connect.UnaryFunc) connect.UnaryFunc {
		return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
			startTime := time.Now()
			resp, err := next(ctx, req)

			if err != nil {
				connectErr, ok := err.(*connect.Error)
				if ok {
					log.Printf("[ERROR] method=%s code=%s msg=%s duration=%v",
						req.Spec().Procedure,
						connectErr.Code(),
						connectErr.Message(),
						time.Since(startTime),
					)
				}
			}

			return resp, err
		}
	}
}

func main() {
	mux := http.NewServeMux()

	interceptors := []connect.Interceptor{
		RecoveryInterceptor(),
		ErrorLoggingInterceptor(),
	}

	_ = interceptors

	log.Println("Error handling service starting on :8080")
	log.Fatal(http.ListenAndServe(":8080", mux))
}

Pattern 4: Streaming Communication

Connect-RPC fully supports three streaming communication modes with unified Protobuf definitions for both frontend and backend.

// Runtime: Go 1.22+, connect-go v1.18.0
// File: server/streaming.go
package main

import (
	"context"
	"fmt"
	"io"
	"log"
	"math/rand"
	"net/http"
	"sync"
	"time"

	"connectrpc.com/connect"
)

// OrderStatusStream manages order status subscriptions
type OrderStatusStream struct {
	subscribers map[string]chan *OrderStatusUpdate
	mu          sync.RWMutex
}

type OrderStatusUpdate struct {
	OrderID string `json:"order_id"`
	Status  string `json:"status"`
	Message string `json:"message"`
}

func NewOrderStatusStream() *OrderStatusStream {
	return &OrderStatusStream{
		subscribers: make(map[string]chan *OrderStatusUpdate),
	}
}

func (s *OrderStatusStream) Subscribe(userID string) <-chan *OrderStatusUpdate {
	s.mu.Lock()
	defer s.mu.Unlock()

	ch := make(chan *OrderStatusUpdate, 100)
	s.subscribers[userID] = ch
	return ch
}

func (s *OrderStatusStream) Unsubscribe(userID string) {
	s.mu.Lock()
	defer s.mu.Unlock()

	if ch, ok := s.subscribers[userID]; ok {
		close(ch)
		delete(s.subscribers, userID)
	}
}

func (s *OrderStatusStream) Publish(update *OrderStatusUpdate) {
	s.mu.RLock()
	defer s.mu.RUnlock()

	for _, ch := range s.subscribers {
		select {
		case ch <- update:
		default:
			log.Printf("Subscriber channel full, dropping update: %s", update.OrderID)
		}
	}
}

// BatchOrderImporter handles batch order imports
type BatchOrderImporter struct {
	processedCount int
	failedCount    int
	mu             sync.Mutex
}

type OrderImportRequest struct {
	UserID string `json:"user_id"`
	Data   string `json:"data"`
}

type BatchImportResult struct {
	BatchID        string `json:"batch_id"`
	ProcessedCount int32  `json:"processed_count"`
	FailedCount    int32  `json:"failed_count"`
}

func (b *BatchOrderImporter) ProcessStream(
	ctx context.Context,
	stream *connect.ClientStream[OrderImportRequest],
) (*BatchImportResult, error) {
	batchID := fmt.Sprintf("batch-%d", time.Now().UnixNano())

	for stream.Receive() {
		req := stream.Msg()
		if err := b.processOne(ctx, req); err != nil {
			b.mu.Lock()
			b.failedCount++
			b.mu.Unlock()
			continue
		}
		b.mu.Lock()
		b.processedCount++
		b.mu.Unlock()
	}

	if stream.Err() != nil {
		return nil, connect.NewError(connect.CodeInternal, stream.Err())
	}

	return &BatchImportResult{
		BatchID:        batchID,
		ProcessedCount: int32(b.processedCount),
		FailedCount:    int32(b.failedCount),
	}, nil
}

func (b *BatchOrderImporter) processOne(ctx context.Context, req *OrderImportRequest) error {
	select {
	case <-time.After(time.Duration(rand.Intn(50)) * time.Millisecond):
	case <-ctx.Done():
		return ctx.Err()
	}
	return nil
}

// CollaborationRoom manages real-time collaboration
type CollaborationRoom struct {
	clients map[string]chan *CollabMessage
	mu      sync.RWMutex
}

type CollabMessage struct {
	UserID  string `json:"user_id"`
	Content string `json:"content"`
	Type    string `json:"type"`
}

func (r *CollaborationRoom) Join(userID string) chan *CollabMessage {
	r.mu.Lock()
	defer r.mu.Unlock()

	ch := make(chan *CollabMessage, 50)
	r.clients[userID] = ch
	return ch
}

func (r *CollaborationRoom) Leave(userID string) {
	r.mu.Lock()
	defer r.mu.Unlock()

	if ch, ok := r.clients[userID]; ok {
		close(ch)
		delete(r.clients, userID)
	}
}

func (r *CollaborationRoom) Broadcast(msg *CollabMessage, excludeUserID string) {
	r.mu.RLock()
	defer r.mu.RUnlock()

	for userID, ch := range r.clients {
		if userID == excludeUserID {
			continue
		}
		select {
		case ch <- msg:
		default:
			log.Printf("Client %s channel full", userID)
		}
	}
}

func (r *CollaborationRoom) HandleCollabStream(
	ctx context.Context,
	stream *connect.BidiStream[CollabMessage, CollabMessage],
) error {
	userID := stream.RequestHeader().Get("X-User-Id")
	if userID == "" {
		userID = fmt.Sprintf("user-%d", rand.Int63())
	}

	recvCh := r.Join(userID)
	defer r.Leave(userID)

	errCh := make(chan error, 1)
	go func() {
		for {
			msg, err := stream.Receive()
			if err != nil {
				if err == io.EOF {
					errCh <- nil
					return
				}
				errCh <- err
				return
			}
			r.Broadcast(msg, userID)
		}
	}()

	for {
		select {
		case msg := <-recvCh:
			if err := stream.Send(msg); err != nil {
				return fmt.Errorf("failed to send collab message: %w", err)
			}
		case err := <-errCh:
			return err
		case <-ctx.Done():
			return ctx.Err()
		}
	}
}

func main() {
	room := &CollaborationRoom{
		clients: make(map[string]chan *CollabMessage),
	}
	statusStream := NewOrderStatusStream()

	_ = room
	_ = statusStream

	log.Println("Streaming service starting on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Pattern 5: Production-Grade Connect Gateway

Build a production-grade Connect-RPC gateway with authentication, rate limiting, monitoring, and graceful shutdown.

// Runtime: Go 1.22+, connect-go v1.18.0
// File: server/gateway.go
package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"strings"
	"sync/atomic"
	"syscall"
	"time"

	"connectrpc.com/connect"
	"connectrpc.com/grpcreflect"
	"golang.org/x/net/http2"
	"golang.org/x/net/http2/h2c"
)

func AuthInterceptor(jwtSecret string) connect.UnaryInterceptorFunc {
	return func(next connect.UnaryFunc) connect.UnaryFunc {
		return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
			if strings.HasSuffix(req.Spec().Procedure, "/Health/Check") {
				return next(ctx, req)
			}

			token := req.Header().Get("Authorization")
			if token == "" {
				return nil, connect.NewError(connect.CodeUnauthenticated,
					fmt.Errorf("missing authentication token"))
			}

			token = strings.TrimPrefix(token, "Bearer ")
			claims, err := validateJWT(token, jwtSecret)
			if err != nil {
				return nil, connect.NewError(connect.CodeUnauthenticated,
					fmt.Errorf("invalid authentication token: %w", err))
			}

			ctx = context.WithValue(ctx, "userID", claims.UserID)
			ctx = context.WithValue(ctx, "scopes", claims.Scopes)

			return next(ctx, req)
		}
	}
}

type JWTClaims struct {
	UserID string   `json:"user_id"`
	Scopes []string `json:"scopes"`
}

func validateJWT(token, secret string) (*JWTClaims, error) {
	return &JWTClaims{
		UserID: "user-from-token",
		Scopes: []string{"orders:read", "orders:write"},
	}, nil
}

type RateLimiter struct {
	tokens     atomic.Int64
	maxTokens  int64
	refillRate time.Duration
}

func NewRateLimiter(maxTokens int64, refillRate time.Duration) *RateLimiter {
	rl := &RateLimiter{
		maxTokens:  maxTokens,
		refillRate: refillRate,
	}
	rl.tokens.Store(maxTokens)

	go func() {
		ticker := time.NewTicker(refillRate)
		defer ticker.Stop()
		for range ticker.C {
			current := rl.tokens.Load()
			if current < maxTokens {
				rl.tokens.CompareAndSwap(current, current+1)
			}
		}
	}()

	return rl
}

func (rl *RateLimiter) Allow() bool {
	for {
		current := rl.tokens.Load()
		if current <= 0 {
			return false
		}
		if rl.tokens.CompareAndSwap(current, current-1) {
			return true
		}
	}
}

func RateLimitInterceptor(limiter *RateLimiter) connect.UnaryInterceptorFunc {
	return func(next connect.UnaryFunc) connect.UnaryFunc {
		return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
			if !limiter.Allow() {
				return nil, connect.NewError(connect.CodeResourceExhausted,
					fmt.Errorf("rate limit exceeded, please retry later"))
			}
			return next(ctx, req)
		}
	}
}

func TracingInterceptor() connect.UnaryInterceptorFunc {
	return func(next connect.UnaryFunc) connect.UnaryFunc {
		return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
			startTime := time.Now()
			traceID := req.Header().Get("X-Trace-Id")
			if traceID == "" {
				traceID = fmt.Sprintf("trace-%d", startTime.UnixNano())
			}

			log.Printf("[TRACE] id=%s method=%s start=%v",
				traceID, req.Spec().Procedure, startTime)

			resp, err := next(ctx, req)

			duration := time.Since(startTime)
			status := "OK"
			if err != nil {
				status = "ERROR"
			}

			log.Printf("[TRACE] id=%s method=%s status=%s duration=%v",
				traceID, req.Spec().Procedure, status, duration)

			if resp != nil {
				resp.Header().Set("X-Trace-Id", traceID)
				resp.Header().Set("X-Response-Time", duration.String())
			}

			return resp, err
		}
	}
}

type ConnectGateway struct {
	server     *http.Server
	limiter    *RateLimiter
	shutdownCh chan os.Signal
}

func NewConnectGateway(addr string) *ConnectGateway {
	gw := &ConnectGateway{
		limiter:    NewRateLimiter(1000, time.Second),
		shutdownCh: make(chan os.Signal, 1),
	}

	mux := http.NewServeMux()

	reflector := grpcreflect.NewStaticReflector()
	mux.Handle(grpcreflect.NewHandlerV1(reflector))

	mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		w.Write([]byte("ok"))
	})

	interceptors := []connect.Interceptor{
		TracingInterceptor(),
		RateLimitInterceptor(gw.limiter),
		AuthInterceptor("your-jwt-secret"),
	}

	_ = interceptors

	gw.server = &http.Server{
		Addr:    addr,
		Handler: h2c.NewHandler(mux, &http2.Server{}),
	}

	return gw
}

func (gw *ConnectGateway) Start() error {
	signal.Notify(gw.shutdownCh, syscall.SIGINT, syscall.SIGTERM)

	errCh := make(chan error, 1)
	go func() {
		log.Printf("Connect gateway starting on %s", gw.server.Addr)
		log.Println("Supported protocols: gRPC, gRPC-Web, Connect (HTTP/1.1 & HTTP/2)")
		if err := gw.server.ListenAndServe(); err != http.ErrServerClosed {
			errCh <- err
		}
	}()

	select {
	case err := <-errCh:
		return fmt.Errorf("gateway failed to start: %w", err)
	case sig := <-gw.shutdownCh:
		log.Printf("Received signal %v, starting graceful shutdown...", sig)
		return gw.Shutdown()
	}
}

func (gw *ConnectGateway) Shutdown() error {
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	log.Println("Stopping new request acceptance...")
	if err := gw.server.Shutdown(ctx); err != nil {
		return fmt.Errorf("gateway shutdown failed: %w", err)
	}

	log.Println("Gateway gracefully shut down")
	return nil
}

func main() {
	gateway := NewConnectGateway(":8080")
	if err := gateway.Start(); err != nil {
		log.Fatalf("Gateway failed: %v", err)
	}
}

Pitfall Guide: 5 Production-Grade Traps

Trap 1: Buf Code Generation Version Conflicts. Different plugin versions generate incompatible code, causing compilation failures. Solution: Lock plugin versions in buf.gen.yaml, use buf generate in CI instead of local protoc.

Trap 2: HTTP/2 Negotiation Failure. Some reverse proxies (older Nginx versions) don't support HTTP/2, causing gRPC call failures. Solution: Use the Connect protocol (HTTP/1.1 compatible) as a fallback, or upgrade proxies to support h2c.

Trap 3: Stream Disconnection Goes Unnoticed. Network jitter causes streaming connections to drop without the server knowing. Solution: Implement heartbeat mechanisms (ping every 30 seconds), set read/write timeouts, and client auto-reconnection.

Trap 4: Large Message Bodies Cause OOM. Streaming large file uploads fills the receiver buffer, causing OOM. Solution: Set maximum message body size (Connect defaults to 4MB), process in streaming chunks, implement backpressure.

Trap 5: Missing CORS Configuration. Browser-side Connect calls are blocked by CORS policy. Solution: Configure CORS middleware at the gateway layer, allowing Content-Type: application/proto and application/json.

Error Troubleshooting Quick Reference

Error Message Cause Solution
connect: code = Unauthenticated Missing or invalid auth token Check Authorization header and JWT validity
connect: code = NotFound Requested resource doesn't exist Check request parameters and resource ID
connect: code = ResourceExhausted Request rate limit exceeded Reduce request frequency or adjust rate limit config
connect: code = Unavailable Service unavailable Check service health and network connectivity
http2: frame too large Message body exceeds default limit Adjust connect.MaxRecvMsgSize option
CORS policy: No Access-Control-Allow-Origin Missing CORS configuration Add CORS middleware at gateway
proto: invalid wire format Protobuf encoding/decoding mismatch Check Proto file version consistency
buf: plugin not found Buf plugin not installed Run buf generate with remote plugins
stream recv: context canceled Client canceled request or timed out Increase timeout or check client logic
tls: handshake failure TLS misconfiguration Check certificate config or use h2c for development

Advanced Optimization: 5 Production-Grade Tips

Tip 1: Proto File Version Management. Use Buf's BSR (Buf Schema Registry) to manage Proto file versions, enabling API versioning and backward compatibility checks.

Tip 2: Connect Protocol Fallback Strategy. Automatically select protocol based on client capability: gRPC (backend-to-backend), gRPC-Web (older browsers), Connect (modern browsers) — no manual switching needed.

Tip 3: Stream Backpressure Control. Implement backpressure in server streaming by dynamically adjusting push rate based on client consumption speed, preventing buffer overflow.

Tip 4: Request Batching. Use Connect's Client Streaming to merge multiple small requests into one streaming request, reducing network round trips.

Tip 5: Observability Integration. Integrate OpenTelemetry in interceptors to automatically generate Spans and Metrics, incorporating Connect call chains into full distributed tracing.

Comparison Analysis

Dimension REST + JSON gRPC gRPC-Connect
Protocol HTTP/1.1 HTTP/2 HTTP/1.1 + HTTP/2
Data Format JSON Protobuf Protobuf + JSON
Browser Support Native Requires gRPC-Web Native (Connect protocol)
Streaming WebSocket Native Native
Code Generation OpenAPI/Swagger protoc Buf
Type Safety Weak (JSON) Strong (Protobuf) Strong (Protobuf)
Error Handling HTTP Status Code gRPC Status Connect Status (gRPC compatible)
Learning Curve Low Medium Medium
Performance Medium High High
Frontend-Backend Unification No No Yes

Conclusion

The 5 core patterns of the gRPC-Connect protocol solve the key pain points of microservice frontend-backend communication: Connect-RPC service definition enables one Proto for both sides, frontend gRPC-Web calls eliminate the REST middleware layer, unified error handling and retry mechanisms ensure reliability, streaming communication provides complete real-time communication capabilities, and the production-grade Connect gateway ensures system stability.

gRPC-Connect is not a REST replacement — it's the best practice for unified frontend-backend communication. If you're building a Go microservice system, the gRPC-Connect + Buf ecosystem is the tech stack worth investing in for 2026. Remember: one definition, both sides work — that's the core value of gRPC-Connect.

  • /en/json/format — JSON formatter for viewing Connect protocol JSON requests and responses
  • /en/dev/curl-to-code — cURL to code converter for quickly generating Connect client call code
  • /en/encode/hash — Hash calculator for verifying Protobuf message digests
  • /en/text/diff — Text diff tool for comparing Proto file changes across versions

Try these browser-local tools — no sign-up required →

#gRPC-Connect#Go微服务#前后端通信#Connect-RPC#Buf#2026#技术架构