Connect Go for Browser Friendly gRPC, A Production Tutorial
TL;DR — Connect-Go 1.18 speaks three wire formats (Connect, gRPC, gRPC-Web) from a single handler. You write protobuf, you get type-safe servers and clients, and your browser code can call them without a proxy. The migration from vanilla gRPC is mostly a re-import.
Most teams I’ve worked with hit the same wall around year two of a gRPC rollout: the backend is great, but the browser teams hate it. gRPC-Web sort of works, Envoy translation is one more moving part, and the developer experience for frontends is worse than plain JSON. So they end up writing a parallel REST gateway, which means every endpoint gets specified twice, drifts, and breaks.
Connect-Go is Buf’s answer to this. It’s a Go RPC framework that uses protobuf for schemas and codegen, but defines its own wire protocol that’s just HTTP/1.1 or HTTP/2 with content negotiation. The same handler can serve Connect, gRPC, and gRPC-Web. The browser client speaks the Connect protocol natively. There’s no proxy in the middle.
I shipped my first Connect service in 2023, and I’ve migrated three more since. This tutorial is the one I wish I’d had then. If you’re considering this stack alongside vanilla gRPC, my earlier post on gRPC patterns for high throughput covers the gRPC side; this one is about when and how to pick Connect.
1. The Connect Wire Protocol, in Two Paragraphs
A Connect unary call is an HTTP POST. The path is /<package>.<Service>/<Method>. The body is the request message, encoded as JSON or protobuf based on Content-Type. The response is the response message in the same encoding. Errors come back as HTTP status codes plus a JSON body with a Connect error code. That’s it for unary. You can curl it.
Streaming uses chunked transfer encoding with a small framing header per message. It’s not gRPC’s HTTP/2 frames, which means HTTP/1.1 streaming works fine, which means proxies that mangle HTTP/2 don’t break it. Bidirectional streaming requires HTTP/2 at the transport layer because half-duplex HTTP/1.1 can’t do it, but server-streaming over HTTP/1.1 is fine.
unary request:
POST /orders.v1.OrderService/GetOrder
Content-Type: application/proto
[protobuf bytes]
unary response:
HTTP/1.1 200 OK
Content-Type: application/proto
[protobuf bytes]
error response:
HTTP/1.1 404 Not Found
Content-Type: application/json
{"code":"not_found","message":"order 42 not found"}
2. Project Setup with buf
I covered buf-driven development in detail in schema first API development with buf. For Connect, the setup is the same plus one plugin.
# buf.gen.yaml
version: v2
plugins:
- remote: buf.build/protocolbuffers/go:v1.34.2
out: gen
opt: paths=source_relative
- remote: buf.build/connectrpc/go:v1.18.1
out: gen
opt: paths=source_relative
Then a minimal .proto:
syntax = "proto3";
package orders.v1;
option go_package = "example.com/orders/gen/orders/v1;ordersv1";
service OrderService {
rpc GetOrder(GetOrderRequest) returns (GetOrderResponse) {}
rpc CreateOrder(CreateOrderRequest) returns (CreateOrderResponse) {}
rpc StreamOrders(StreamOrdersRequest) returns (stream Order) {}
}
message Order {
string id = 1;
string customer_id = 2;
int64 amount_cents = 3;
string currency = 4;
}
message GetOrderRequest { string id = 1; }
message GetOrderResponse { Order order = 1; }
message CreateOrderRequest { Order order = 1; }
message CreateOrderResponse { string id = 1; }
message StreamOrdersRequest { string customer_id = 1; }
Run buf generate and you get a gen/orders/v1/ordersv1connect/order_service.connect.go file with interface and HTTP handler. That file is the only thing different from a vanilla gRPC project.
3. Server Skeleton
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"time"
"connectrpc.com/connect"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
ordersv1 "example.com/orders/gen/orders/v1"
"example.com/orders/gen/orders/v1/ordersv1connect"
)
type orderServer struct{}
func (s *orderServer) GetOrder(
ctx context.Context,
req *connect.Request[ordersv1.GetOrderRequest],
) (*connect.Response[ordersv1.GetOrderResponse], error) {
id := req.Msg.GetId()
if id == "" {
return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("id required"))
}
resp := connect.NewResponse(&ordersv1.GetOrderResponse{
Order: &ordersv1.Order{Id: id, AmountCents: 1999, Currency: "USD"},
})
resp.Header().Set("Cache-Control", "no-store")
return resp, nil
}
func (s *orderServer) CreateOrder(
ctx context.Context,
req *connect.Request[ordersv1.CreateOrderRequest],
) (*connect.Response[ordersv1.CreateOrderResponse], error) {
// validate, persist, etc.
return connect.NewResponse(&ordersv1.CreateOrderResponse{Id: "ord_" + req.Msg.Order.Id}), nil
}
func (s *orderServer) StreamOrders(
ctx context.Context,
req *connect.Request[ordersv1.StreamOrdersRequest],
stream *connect.ServerStream[ordersv1.Order],
) error {
tick := time.NewTicker(500 * time.Millisecond)
defer tick.Stop()
for i := 0; i < 10; i++ {
select {
case <-ctx.Done():
return ctx.Err()
case <-tick.C:
if err := stream.Send(&ordersv1.Order{Id: "stream", AmountCents: int64(i)}); err != nil {
return err
}
}
}
return nil
}
func main() {
mux := http.NewServeMux()
path, handler := ordersv1connect.NewOrderServiceHandler(&orderServer{})
mux.Handle(path, handler)
srv := &http.Server{
Addr: ":8080",
Handler: h2c.NewHandler(mux, &http2.Server{}),
ReadHeaderTimeout: 5 * time.Second,
}
slog.Info("listening", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil {
slog.Error("serve", "err", err)
}
}
h2c.NewHandler is what lets the same listener accept HTTP/1.1 from browsers and HTTP/2 from gRPC clients. In production behind a TLS-terminating proxy, you typically don’t need h2c, but it’s handy in dev and behind a service mesh that handles TLS.
4. The Three Wire Formats in One Listener
This is the part that sells Connect. The handler returned by NewOrderServiceHandler automatically negotiates protocol based on request headers:
Content-Type: application/grpc-web+proto-> gRPC-Web (browsers via the old protocol)Content-Type: application/grpc+proto-> regular gRPC (existing vanilla gRPC clients)Content-Type: application/protoorapplication/json-> Connect protocol
Curl it three ways and you’ll see the same response:
# Connect, JSON
curl -X POST http://localhost:8080/orders.v1.OrderService/GetOrder \
-H "Content-Type: application/json" \
-d '{"id":"42"}'
# Connect, protobuf
curl -X POST http://localhost:8080/orders.v1.OrderService/GetOrder \
-H "Content-Type: application/proto" \
--data-binary @request.bin
# gRPC, with grpcurl
grpcurl -plaintext -d '{"id":"42"}' localhost:8080 orders.v1.OrderService/GetOrder
+---------- Connect (HTTP/1.1, JSON or proto)
HTTP listener ---+---------- gRPC (HTTP/2, protobuf)
+---------- gRPC-Web (HTTP/1.1, base64-wrapped)
5. Browser Clients Without a Proxy
Generate a TypeScript client with buf generate and the @connectrpc/protoc-gen-connect-es plugin:
# buf.gen.web.yaml
version: v2
plugins:
- remote: buf.build/bufbuild/es:v2.2.0
out: web/src/gen
opt: target=ts
- remote: buf.build/connectrpc/es:v1.6.1
out: web/src/gen
opt: target=ts
Then in the browser:
import { createConnectTransport } from "@connectrpc/connect-web";
import { createPromiseClient } from "@connectrpc/connect";
import { OrderService } from "./gen/orders/v1/order_service_connect";
const transport = createConnectTransport({
baseUrl: "https://api.example.com",
});
const client = createPromiseClient(OrderService, transport);
const { order } = await client.getOrder({ id: "42" });
console.log(order?.amountCents);
No Envoy, no grpc-web proxy, no codegen plugin chains to maintain. The browser speaks Connect, the server speaks Connect, done. The wire format is JSON-friendly enough that you can debug in DevTools network panel.
6. Interceptors
Connect’s interceptor model is cleaner than gRPC-Go’s. A single interceptor type works for unary and streaming, client and server. Here’s an auth check:
func authInterceptor(secret string) connect.UnaryInterceptorFunc {
return func(next connect.UnaryFunc) connect.UnaryFunc {
return connect.UnaryFunc(func(
ctx context.Context,
req connect.AnyRequest,
) (connect.AnyResponse, error) {
if req.Spec().IsClient {
req.Header().Set("Authorization", "Bearer "+secret)
return next(ctx, req)
}
tok := req.Header().Get("Authorization")
if tok == "" {
return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("missing token"))
}
return next(ctx, req)
})
}
}
Wire it up at handler construction:
interceptors := connect.WithInterceptors(connect.UnaryInterceptorFunc(authInterceptor("s3cret")))
path, handler := ordersv1connect.NewOrderServiceHandler(&orderServer{}, interceptors)
For richer auth (mTLS, SPIFFE), see securing internal microservices with JWT and SPIFFE.
7. Common Pitfalls
7.1 Forgetting h2c in Dev, Then in Prod
If your dev setup uses http.ListenAndServe (no h2c), gRPC clients to that listener will fail with protocol error. If your prod setup uses h2c when there’s already TLS termination upstream, you’re fine but it’s confusing. Pick a model per environment and document it.
7.2 Returning Plain error
return errors.New("boom") becomes a Connect unknown error, which clients can’t introspect. Always use connect.NewError(connect.CodeX, err). Bonus: you can attach error details with connect.ErrorDetail.
7.3 Mixing Connect and gRPC Status Codes
The code enums look identical but aren’t interchangeable in code. connect.CodeNotFound is not codes.NotFound. If you’re calling a Connect service from a vanilla gRPC client, the wire-level translation handles it; in your application code, pick one taxonomy.
7.4 Streaming Over a Buffering Proxy
Cloud load balancers sometimes buffer HTTP/1.1 chunked responses, which kills server-streaming latency. Use HTTP/2 end-to-end for streams, or set the LB to passthrough. AWS ALB does this with HTTP/2 enabled; CloudFront does not.
7.5 Skipping the JSON Wire Format
JSON over Connect is slower than proto on the wire but trivially debuggable. Keep both enabled in dev; you’ll thank yourself when chasing a bad payload.
8. Troubleshooting
8.1 415 Unsupported Media Type
The client sent a Content-Type Connect doesn’t recognize. Usually means a generated client and a server are out of version sync. Regenerate.
8.2 Browser CORS Errors
Connect needs Access-Control-Allow-Headers to include Content-Type, Connect-Protocol-Version, and Connect-Timeout-Ms. Use the connectcors helper from the @connectrpc/connect Go package, or write your CORS middleware to allow those explicitly.
8.3 Streaming Hangs at ~30 Seconds
Some proxies kill idle HTTP responses after 30s. Add an in-stream heartbeat message, or shorten the proxy’s read timeout policy for this path. The same heartbeat pattern from vanilla gRPC applies.
9. Wrapping Up
Connect-Go isn’t a replacement for gRPC; it’s gRPC with a saner wire story and a working browser client. If your service is internal-only and high-throughput, vanilla gRPC is still fine. If you have any frontend talking to it, Connect is the lower-friction path, and the migration from vanilla gRPC is shallow.
The Connect docs are excellent and worth reading top to bottom before a real rollout. Pay close attention to the section on protocol negotiation; the magic of one handler serving three protocols rests on Content-Type discipline, and that’s the thing most likely to bite you in production. Next up in this series, we tighten the security story with JWT and SPIFFE.