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.