- Go 100%
- Codegen via Buf remote plugins (protocolbuffers/go + connectrpc/go) - http3.Server serves the Connect handler; http3.Transport on the client - TLS via demo CA (QUIC mandates TLS 1.3) - benchmark: codec sweep 64K-2M, concurrency scaling with in-process QUIC connection counting (http3.Transport Dial hook) - connect v1.21 new streaming API |
||
|---|---|---|
| cmd | ||
| gen/greet/v1 | ||
| proto/greet/v1 | ||
| .gitignore | ||
| buf.gen.yaml | ||
| buf.yaml | ||
| go.mod | ||
| go.sum | ||
| README.md | ||
golang-quic-rpc
A minimal demo of a Go server and client speaking Connect RPC (connect-go) over HTTP/3 / QUIC (quic-go) — same schema, all four RPC modes, and the same benchmark methodology as the sibling repos:
pyrpc-demo— Connect RPC + protobuf-py (Python)pygrpc-demo— gRPC + classic protobuf (Python)rust-grpc-demo— gRPC + tonic/prost (Rust)golang-grpc-demo— gRPC + protobuf-go (Go)- this repo — Connect RPC + quic-go, over HTTP/3 (Go)
Why this repo exists: the sibling benchmarks show HTTP/2 multiplexing trades connection fan-out for TCP-level head-of-line blocking (one lost segment stalls every stream). QUIC was designed to fix exactly that — independent per-stream loss recovery over UDP. This repo measures what that architecture costs and buys on loopback.
Project layout
proto/greet/v1/greet.proto # the schema: greet.v1.GreetService (4 RPC modes)
buf.yaml / buf.gen.yaml # codegen via Buf remote plugins (Go plugins)
gen/greet/v1/greet.pb.go # generated messages (checked in, idiomatic Go)
gen/greet/v1/greetv1connect/ # generated Connect service stubs (checked in)
certs/ # demo CA + server cert (gitignored; QUIC requires TLS)
cmd/server/main.go # Connect server on http3.Server (QUIC)
cmd/client/main.go # Connect client via http3.Transport
cmd/benchmark/main.go # performance benchmark (codec + RPC throughput)
Dependencies (go.mod)
| Dependency | Why it's here |
|---|---|
connectrpc.com/connect |
Connect RPC runtime for Go — handlers are plain net/http handlers, clients are plain http.Client users. |
github.com/quic-go/quic-go |
QUIC + HTTP/3: http3.Server serves the Connect handler over QUIC, http3.Transport gives the client an http.RoundTripper. |
google.golang.org/protobuf |
protobuf-go runtime for the generated message types. |
Codegen uses Buf remote plugins (buf.build/protocolbuffers/go and
buf.build/connectrpc/go), which run on Buf's servers — no local protoc
needed. The generated files are checked in (idiomatic Go); regenerate with
any buf binary, e.g. from a sibling pyrpc-demo venv:
/path/to/pyrpc-demo/.venv/bin/buf generate
TLS
QUIC mandates TLS 1.3, so the server listens on https://localhost:8443
with the demo certificate in certs/ (self-signed CA + leaf, 30-day
validity — same scheme as pyrpc-demo's HTTP/2 demo). The client trusts
certs/ca.pem. Regenerate with openssl if expired (see pyrpc-demo's
server_h2.py notes for the CA + leaf procedure).
Run
go run ./cmd/server # terminal 1 (https://127.0.0.1:8443, QUIC)
go run ./cmd/client # terminal 2
Expected client output (all four RPC kinds):
--- unary: Greet ---
[client] Hello, world!
--- server streaming: GreetStream ---
[client] Hello #1, streamer!
[client] Hello #2, streamer!
[client] Hello #3, streamer!
--- client streaming: GreetCount ---
[client] server counted 3 requests
--- bidirectional streaming: GreetChat ---
[client] Hi, alice!
[client] Hi, bob!
[client] Hi, carol!
Benchmark
Same methodology as the sibling repos:
- Codec — in-process serialize/deserialize of
GreetRequestat 64 / 128 / 256 / 512 / 1024 / 2048 KiB payloads (protobuf-go; identical to golang-grpc-demo's numbers by construction). - Unary throughput vs concurrency — 2,000
Greetround-trips at each concurrency level (1, 4, 16, 64). Connection counting is done in-process: the benchmark transport wrapshttp3.Transport.Dialto track open QUIC connections (quic-go's client UDP sockets don't expose a peer address to lsof/netstat, so OS-level counting doesn't work for QUIC). - Unary throughput, large payloads — 200 round-trips, 2 MiB payloads.
- Bidi streaming throughput — 5,000 messages over one
GreetChatstream.
go run ./cmd/server # terminal 1
go run ./cmd/benchmark # terminal 2
Sample results (M-series MacBook, 2026-09)
| Benchmark | Connect + quic-go (this repo) | Connect + uvicorn h1 (py) | gRPC + grpc-go (h2) |
|---|---|---|---|
| unary c=1 (1 KiB) | 12,018 req/s | 4,871 req/s | 19,583 req/s |
| unary c=64 (1 KiB) | 38,234 req/s (3.18×) | 13,383 req/s (2.75×) | 72,394 req/s (3.70×) |
| connections at c=64 | 1 QUIC conn (UDP) | 64 TCP conns | 1 TCP conn |
| unary 2 MiB (c=8) | 104 req/s (218 MB/s) | 189 req/s (397 MB/s) | 922 req/s (1,933 MB/s) |
| bidi streaming (1 KiB) | 91k msg/s | 18.2k msg/s | 398k msg/s |
Observations:
- The HTTP/3 connection model works as advertised: exactly one QUIC connection at every concurrency level, carrying multiplexed streams — same fingerprint as the HTTP/2 stacks, but over UDP.
- Loopback throughput is lower than kernel TCP — for both small and (especially) large payloads. quic-go implements QUIC in user space: every packet is a syscall and copies through Go, while HTTP/1.1/h1 and gRPC's HTTP/2 ride the kernel TCP stack. At 2 MiB payloads, single-stream QUIC flow control also caps throughput (~218 MB/s vs ~1.9 GB/s for gRPC-Go over TCP on loopback). On real networks with loss, the tradeoff can invert: per-stream loss recovery avoids the TCP head-of-line blocking that stalls every multiplexed stream on a shared HTTP/2 connection.
- The codec numbers match golang-grpc-demo exactly (same protobuf-go runtime, no networking involved).