Skip to content
gohacks.dev
APIs

Connect RPC in Go as a Friendlier Alternative to gRPC

Build a Connect RPC service in Go and call it with curl, the Connect protocol, gRPC and gRPC-Web from the same handler, with typed errors and interceptors.

13 min read
Summarize in AI
Flat illustration of a gateway connected to a ring of services.

gRPC solved a real problem: typed, fast, schema-driven calls between services. It also arrived with its own server, its own HTTP/2 requirements and a story for browsers that involves a proxy. You cannot call a gRPC endpoint with curl without extra tools, you cannot put it behind every load balancer, and the ordinary Go HTTP stack you have spent years learning (middleware, httptest, http.Server timeouts) does not apply to it.

Tested with Go 1.25.6, connectrpc.com/connect v1.18.1, golang.org/x/net v0.43.0, protobuf-go v1.36.8, macOS on Apple Silicon (arm64)

Connect is Buf's answer to those complaints. It is a family of libraries, with connectrpc.com/connect as the Go one, that implement a protobuf RPC protocol over plain HTTP, and speak gRPC and gRPC-Web on the same handler. You get schema-driven services without giving up net/http. This article builds a service, calls it four different ways, and explains what you gain and give up. The Connect versus gRPC comparison goes through the decision for an existing system, and the full gRPC treatment starts with gRPC from protobuf to running services. It ran on Go 1.25.6.

What Connect is

Three pieces:

  1. A protocol. A unary RPC is an HTTP POST to /<package>.<Service>/<Method>, with the request and response body encoded as protobuf (application/proto) or JSON (application/json). Errors are an HTTP status plus a JSON body with a code and a message. Streaming uses a framed envelope over HTTP.
  2. A Go library, connectrpc.com/connect, which gives you handlers and clients that work with that protocol.
  3. Generated code, from protoc-gen-connect-go, which produces typed service interfaces and client constructors from your .proto files.

The point that makes people switch: the same handler also understands the gRPC and gRPC-Web protocols. Clients choose how to speak to it. So you can adopt Connect on the server and keep your existing gRPC clients unchanged, or let a browser call the same service directly with no Envoy proxy in between.

The workflow in a real project

A normal Connect project looks like this. I am describing the toolchain, not running it here, because the lab below avoids code generation to keep the output reproducible from a blank directory.

You write a schema, greet/v1/greet.proto:

Text
syntax = "proto3";
package greet.v1;
option go_package = "example.test/gen/greet/v1;greetv1";

message GreetRequest { string name = 1; }
message GreetResponse { string greeting = 1; }

service GreetService {
  rpc Greet(GreetRequest) returns (GreetResponse) {}
}

You configure generation with two plugins in buf.gen.yaml, one for the messages and one for the Connect service code:

YAML
version: v2
plugins:
  - local: protoc-gen-go
    out: gen
    opt: paths=source_relative
  - local: protoc-gen-connect-go
    out: gen
    opt: paths=source_relative

buf generate then writes greet.pb.go and a greetv1connect package containing a GreetServiceHandler interface, NewGreetServiceHandler(svc) which returns a path and an http.Handler, and NewGreetServiceClient(httpClient, baseURL). You implement the interface with ordinary methods that take a *connect.Request[GreetRequest] and return a *connect.Response[GreetResponse]. The envelope types exist so headers and trailers have somewhere to live.

The setup of the generator tools themselves is covered in code generation for gRPC.

A runnable service

The lab below uses wrapperspb.StringValue, a message that ships inside the protobuf library, as both request and response. That removes the generation step but keeps everything else real: the handler constructor, the client constructor, the protocols and the error model are exactly what generated code calls. The handler is the whole service:

main.goGo
package main

import (
	"context"
	"crypto/tls"
	"errors"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/http/httptest"
	"strings"

	"connectrpc.com/connect"
	"golang.org/x/net/http2"
	"golang.org/x/net/http2/h2c"
	"google.golang.org/protobuf/types/known/wrapperspb"
)

const procedure = "/greet.v1.GreetService/Greet"

// greet is the whole service. Real code would have generated request and response types from a .proto file;
// StringValue is a message that ships with protobuf, so this lab needs no code generation.
func greet(ctx context.Context, req *connect.Request[wrapperspb.StringValue]) (*connect.Response[wrapperspb.StringValue], error) {
	name := strings.TrimSpace(req.Msg.GetValue())
	if name == "" {
		return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("name is required"))
	}
	if name == "root" {
		return nil, connect.NewError(connect.CodePermissionDenied, errors.New("no greeting for root"))
	}
	res := connect.NewResponse(wrapperspb.String("Hello, " + name))
	res.Header().Set("Greet-Version", "v1")
	return res, nil
}

// h2cClient speaks HTTP/2 without TLS, which is how gRPC clients reach a plain-text test server.
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)
		},
	}}
}

func main() {
	mux := http.NewServeMux()
	mux.Handle(procedure, connect.NewUnaryHandler(procedure, greet))
	// h2c lets one plain-HTTP port speak HTTP/1.1 and HTTP/2, which gRPC clients need.
	srv := httptest.NewUnstartedServer(h2c.NewHandler(mux, &http2.Server{}))
	srv.Start()
	defer srv.Close()

	// 1. Any HTTP client can call a Connect unary RPC: POST, JSON body, ordinary status codes.
	post := func(label, body string) {
		resp, err := http.Post(srv.URL+procedure, "application/json", strings.NewReader(body))
		if err != nil {
			panic(err)
		}
		b, _ := io.ReadAll(resp.Body)
		resp.Body.Close()
		fmt.Printf("curl-style %-12s %d %s\n", label, resp.StatusCode, strings.TrimSpace(string(b)))
	}
	post("ok", `"Ada"`)
	post("empty", `""`)
	post("denied", `"root"`)

	// 2. The generated-style client, speaking the Connect protocol, the gRPC protocol and gRPC-Web over the same handler.
	ctx := context.Background()
	for _, c := range []struct {
		name string
		opts []connect.ClientOption
		http *http.Client
	}{
		{"connect+proto", nil, srv.Client()},
		{"connect+json", []connect.ClientOption{connect.WithProtoJSON()}, srv.Client()},
		{"grpc", []connect.ClientOption{connect.WithGRPC()}, h2cClient()},
		{"grpc-web", []connect.ClientOption{connect.WithGRPCWeb()}, srv.Client()},
	} {
		client := connect.NewClient[wrapperspb.StringValue, wrapperspb.StringValue](c.http, srv.URL+procedure, c.opts...)
		res, err := client.CallUnary(ctx, connect.NewRequest(wrapperspb.String("Grace")))
		if err != nil {
			fmt.Printf("%-14s error: %v\n", c.name, err)
			continue
		}
		fmt.Printf("%-14s %q  header Greet-Version=%s\n", c.name, res.Msg.GetValue(), res.Header().Get("Greet-Version"))
	}

	// 3. Errors carry a code that the client can switch on, whichever protocol was used.
	client := connect.NewClient[wrapperspb.StringValue, wrapperspb.StringValue](srv.Client(), srv.URL+procedure)
	_, err := client.CallUnary(ctx, connect.NewRequest(wrapperspb.String("root")))
	var cerr *connect.Error
	if errors.As(err, &cerr) {
		fmt.Printf("client sees code=%s message=%q\n", cerr.Code(), cerr.Message())
	}
}
Text
curl-style ok           200 "Hello, Ada"
curl-style empty        400 {"code":"invalid_argument","message":"name is required"}
curl-style denied       403 {"code":"permission_denied","message":"no greeting for root"}
connect+proto  "Hello, Grace"  header Greet-Version=v1
connect+json   "Hello, Grace"  header Greet-Version=v1
grpc           "Hello, Grace"  header Greet-Version=v1
grpc-web       "Hello, Grace"  header Greet-Version=v1
client sees code=permission_denied message="no greeting for root"

connect.NewUnaryHandler returns an http.Handler. It goes on a normal ServeMux. The mux is wrapped in h2c.NewHandler, which lets one plain-text port serve HTTP/1.1 and HTTP/2 without TLS. In production you terminate TLS and HTTP/2 is negotiated through ALPN, so you would not need h2c, but a local test server needs it for gRPC clients.

Reading the results

Curl-style calls. The first three lines are what curl -H 'Content-Type: application/json' -d '"Ada"' URL would print. A good request returns 200 with a JSON body. An empty name returns HTTP 400 with {"code":"invalid_argument",...}. A forbidden name returns 403 with permission_denied. Connect maps each of its error codes to an HTTP status, so proxies, dashboards and browsers see a meaningful status code and not a universal 200.

This is the largest practical difference from gRPC for people who run services. The status is on the wire, in a form every tool understands.

Four protocols, one handler. The next four lines are the same client constructor with different options: default Connect with protobuf bodies, WithProtoJSON, WithGRPC, and WithGRPCWeb. Each returned Hello, Grace and the custom response header Greet-Version. The server did not change between them.

The gRPC client needs HTTP/2, so the lab builds an http2.Transport with AllowHTTP and a dialer that skips TLS. Against a production server with TLS you use a normal http.Client.

Typed errors. The last line shows the client side of errors: errors.As into *connect.Error, then read Code() and Message(). The code is a stable enum shared with gRPC (InvalidArgument, NotFound, DeadlineExceeded...), so client logic can switch on it. The gRPC error codes article lists them and what each should mean.

Interceptors and middleware

Connect has two layers where you can add behaviour, and knowing which to use saves confusion.

HTTP middleware wraps the whole http.Handler. Use it for what is truly about HTTP: request IDs, CORS, access logs, authentication by header, panic recovery at the edge. Every Go developer already knows this layer.

Interceptors are Connect's own hooks, passed with connect.WithInterceptors(...). They see the decoded request or response and the procedure name, for unary and streaming calls alike. Use them for RPC-aware concerns: log the procedure and the Connect code, record metrics per method, validate messages, enforce per-procedure authorisation. A unary interceptor is a function from UnaryFunc to UnaryFunc, and the same interceptor can be attached to clients and handlers.

A reasonable split is request IDs and compression and auth token parsing in HTTP middleware, then logging, metrics and tracing as interceptors, because they need the procedure name and the error code. gRPC interceptors explains the equivalent pattern on the gRPC server.

Streaming

Connect supports server streaming, client streaming and bidirectional streaming. Handlers receive a stream object with Receive and Send. Server streaming works over HTTP/1.1 and HTTP/2. Client and bidirectional streaming need HTTP/2 end to end, so make sure every proxy between the caller and the service supports it. This is where Connect inherits the same infrastructure constraint as gRPC; the unary calls are the part that is friendlier. Streaming is covered in server streaming and bidirectional streaming.

Testing

Because a handler is an http.Handler, you test it with httptest.NewServer, exactly as the lab does, and you test clients against it without a special in-memory transport. Use a table of requests and expected codes, and check headers and trailers where they matter. For unary calls you can also invoke the handler method directly with connect.NewRequest(...), bypassing HTTP, when you only want to test the logic. See testing gRPC services for the structure of such tests.

Deadlines, headers and cancellation

A Connect client turns a Go context deadline into a Connect-Timeout-Ms header, and the handler's ctx is cancelled when that time runs out or when the client disconnects. In the handler you do what you would do in any Go service: pass ctx to database calls and stop work when it is done. A call that exceeds its deadline surfaces on the client as deadline_exceeded. With the gRPC protocol the same deadline travels as grpc-timeout, so the handler code does not change. The pitfalls are covered in propagating deadlines and in the general article on context timeouts.

Request and response metadata are HTTP headers. req.Header() reads what the client sent and res.Header() sets what goes back, which is how Greet-Version travelled in the lab. Trailers are set with res.Trailer() on a unary response. Keep binary values out of headers, or give them a -Bin suffix and base64 them as gRPC does; that is part of metadata handling.

Calling it from a browser

This is the case that sold Connect to many teams. A browser cannot speak native gRPC, because it cannot control HTTP/2 framing or read trailers. The Connect protocol was designed to work from fetch. The TypeScript client (@connectrpc/connect-web) generates a typed client from the same .proto file, and a unary call is an ordinary POST with a JSON or binary body.

Two things need your attention on the server:

  • CORS. The browser sends a preflight request for the custom headers Connect uses, such as Connect-Protocol-Version. The Connect project publishes a small helper package, connectrpc.com/cors, which returns the exact lists of allowed methods, headers and exposed headers that the protocol needs. Use it with whichever CORS middleware you already run, and restrict the origins to your own front end.
  • Credentials. Browsers send cookies only when asked. If your auth is cookie based, the web client needs credentials: "include" and the server needs a specific origin, not a wildcard, plus CSRF protection for state-changing calls. Bearer tokens in an Authorization header avoid that class of problem, with the usual care about where the token is stored.

A browser can also call an existing gRPC-Web endpoint. The same handler that serves Go clients answers WithGRPCWeb clients, as the fourth line of the lab output shows, so an Envoy translating proxy is no longer part of the picture.

Moving an existing gRPC service

Because the handler speaks gRPC too, migration can be gradual.

  1. Add the generator. Run protoc-gen-connect-go alongside the existing generator, writing to a sibling package. Your .pb.go messages stay as they are.
  2. Adapt the service. The generated Connect interface takes *connect.Request[T] and returns *connect.Response[U]. Wrap your existing implementation, calling it with req.Msg and wrapping the result with connect.NewResponse. Keep the old gRPC server running until the new one is verified.
  3. Mount the handler on the mux of an existing http.Server, with TLS and HTTP/2 enabled. Existing gRPC clients keep working, because the handler recognises their content type.
  4. Translate errors. gRPC status.Error(codes.NotFound, ...) becomes connect.NewError(connect.CodeNotFound, ...). The codes are the same set, so this is mechanical. If you use rich error details, connect.NewErrorDetail carries protobuf messages in the same way.
  5. Move interceptors. Logging, metrics and auth interceptors port over with a small signature change.
  6. Retire the old server once traffic shows no direct users.

Do this one service at a time, and keep a contract test that calls each method over both protocols until you are sure they agree. If you find that you want browser clients and curl but not gRPC, the final state can drop the gRPC listener entirely.

Security and operations

A Connect service is an HTTP server, so everything in the net/http server hardening checklist applies: set ReadHeaderTimeout, IdleTimeout and a sensible MaxHeaderBytes. Set a read limit on messages with connect.WithReadMaxBytes, because the default accepts messages up to the limit of the underlying codec and a large unauthenticated body is cheap for an attacker to send. Compress responses only above a size threshold. Use TLS between services, and mutual TLS where the network is shared, as described in gRPC authentication; the Connect handler takes whatever tls.Config the server has.

Health checks and reflection exist as separate packages (connectrpc.com/grpchealth and connectrpc.com/grpcreflect) that register handlers on the mux, so tools such as grpcurl and Kubernetes probes work as before. See health checking and reflection.

For observability, connectrpc.com/otelconnect provides an interceptor that records OpenTelemetry traces and metrics per procedure, which is what gRPC observability with OpenTelemetry builds by hand for the native server.

What you give up

Connect is not strictly better than gRPC.

Ecosystem breadth. The official gRPC libraries have more built-in features for load balancing, name resolution, health checks and channel management. With Connect you use ordinary Go http.Client transports and net/http configuration, so client-side load balancing across many pods needs a service mesh, a proxy, or your own resolver logic. For the options, see gRPC load balancing.

Maturity in other languages. The Go, TypeScript, Swift and Kotlin libraries are solid. Others vary. Check your client languages before committing.

Some operational guarantees. gRPC's wire format and library are stricter about things like message size defaults and keepalive behaviour. With Connect, the http.Server and http.Transport settings you already own are the ones that apply, so review them: timeouts, idle connections and MaxBytesReader-style limits (Connect has WithReadMaxBytes for this).

When to use it

Pick Connect when you are starting a new service, when browsers should call it directly, when you want plain HTTP tooling and curl to work, or when your team's skills are in net/http more than in gRPC's runtime. Stay on gRPC when you have a large fleet of gRPC clients and infrastructure tuned for it, or when you rely on a feature that Connect leaves to the surrounding platform. The two coexist well: a Connect handler serves existing gRPC clients unchanged, so migrating is a server-side change.

The protocol is documented at connectrpc.com, and the Go API reference is on pkg.go.dev. Start with one unary method, call it from curl, and add streaming only when a use case asks for it.

Also inNetworking
Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.