The RPC Evolution: From gRPC to ConnectRPC

While gRPC has been the go-to choice for high-performance RPC communication, ConnectRPC is emerging as a compelling alternative that addresses many of gRPC’s pain points while maintaining compatibility with existing ecosystems.

What is ConnectRPC?

ConnectRPC (formerly known as Connect) is a protocol that provides a simpler, more flexible approach to RPC. It’s designed to be:

  • HTTP/1.1 and HTTP/2 compatible - Works with existing infrastructure
  • Browser-friendly - No need for special proxies
  • gRPC compatible - Can interoperate with gRPC services
  • Developer-friendly - Simpler tooling and better debugging

Why Teams Choose ConnectRPC Over gRPC

1. Browser Compatibility Without Proxies

gRPC requires special proxies (like grpc-web) to work with browsers because it relies on HTTP/2-specific features. ConnectRPC works directly in browsers using standard fetch APIs.

2. Better Debugging Experience

ConnectRPC uses standard HTTP headers and status codes, making it much easier to debug with familiar tools like curl, Postman, or browser DevTools.

3. Simpler Deployment

No need for special load balancers or proxies that support HTTP/2 trailers. ConnectRPC works with any HTTP infrastructure.

4. Three Protocols in One

ConnectRPC supports:

  • Connect protocol - Optimized for performance
  • gRPC protocol - Full gRPC compatibility
  • gRPC-Web protocol - Browser compatibility

5. Smaller Generated Code

ConnectRPC generates cleaner, more maintainable code compared to gRPC’s verbose stubs.

Practical Comparison: Building a User Service

Let’s build a simple user service to see the differences in practice.

Step 1: Define the Protocol Buffer

Both use the same .proto file:

// user.proto
syntax = "proto3";

package user.v1;

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

message User {
  string id = 1;
  string name = 2;
  string email = 3;
  int64 created_at = 4;
}

message GetUserRequest {
  string id = 1;
}

message GetUserResponse {
  User user = 1;
}

message CreateUserRequest {
  string name = 1;
  string email = 2;
}

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;
}

service UserService {
  rpc GetUser(GetUserRequest) returns (GetUserResponse);
  rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
  rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
  rpc StreamUsers(ListUsersRequest) returns (stream User);
}

Step 2: Generate Code

gRPC:

protoc --go_out=. --go_opt=paths=source_relative \
  --go-grpc_out=. --go-grpc_opt=paths=source_relative \
  user.proto

ConnectRPC:

protoc --go_out=. --go_opt=paths=source_relative \
  --connect-go_out=. --connect-go_opt=paths=source_relative \
  user.proto

Step 3: Implement the Server

ConnectRPC Implementation:

package main

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

    "connectrpc.com/connect"
    userv1 "github.com/example/user/v1"
    "github.com/example/user/v1/userv1connect"
)

// UserServer implements the UserService
type UserServer struct {
    users map[string]*userv1.User
    mu    sync.RWMutex
}

func NewUserServer() *UserServer {
    return &UserServer{
        users: make(map[string]*userv1.User),
    }
}

// GetUser retrieves a user by ID
func (s *UserServer) GetUser(
    ctx context.Context,
    req *connect.Request[userv1.GetUserRequest],
) (*connect.Response[userv1.GetUserResponse], error) {
    s.mu.RLock()
    defer s.mu.RUnlock()

    user, ok := s.users[req.Msg.Id]
    if !ok {
        return nil, connect.NewError(connect.CodeNotFound, errors.New("user not found"))
    }

    return connect.NewResponse(&userv1.GetUserResponse{
        User: user,
    }), nil
}

// CreateUser creates a new user
func (s *UserServer) CreateUser(
    ctx context.Context,
    req *connect.Request[userv1.CreateUserRequest],
) (*connect.Response[userv1.CreateUserResponse], error) {
    s.mu.Lock()
    defer s.mu.Unlock()

    user := &userv1.User{
        Id:        fmt.Sprintf("user_%d", len(s.users)+1),
        Name:      req.Msg.Name,
        Email:     req.Msg.Email,
        CreatedAt: time.Now().Unix(),
    }

    s.users[user.Id] = user

    return connect.NewResponse(&userv1.CreateUserResponse{
        User: user,
    }), nil
}

// ListUsers returns a paginated list of users
func (s *UserServer) ListUsers(
    ctx context.Context,
    req *connect.Request[userv1.ListUsersRequest],
) (*connect.Response[userv1.ListUsersResponse], error) {
    s.mu.RLock()
    defer s.mu.RUnlock()

    users := make([]*userv1.User, 0, len(s.users))
    for _, user := range s.users {
        users = append(users, user)
    }

    return connect.NewResponse(&userv1.ListUsersResponse{
        Users: users,
    }), nil
}

// StreamUsers streams users to the client
func (s *UserServer) StreamUsers(
    ctx context.Context,
    req *connect.Request[userv1.ListUsersRequest],
    stream *connect.ServerStream[userv1.User],
) error {
    s.mu.RLock()
    defer s.mu.RUnlock()

    for _, user := range s.users {
        if err := stream.Send(user); err != nil {
            return err
        }
        time.Sleep(100 * time.Millisecond) // Simulate streaming delay
    }

    return nil
}

func main() {
    server := NewUserServer()

    // Seed some data
    server.CreateUser(context.Background(), connect.NewRequest(&userv1.CreateUserRequest{
        Name:  "Alice Johnson",
        Email: "[email protected]",
    }))
    server.CreateUser(context.Background(), connect.NewRequest(&userv1.CreateUserRequest{
        Name:  "Bob Smith",
        Email: "[email protected]",
    }))

    mux := http.NewServeMux()

    // Register the Connect handler
    path, handler := userv1connect.NewUserServiceHandler(server)
    mux.Handle(path, handler)

    // Health check endpoint
    mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        w.Write([]byte("OK"))
    })

    log.Println("ConnectRPC server listening on :8080")
    if err := http.ListenAndServe(":8080", mux); err != nil {
        log.Fatal(err)
    }
}

Standard gRPC Implementation:

package main

import (
    "context"
    "errors"
    "fmt"
    "log"
    "net"
    "sync"
    "time"

    userv1 "github.com/example/user/v1"
    "google.golang.org/grpc"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

// UserServer implements the UserService
type UserServer struct {
    userv1.UnimplementedUserServiceServer
    users map[string]*userv1.User
    mu    sync.RWMutex
}

func NewUserServer() *UserServer {
    return &UserServer{
        users: make(map[string]*userv1.User),
    }
}

func (s *UserServer) GetUser(
    ctx context.Context,
    req *userv1.GetUserRequest,
) (*userv1.GetUserResponse, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()

    user, ok := s.users[req.Id]
    if !ok {
        return nil, status.Error(codes.NotFound, "user not found")
    }

    return &userv1.GetUserResponse{
        User: user,
    }, nil
}

func (s *UserServer) CreateUser(
    ctx context.Context,
    req *userv1.CreateUserRequest,
) (*userv1.CreateUserResponse, error) {
    s.mu.Lock()
    defer s.mu.Unlock()

    user := &userv1.User{
        Id:        fmt.Sprintf("user_%d", len(s.users)+1),
        Name:      req.Name,
        Email:     req.Email,
        CreatedAt: time.Now().Unix(),
    }

    s.users[user.Id] = user

    return &userv1.CreateUserResponse{
        User: user,
    }, nil
}

func (s *UserServer) ListUsers(
    ctx context.Context,
    req *userv1.ListUsersRequest,
) (*userv1.ListUsersResponse, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()

    users := make([]*userv1.User, 0, len(s.users))
    for _, user := range s.users {
        users = append(users, user)
    }

    return &userv1.ListUsersResponse{
        Users: users,
    }, nil
}

func (s *UserServer) StreamUsers(
    req *userv1.ListUsersRequest,
    stream userv1.UserService_StreamUsersServer,
) error {
    s.mu.RLock()
    defer s.mu.RUnlock()

    for _, user := range s.users {
        if err := stream.Send(user); err != nil {
            return err
        }
        time.Sleep(100 * time.Millisecond)
    }

    return nil
}

func main() {
    server := NewUserServer()

    // Seed some data
    server.CreateUser(context.Background(), &userv1.CreateUserRequest{
        Name:  "Alice Johnson",
        Email: "[email protected]",
    })
    server.CreateUser(context.Background(), &userv1.CreateUserRequest{
        Name:  "Bob Smith",
        Email: "[email protected]",
    })

    lis, err := net.Listen("tcp", ":8080")
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }

    grpcServer := grpc.NewServer()
    userv1.RegisterUserServiceServer(grpcServer, server)

    log.Println("gRPC server listening on :8080")
    if err := grpcServer.Serve(lis); err != nil {
        log.Fatalf("failed to serve: %v", err)
    }
}

Step 4: Implement the Client

ConnectRPC Client:

package main

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

    "connectrpc.com/connect"
    userv1 "github.com/example/user/v1"
    "github.com/example/user/v1/userv1connect"
)

func main() {
    client := userv1connect.NewUserServiceClient(
        http.DefaultClient,
        "http://localhost:8080",
    )

    ctx := context.Background()

    // Create a user
    createResp, err := client.CreateUser(ctx, connect.NewRequest(&userv1.CreateUserRequest{
        Name:  "Charlie Brown",
        Email: "[email protected]",
    }))
    if err != nil {
        log.Fatalf("CreateUser failed: %v", err)
    }
    fmt.Printf("Created user: %+v\n", createResp.Msg.User)

    // Get the user
    getResp, err := client.GetUser(ctx, connect.NewRequest(&userv1.GetUserRequest{
        Id: createResp.Msg.User.Id,
    }))
    if err != nil {
        log.Fatalf("GetUser failed: %v", err)
    }
    fmt.Printf("Retrieved user: %+v\n", getResp.Msg.User)

    // List all users
    listResp, err := client.ListUsers(ctx, connect.NewRequest(&userv1.ListUsersRequest{}))
    if err != nil {
        log.Fatalf("ListUsers failed: %v", err)
    }
    fmt.Printf("Total users: %d\n", len(listResp.Msg.Users))

    // Stream users
    stream, err := client.StreamUsers(ctx, connect.NewRequest(&userv1.ListUsersRequest{}))
    if err != nil {
        log.Fatalf("StreamUsers failed: %v", err)
    }

    fmt.Println("Streaming users:")
    for stream.Receive() {
        user := stream.Msg()
        fmt.Printf("  - %s (%s)\n", user.Name, user.Email)
    }
    if err := stream.Err(); err != nil {
        log.Fatalf("StreamUsers error: %v", err)
    }
}

Testing with curl (ConnectRPC advantage):

# Create a user - works directly with curl!
curl -X POST http://localhost:8080/user.v1.UserService/CreateUser \
  -H "Content-Type: application/json" \
  -d '{
    "name": "David Wilson",
    "email": "[email protected]"
  }'

# Get a user
curl http://localhost:8080/user.v1.UserService/GetUser \
  -H "Content-Type: application/json" \
  -d '{"id": "user_1"}'

Performance Comparison

Feature gRPC ConnectRPC
HTTP/1.1 Support
HTTP/2 Support
Browser Native ❌ (needs grpc-web)
curl Friendly
Performance Excellent Excellent (on par)
Streaming
Load Balancer Friendly Requires HTTP/2 Works everywhere
Generated Code Size Large Smaller
Debugging Complex Simple

Middleware and Interceptors

ConnectRPC Interceptor:

package main

import (
    "context"
    "log"
    "time"

    "connectrpc.com/connect"
)

// LoggingInterceptor logs all requests
func LoggingInterceptor() connect.UnaryInterceptorFunc {
    return func(next connect.UnaryFunc) connect.UnaryFunc {
        return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
            start := time.Now()
            log.Printf("→ %s %s", req.Spec().Procedure, req.Header().Get("User-Agent"))

            resp, err := next(ctx, req)

            duration := time.Since(start)
            if err != nil {
                log.Printf("← %s failed in %v: %v", req.Spec().Procedure, duration, err)
            } else {
                log.Printf("← %s completed in %v", req.Spec().Procedure, duration)
            }

            return resp, err
        }
    }
}

// Usage in server
func main() {
    interceptors := connect.WithInterceptors(LoggingInterceptor())

    mux := http.NewServeMux()
    path, handler := userv1connect.NewUserServiceHandler(
        NewUserServer(),
        interceptors,
    )
    mux.Handle(path, handler)

    http.ListenAndServe(":8080", mux)
}

Error Handling

ConnectRPC provides cleaner error handling with standard HTTP status codes:

func (s *UserServer) GetUser(
    ctx context.Context,
    req *connect.Request[userv1.GetUserRequest],
) (*connect.Response[userv1.GetUserResponse], error) {
    // Validation error
    if req.Msg.Id == "" {
        return nil, connect.NewError(
            connect.CodeInvalidArgument,
            errors.New("user ID is required"),
        )
    }

    user, ok := s.users[req.Msg.Id]
    if !ok {
        // Not found error
        return nil, connect.NewError(
            connect.CodeNotFound,
            fmt.Errorf("user %s not found", req.Msg.Id),
        )
    }

    // Success
    return connect.NewResponse(&userv1.GetUserResponse{
        User: user,
    }), nil
}

// Client error handling
resp, err := client.GetUser(ctx, connect.NewRequest(&userv1.GetUserRequest{Id: "invalid"}))
if err != nil {
    if connectErr := new(connect.Error); errors.As(err, &connectErr) {
        switch connectErr.Code() {
        case connect.CodeNotFound:
            fmt.Println("User not found")
        case connect.CodeInvalidArgument:
            fmt.Println("Invalid request")
        default:
            fmt.Printf("Error: %v\n", connectErr.Message())
        }
    }
}

When to Choose ConnectRPC

Choose ConnectRPC when:

  • You need browser compatibility without proxies
  • You want simpler debugging and testing
  • Your infrastructure doesn’t fully support HTTP/2
  • You value developer experience
  • You want to support both REST-like and RPC patterns
  • You need backward compatibility with existing HTTP infrastructure

Stick with gRPC when:

  • You have deep gRPC integration in your ecosystem
  • You need maximum performance at scale (though the difference is minimal)
  • Your team is already invested in gRPC tooling
  • You only do server-to-server communication

Migration Path

ConnectRPC makes migration easy because it can speak gRPC protocol:

// Your existing gRPC clients can talk to ConnectRPC servers!
// Just change the server implementation, keep the clients

// Server (ConnectRPC)
path, handler := userv1connect.NewUserServiceHandler(server)
mux.Handle(path, handler)

// Client (still using gRPC)
conn, _ := grpc.Dial("localhost:8080", grpc.WithInsecure())
client := userv1.NewUserServiceClient(conn)
// This works! ConnectRPC server understands gRPC protocol

Conclusion

ConnectRPC represents the evolution of RPC, combining the best of gRPC’s performance with the simplicity and ubiquity of HTTP/1.1 and REST. For new projects, especially those requiring browser compatibility or simpler operations, ConnectRPC is increasingly the smarter choice.

Key Takeaways:

  • ✅ Same performance as gRPC, better compatibility
  • ✅ Works natively in browsers without special proxies
  • ✅ Easier debugging with standard HTTP tools
  • ✅ Simpler deployment with any HTTP infrastructure
  • ✅ Can interoperate with existing gRPC services
  • ✅ Smaller, cleaner generated code

The Go ecosystem is rapidly adopting ConnectRPC, and for good reason—it delivers on gRPC’s promises while eliminating its rough edges.

Additional Resources