A lightweight RPC framework for Go that runs directly over QUIC streams. It uses Protocol Buffers for service definitions and includes a protoc plugin that generates type-safe client and server stubs in the same style as gRPC-Go — server interfaces, registration functions, and typed client constructors.
gRPC sends protobuf messages through several layers of framing: protobuf payloads are wrapped in gRPC length-prefixed messages, carried inside HTTP/2 DATA frames with HPACK-compressed headers, and terminated by HTTP trailers that encode grpc-status and grpc-message. All of this runs over a TCP connection with a separate TLS handshake. Each layer adds complexity, overhead, and code that exists only to satisfy the requirements of the layer above it.
QUIC eliminates the need for most of these layers. It provides multiplexed, independent streams with TLS 1.3 built into the handshake — the same core properties that HTTP/2 was designed to provide over TCP, but at the transport level. qrpc uses QUIC streams directly as the delivery mechanism for RPCs: each call opens a dedicated stream, writes a compact binary frame, and reads the response. There is no HTTP framing, no HPACK, no pseudo-headers, and no trailer-encoded status codes. The protocol negotiation that HTTP would normally handle is replaced by a single ALPN token (qrpc) exchanged during the QUIC handshake.
This means fewer allocations per call, less parsing, and a smaller dependency surface. The entire wire format is a handful of length-prefixed fields — simple enough to describe in a few lines and implement without a framing library.
- QUIC transport — multiplexed streams over a single connection with no head-of-line blocking
- Protobuf code generation —
protoc-gen-qrpcgenerates familiar gRPC-style client interfaces, server interfaces, and registration glue from.protofiles - Transparent reconnection —
ClientConnautomatically re-establishes the underlying QUIC connection on failure - Minimal wire format — compact binary framing with no HTTP dependency
- Buffer pooling —
sync.Pool-backed buffers minimize per-RPC heap allocations - Context cancellation — client-side context cancellation propagates to stream teardown
go get github.com/acycl/qrpcInstall the protoc plugin:
go install github.com/acycl/qrpc/cmd/protoc-gen-qrpc@latestOr add it as a tool dependency in your go.mod:
go get -tool github.com/acycl/qrpc/cmd/protoc-gen-qrpcThis lets you invoke it via go tool protoc-gen-qrpc without a global install, which is especially useful with Buf.
// greeter.proto
syntax = "proto3";
package greeter;
option go_package = "example.com/greeter";
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply);
}protoc --go_out=. --go_opt=paths=source_relative \
--qrpc_out=. --qrpc_opt=paths=source_relative \
greeter.protoThis produces greeter.pb.go (protobuf messages) and greeter_qrpc.pb.go (client/server stubs).
The generated code follows the same pattern as gRPC-Go:
- A server interface (
GreeterServer) that you implement - A registration function (
RegisterGreeterServer) to wire it up - A client interface (
GreeterClient) with a constructor (NewGreeterClient) backed by a*qrpc.ClientConn
package main
import (
"context"
"crypto/tls"
"log"
"github.com/acycl/qrpc"
pb "example.com/greeter"
)
type greeterServer struct{}
func (s *greeterServer) SayHello(ctx context.Context, req *pb.HelloRequest) (*pb.HelloReply, error) {
return &pb.HelloReply{Message: "Hello, " + req.Name + "!"}, nil
}
func main() {
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{mustLoadCert()},
}
srv := qrpc.NewServer()
pb.RegisterGreeterServer(srv, &greeterServer{})
ctx := context.Background()
if err := srv.ListenAndServe(ctx, "127.0.0.1:4242", tlsConfig); err != nil {
log.Fatal(err)
}
}package main
import (
"context"
"crypto/tls"
"fmt"
"log"
"github.com/acycl/qrpc"
pb "example.com/greeter"
)
func main() {
tlsConfig := &tls.Config{
ServerName: "localhost",
// RootCAs: ...,
}
conn, err := qrpc.Dial(context.Background(), "127.0.0.1:4242", tlsConfig)
if err != nil {
log.Fatal(err)
}
defer conn.Close()
client := pb.NewGreeterClient(conn)
reply, err := client.SayHello(context.Background(), &pb.HelloRequest{Name: "World"})
if err != nil {
log.Fatal(err)
}
fmt.Println(reply.Message) // Hello, World!
}If you use Buf for protobuf management, you can invoke protoc-gen-qrpc via go tool so that your project's pinned version is always used. Add it as a tool dependency (see Installation), then configure buf.gen.yaml:
version: v2
plugins:
- protoc_path: ["go", "tool", "protoc-gen-go"]
out: .
opt: paths=source_relative
- protoc_path: ["go", "tool", "protoc-gen-qrpc"]
out: .
opt: paths=source_relativeThen generate with:
buf generateEach unary RPC occupies a single QUIC stream. The client writes a request frame and closes the write side of the stream; the server writes a response frame and the stream is complete. No additional framing, content negotiation, or status trailers are needed because QUIC itself handles stream boundaries, flow control, and encryption.
Request frame:
[4 bytes: method length] [4 bytes: payload length] [method] [payload]
The method is the fully-qualified protobuf method path (e.g. /greeter.Greeter/SayHello). The payload is the serialized protobuf request message.
Response frame (success):
[1 byte: 0x00] [4 bytes: payload length] [payload]
Response frame (error):
[1 byte: 0x01] [4 bytes: message length] [error message]
The single status byte replaces gRPC's trailer-encoded grpc-status and grpc-message fields. Errors are transmitted as plain UTF-8 strings.
| Function / Method | Description |
|---|---|
qrpc.NewServer() |
Create a new server instance |
srv.RegisterService(desc) |
Register a service (called by generated code) |
srv.HandlerTimeout |
Per-RPC timeout; zero means no limit |
srv.ListenAndServe(ctx, addr, tlsConfig) |
Listen on an address and serve RPCs |
srv.Serve(ctx, listener) |
Serve RPCs on an existing quic.Listener |
| Function / Method | Description |
|---|---|
qrpc.Dial(ctx, addr, tlsConfig) |
Connect to a qrpc server |
cc.Invoke(ctx, method, req, reply) |
Make a unary RPC call (called by generated code) |
cc.Close() |
Close the connection |
The client transparently reconnects if the underlying QUIC connection is lost. Concurrent callers coordinate so that only one reconnection attempt is made.
The generated code provides typed wrappers so you don't call Invoke directly:
RegisterXxxServer(srv, impl)— registers your implementation with the serverNewXxxClient(cc)— returns a typed client backed by a*qrpc.ClientConn
The default QUIC configuration is tuned for RPC workloads:
| Parameter | Value |
|---|---|
| Max concurrent streams | 65,536 |
| Idle timeout | 60 s |
| Keep-alive period | 15 s |
| Initial stream receive window | 1 MiB |
| Max stream receive window | 8 MiB |
| Max connection receive window | 64 MiB |
| Max payload size | 16 MiB |
go test ./...Run benchmarks:
go test -bench=. -benchmem ./...See LICENSE for details.