Connect RPC vs gRPC in Go and Which One to Pick First
Compare Connect RPC and gRPC in Go with measured unary call cost, protocol differences and a decision guide for browsers, curl, middleware and fleets.

Teams starting a new service with protobuf face a choice that did not exist a few years ago. gRPC is the established framework, with a large ecosystem and years of production use. Connect is a newer family of libraries from Buf that implements the same schema-driven RPC model on plain net/http, and whose handlers also speak gRPC. Both use .proto files and generate Go code. They differ in what they ask of your infrastructure, what you can do with them from a browser or curl, and how they behave under load.
Tested with Go 1.25.6, connect-go v1.18.1, grpc-go v1.75.0, golang.org/x/net v0.43.0, Apple M1 Max
This comparison is for Go developers deciding which to start with. It includes a measurement, with the caveats that go with a microbenchmark, and a decision guide. The mechanics of Connect are in Connect RPC in Go, and gRPC starts at the first gRPC service. The benchmark ran on Go 1.25.6 on an Apple M1 Max.
What is the same
The schema and the programming model. Both generate typed clients and servers from .proto files, support unary and the three streaming shapes, use status codes (the same 17), carry metadata as headers, propagate deadlines and cancellation through contexts, and use protobuf for the wire format by default. If you have read the earlier articles on deadlines, error codes and interceptors, nearly all of it carries over.
What is different
The protocol and the server. grpc-go has its own HTTP/2 transport and its own server type. A gRPC call needs HTTP/2 and trailers end to end. Connect defines a protocol that works over HTTP/1.1 or HTTP/2 and uses ordinary status codes, and implements it as http.Handler. One Connect handler also understands the gRPC and gRPC-Web protocols, so clients choose how to talk to it.
What you can call from where. A Connect unary call is a plain POST with a JSON or protobuf body, so curl, a browser fetch and the Connect web client all work with no proxy. A gRPC server needs reflection and a tool like grpcurl for ad hoc calls, and browsers need gRPC-Web with a translating proxy in front.
Middleware. Connect handlers are http.Handlers, so every piece of HTTP middleware you own (request ids, CORS, logging, authentication, compression) works unchanged, and httptest tests them. grpc-go servers have interceptors and stats handlers instead. The two worlds are parallel, not compatible.
Ecosystem. grpc-go is the reference implementation for Go, with the longest list of integrations: xDS-based service discovery and load balancing, client-side balancing policies including weighted round robin, health checking in the client, channelz for diagnostics, many third-party interceptor libraries and exporters. Connect relies on the standard library for transport, so many of those pieces are provided by your platform (a mesh or a proxy) or not at all.
The measurement
The benchmark sends a 256 byte string echo as a unary call, in parallel from all CPUs, on loopback with no TLS. It uses protobuf's StringValue as the message so there is no code generation. Four setups: a grpc-go server and client; a Connect server with a gRPC-protocol client; a Connect server with the Connect protocol over HTTP/2 (h2c); and the Connect protocol over HTTP/1.1.
package main
func main() {}package main
import (
"context"
"crypto/tls"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"connectrpc.com/connect"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/types/known/wrapperspb"
)
const method = "/bench.v1.Echo/Echo"
// A grpc-go service registered by hand, so the lab needs no code generation. The message type is a protobuf well-known type.
var echoDesc = grpc.ServiceDesc{
ServiceName: "bench.v1.Echo",
HandlerType: (*any)(nil),
Methods: []grpc.MethodDesc{{
MethodName: "Echo",
Handler: func(_ any, ctx context.Context, dec func(any) error, _ grpc.UnaryServerInterceptor) (any, error) {
in := new(wrapperspb.StringValue)
if err := dec(in); err != nil {
return nil, err
}
return in, nil
},
}},
}
func startGRPC(b *testing.B) *grpc.ClientConn {
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
b.Fatal(err)
}
srv := grpc.NewServer()
srv.RegisterService(&echoDesc, struct{}{})
go srv.Serve(lis)
b.Cleanup(srv.Stop)
conn, err := grpc.NewClient(lis.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
b.Fatal(err)
}
b.Cleanup(func() { conn.Close() })
return conn
}
func startConnect(b *testing.B) *httptest.Server {
mux := http.NewServeMux()
mux.Handle(method, connect.NewUnaryHandler(method, func(_ context.Context, r *connect.Request[wrapperspb.StringValue]) (*connect.Response[wrapperspb.StringValue], error) {
return connect.NewResponse(r.Msg), nil
}))
srv := httptest.NewUnstartedServer(h2c.NewHandler(mux, &http2.Server{}))
srv.Start()
b.Cleanup(srv.Close)
return srv
}
func h2cClient() *http.Client {
return &http.Client{Transport: &http2.Transport{AllowHTTP: true,
DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, network, addr)
}}}
}
var payload = wrapperspb.String(strings.Repeat("x", 256))
func BenchmarkGRPCServerGRPCClient(b *testing.B) {
conn := startGRPC(b)
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
out := new(wrapperspb.StringValue)
if err := conn.Invoke(context.Background(), method, payload, out); err != nil {
b.Fatal(err)
}
}
})
}
func BenchmarkConnectServerGRPCClient(b *testing.B) {
srv := startConnect(b)
c := connect.NewClient[wrapperspb.StringValue, wrapperspb.StringValue](h2cClient(), srv.URL+method, connect.WithGRPC())
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
if _, err := c.CallUnary(context.Background(), connect.NewRequest(payload)); err != nil {
b.Fatal(err)
}
}
})
}
func BenchmarkConnectServerConnectClientHTTP2(b *testing.B) {
srv := startConnect(b)
c := connect.NewClient[wrapperspb.StringValue, wrapperspb.StringValue](h2cClient(), srv.URL+method)
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
if _, err := c.CallUnary(context.Background(), connect.NewRequest(payload)); err != nil {
b.Fatal(err)
}
}
})
}
func BenchmarkConnectServerConnectClientHTTP1(b *testing.B) {
srv := startConnect(b)
hc := &http.Client{Transport: &http.Transport{MaxIdleConnsPerHost: 64}}
c := connect.NewClient[wrapperspb.StringValue, wrapperspb.StringValue](hc, srv.URL+method)
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
if _, err := c.CallUnary(context.Background(), connect.NewRequest(payload)); err != nil {
b.Fatal(err)
}
}
})
}go test -run='^$' -bench=. -benchmem -count=3 . | grep -E '^(cpu|Benchmark)'cpu: Apple M1 Max
BenchmarkGRPCServerGRPCClient-10 62365 18621 ns/op 10275 B/op 155 allocs/op
BenchmarkGRPCServerGRPCClient-10 63002 18674 ns/op 10283 B/op 155 allocs/op
BenchmarkGRPCServerGRPCClient-10 66247 19199 ns/op 10280 B/op 155 allocs/op
BenchmarkConnectServerGRPCClient-10 21888 55395 ns/op 41623 B/op 182 allocs/op
BenchmarkConnectServerGRPCClient-10 21861 56166 ns/op 36073 B/op 181 allocs/op
BenchmarkConnectServerGRPCClient-10 21027 56852 ns/op 40117 B/op 181 allocs/op
BenchmarkConnectServerConnectClientHTTP2-10 27020 45188 ns/op 32474 B/op 140 allocs/op
BenchmarkConnectServerConnectClientHTTP2-10 27168 45119 ns/op 30865 B/op 140 allocs/op
BenchmarkConnectServerConnectClientHTTP2-10 25867 47113 ns/op 32490 B/op 140 allocs/op
BenchmarkConnectServerConnectClientHTTP1-10 51280 22274 ns/op 25264 B/op 134 allocs/op
BenchmarkConnectServerConnectClientHTTP1-10 55514 22322 ns/op 24498 B/op 134 allocs/op
BenchmarkConnectServerConnectClientHTTP1-10 55291 21902 ns/op 24187 B/op 134 allocs/op| Setup | Time per call | Allocations per call |
|---|---|---|
| grpc-go client and server | about 19 us | 155 |
| Connect server, gRPC protocol client | about 56 us | 181 |
| Connect server, Connect protocol, HTTP/2 | about 45 us | 140 |
| Connect server, Connect protocol, HTTP/1.1 | about 22 us | 134 |
(Three runs of each agreed within about 5 percent.)
What the numbers mean
Do not read the table as "gRPC is twice as fast as Connect". Read the last row. The same Connect handler, using the same Connect protocol over HTTP/1.1 with many parallel connections, costs about as much per call as the grpc-go pair. What differs between rows is the HTTP/2 implementation in the path. The h2c setup in this lab uses golang.org/x/net/http2, for both the server wrapper and the client transport, and that library is slower for this tiny-message, high-concurrency workload than grpc-go's purpose-built transport, which multiplexes many calls over few connections with less per-call work.
So the honest summary of the measurement is: for the cheapest possible calls on a loopback, grpc-go's transport is faster than x/net/http2, by a factor of about 2 here. Three caveats before you act on it.
- Loopback, no TLS, tiny messages. A real call crosses a network (hundreds of microseconds) and often TLS. In production, Go's
net/httpserver negotiates HTTP/2 through TLS with its own bundled implementation, notx/net's h2c, so the Connect numbers over real TLS may differ from the h2c rows here. I did not measure that. - Handlers do work. The echo does nothing. If a handler spends 300 microseconds on a database query, a 25 microsecond difference per call is under 10 percent of the total, and the wire format is a rounding error beside the query.
- Parallelism shapes the result. Different concurrency levels, message sizes and streaming workloads move the numbers. Benchmark your own workload before treating any of this as a reason to choose (benchmarking a handler shows the method).
If your service is an internal hot path doing hundreds of thousands of tiny calls per second per core, the transport matters and grpc-go has the advantage today. For almost everything else, it does not decide the question.
A decision guide
Choose Connect first when:
- Browsers or mobile web apps call the service directly, with no Envoy in between.
- People need to call endpoints with
curland read the responses. - Your team's experience, middleware and observability are built around
net/http. - You are starting fresh, or services in several languages need one handler that speaks multiple protocols.
- You want standard HTTP status codes visible to proxies and dashboards.
Choose grpc-go first when:
- You have a fleet of gRPC clients and infrastructure tuned for gRPC and do not want to change the server stack.
- You need the ecosystem: xDS, client-side load balancing policies, client health checking, channelz, a particular interceptor library.
- The measurement above matters for your call rate and message size, and you have confirmed it on your own workload and network.
- Your organisation's platform team supports gRPC and not arbitrary HTTP handlers.
It is not a one-way door. A Connect handler serves gRPC clients, so you can adopt Connect on the server and keep gRPC clients unchanged. A grpc-go server cannot serve Connect-protocol clients, so the reverse move is a bigger change. If you are unsure, starting with Connect keeps more options open.
Things people assume that are not true
"Connect is not real gRPC." A Connect handler implements the gRPC protocol on the wire and interoperates with standard gRPC clients in any language. It is gRPC-compatible, not gRPC-incompatible.
"gRPC cannot be used from a browser." It can, through gRPC-Web with a translating proxy, or through a Connect handler. The proxy is the cost.
"Protobuf is only for gRPC." The schema and the evolution rules are the same wherever you use them (protobuf for Go developers). Both frameworks speak JSON too: Connect natively, gRPC through grpc-gateway.
"Connect needs different .proto files." The schemas are standard. Only the generator plugin differs (protoc-gen-connect-go instead of protoc-gen-go-grpc), and both can run on the same files.
What to do
Write the .proto files, which are the durable asset, and keep them free of framework details. Generate for the framework you choose now. Put the business logic behind your own interface, so a handler is a thin adapter. Then the choice between Connect and gRPC concerns the adapter and the transport, and you can change your mind per service. The project documentation is at connectrpc.com and grpc.io.


