When gRPC hits a wall
You are building a microservice. You want the structure of Protobufs, the speed of binary encoding, and the power of streaming. You also want to debug with curl, pass traffic through a legacy CDN, and let a browser talk to your backend without a gRPC-Web shim. You've tried gRPC. The setup is heavy, the wire format is opaque, and your load balancer drops connections because it doesn't understand gRPC metadata.
Connect-go solves this by treating RPC as HTTP. It uses standard HTTP/2, supports JSON or Protobuf payloads, and drops into net/http like a native citizen. You get code generation, streaming, and error handling, but the transport is plain HTTP. Proxies, browsers, and monitoring tools understand it immediately.
Connect is HTTP with superpowers
gRPC defines its own wire format and metadata rules. It returns HTTP 200 OK for every successful call and hides the actual status in response trailers. This works perfectly in a controlled environment. It breaks when infrastructure expects standard HTTP semantics.
Connect uses standard HTTP methods, headers, and status codes. A 404 means not found. A 500 means server error. The payload is wrapped in a content-type that tells the other side how to decode the bytes. You can switch between Protobuf for performance and JSON for debugging without changing your code. The protocol is just HTTP/2 streaming with a smart envelope.
The framework generates Go code from your .proto files, just like gRPC. The generated code defines interfaces, request/response types, and handlers. You implement the interface, register the handler with a mux, and Connect handles the rest. The API is designed around Go's standard library. You use http.Client for the client and http.ServeMux for the server.
Minimal setup and first call
Start by installing the protocol compiler plugin and generating code. Connect uses protoc with a custom plugin. The generated code lives alongside your standard Protobuf output.
# Install the Connect plugin for protoc
go install github.com/bufbuild/connect-go/cmd/protoc-gen-connect-go@latest
# Generate Go code from your proto definition
# The connect-go plugin produces interfaces and handler helpers
protoc --go_out=. --go_opt=paths=source_relative \
--connect-go_out=. --connect-go_opt=paths=source_relative \
path/to/service.proto
The server implementation mirrors standard Go HTTP handlers. You define a struct that satisfies the generated interface and mount it on a mux.
package main
import (
"context"
"log"
"net/http"
connect "github.com/bufbuild/connect-go"
"github.com/example/gen" // Generated package
)
// GreeterService implements the generated GreeterHandler interface.
type GreeterService struct {
// Embed the unimplemented handler to satisfy the interface
// without implementing every method manually.
connect.UnimplementedGreeterHandler
}
// SayHello handles the unary RPC request.
// The context carries cancellation and deadlines from the client.
func (s *GreeterService) SayHello(
ctx context.Context,
req *connect.Request[gen.HelloRequest],
) (*connect.Response[gen.HelloResponse], error) {
// Access the Protobuf message via the Msg field.
name := req.Msg.Name
// Return the response wrapped in the Connect type.
return connect.NewResponse(&gen.HelloResponse{
Message: "Hello, " + name,
}), nil
}
func main() {
mux := http.NewServeMux()
// Mount the Connect handler on the standard mux.
// Connect registers the route and handles HTTP/2 framing.
mux.Handle(gen.NewGreeterHandler(&GreeterService{}))
log.Println("Listening on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
The client uses http.Client under the hood. You instantiate the client with a base URL and make calls using the generated methods.
// Create a client using the default HTTP client.
// Connect handles retries, timeouts, and error mapping automatically.
client := gen.NewGreeterClient(
http.DefaultClient,
"http://localhost:8080",
)
// Build the request and call the RPC.
resp, err := client.SayHello(
context.Background(),
connect.NewRequest(&gen.HelloRequest{Name: "Alice"}),
)
if err != nil {
log.Fatalf("RPC failed: %v", err)
}
// Access the response message via the Msg field.
log.Println(resp.Msg.Message)
How the pieces fit together
The protoc command generates Go code that defines interfaces for your service. The generated code includes an UnimplementedGreeterHandler struct. Embed this struct in your service type to satisfy the interface without implementing every method manually. If you skip a method, the framework returns an "unimplemented" error automatically. This pattern prevents breaking changes when you add new RPCs to your proto file.
The handler signature takes a context.Context and a *connect.Request[T]. The request wraps your Protobuf message in a .Msg field and carries metadata. You can read custom headers via req.Header(). You return a *connect.Response[T] or an error. Connect maps Go errors to HTTP status codes and error details automatically. If you return a plain error, Connect treats it as an internal error and returns HTTP 500.
The mux handles the HTTP/2 framing, content-type negotiation, and streaming setup. You write business logic; Connect handles the transport. The generated NewGreeterHandler function returns an http.Handler that you can mount anywhere in your Go HTTP stack. You can add middleware, CORS handlers, or authentication layers using standard Go patterns.
Connect respects context cancellation. If the client drops the connection, the context cancels, and your handler should exit. Always check ctx.Err() in long-running operations. The framework propagates cancellation from the HTTP layer to your handler seamlessly.
Streaming and structured errors
Connect supports unary calls, server streaming, client streaming, and bidirectional streaming. Streaming works by returning a stream object instead of a single response. The server sends messages over the stream until the context is cancelled or the function returns.
// CountUp streams integers from 1 to N.
// The stream parameter allows sending multiple responses.
func (s *GreeterService) CountUp(
ctx context.Context,
req *connect.Request[gen.CountRequest],
stream *connect.ServerStream[gen.CountResponse],
) error {
// Loop until the context is cancelled or we hit the limit.
for i := 1; i <= int(req.Msg.Limit); i++ {
// Check context before each send to respect cancellation.
if err := ctx.Err(); err != nil {
return err
}
// Send the response over the stream.
// Returns an error if the client disconnects.
if err := stream.Send(&gen.CountResponse{Value: int64(i)}); err != nil {
return err
}
}
return nil
}
Error handling goes beyond simple messages. Connect supports error details, similar to gRPC status codes. You can attach structured information to errors, such as field violations or retry hints. The client can inspect these details using the connect error interface.
import "google.golang.org/genproto/googleapis/rpc/errdetails"
// ValidateName returns a structured error if the name is empty.
// Structured errors help clients display precise validation messages.
func (s *GreeterService) SayHello(
ctx context.Context,
req *connect.Request[gen.HelloRequest],
) (*connect.Response[gen.HelloResponse], error) {
if req.Msg.Name == "" {
// Build a structured error with field violations.
// This maps to HTTP 400 Bad Request.
return nil, connect.NewError(
connect.CodeInvalidArgument,
errors.New("name is required"),
&errdetails.BadRequest{
FieldViolations: []*errdetails.BadRequest_FieldViolation{
{Field: "name", Description: "must not be empty"},
},
},
)
}
return connect.NewResponse(&gen.HelloResponse{
Message: "Hello, " + req.Msg.Name,
}), nil
}
On the client side, you can check for structured errors using errors.As. This lets you handle validation errors differently from server failures.
resp, err := client.SayHello(ctx, connect.NewRequest(&gen.HelloRequest{}))
if err != nil {
// Check if the error contains structured details.
var connErr *connect.Error
if errors.As(err, &connErr) {
log.Printf("RPC error code: %s", connErr.Code())
// You can also extract error details here.
}
log.Fatalf("Call failed: %v", err)
}
Interceptors for cross-cutting concerns
Connect provides a clean interceptor chain for logging, authentication, and metrics. Interceptors wrap handlers without cluttering your business logic. You define an interceptor function that takes the next handler and returns a new handler.
// LoggingInterceptor wraps the handler to log requests and responses.
// Interceptors run before and after the handler logic.
func LoggingInterceptor(next connect.Handler) connect.Handler {
return connect.UnaryHandlerFunc(func(
ctx context.Context,
req connect.AnyRequest,
) (connect.AnyResponse, error) {
// Log before calling the next handler.
log.Printf("Request: %s %s", req.Spec().Method, req.Spec().Procedure)
// Call the next handler in the chain.
resp, err := next.Handle(ctx, req)
// Log after the response is ready.
log.Printf("Response: %v", err)
return resp, err
})
}
You pass interceptors to the handler constructor. They apply to all RPCs in the service. You can chain multiple interceptors for different concerns. The interceptor API supports both unary and streaming RPCs. Use connect.UnaryInterceptor for simple calls and connect.StreamInterceptor for streaming.
// Mount the handler with the logging interceptor.
mux.Handle(gen.NewGreeterHandler(&GreeterService{}, LoggingInterceptor))
Connect is HTTP with superpowers. Treat it like HTTP, and it works everywhere.
Pitfalls and compiler errors
If you forget to embed the unimplemented handler, the compiler rejects the code with GreeterService does not implement connect.GreeterHandler (missing method X). You must embed the generated unimplemented struct to satisfy the interface. This ensures your service implements all required methods.
If you return a raw error instead of wrapping it with connect.NewError, the framework maps it to a generic internal error. You lose the ability to send specific status codes or error details. The client sees a 500 response with no structured information. Always use connect.NewError when you need to control the error code or attach details.
If you try to use Connect with HTTP/1.1, streaming fails. Connect requires HTTP/2 for streaming support. The server will panic or return an error if you attempt to stream over HTTP/1.1. Ensure your transport supports HTTP/2. Use http.Server with TLSConfig or a proxy that upgrades to HTTP/2.
If you pass a nil message, the runtime panics. Always validate inputs before processing. The generated code does not check for nil messages. You are responsible for handling edge cases in your handler logic.
If you modify the request message in place, you may cause race conditions. The request message is shared between the framework and your handler. Clone the message if you need to modify it or store it for later use.
Embed the unimplemented handler. Save yourself the missing method errors.
When to use Connect
Use Connect-go when you need Protobuf RPC with standard HTTP compatibility. Use Connect-go when your infrastructure blocks gRPC metadata or requires JSON fallback. Use Connect-go when you want to share .proto definitions across Go, TypeScript, and other languages without gRPC-Web shims. Use gRPC-Go when you need maximum performance and full control over the gRPC wire format. Use plain net/http with JSON when your API is simple and you don't need code generation or streaming. Use Connect-go when you want a clean interceptor API for logging, auth, and metrics.
HTTP/2 is the floor. Streaming breaks without it.