{"schemaVersion":1,"id":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design","url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design","title":"gRPC + Protobuf + ConnectRPC: Type-Safe Streaming APIs with Code Generation","description":"A practical guide to gRPC, Protocol Buffers, and ConnectRPC for building type-safe streaming APIs, with comparisons to REST and tRPC, reflection setup, load balancing considerations, and production-ready code.","language":"en","datePublished":"2026-01-06","dateModified":null,"contentFormat":"text/markdown","content":"API Design\n\n# gRPC + Protobuf + ConnectRPC: Type-Safe Streaming APIs with Code Generation\n\n2026-01-0611 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/grpc-protobuf-connectrpc-api-design>)\n\n## Protocol Buffers: Your API's Source of Truth\n\nThe API contract is a ` .proto ` file, and everything else is generated from it. This replaces OpenAPI YAML and Swagger editors.\n\n```protobuf\n// proto/blog/v1/blog.proto\nsyntax = \"proto3\";\n\npackage blog.v1;\n\nimport \"google/protobuf/timestamp.proto\";\nimport \"google/protobuf/field_mask.proto\";\nimport \"buf/validate/validate.proto\";\n\nmessage Post {\n string id = 1;\n string title = 2 [(buf.validate.field).string.min_len = 1];\n string content = 3;\n string author_id = 4;\n repeated string tags = 5;\n PostStatus status = 6;\n google.protobuf.Timestamp created_at = 7;\n google.protobuf.Timestamp updated_at = 8;\n optional string featured_image_url = 9;\n}\n\nenum PostStatus {\n POST_STATUS_UNSPECIFIED = 0;\n POST_STATUS_DRAFT = 1;\n POST_STATUS_PUBLISHED = 2;\n POST_STATUS_ARCHIVED = 3;\n}\n\nmessage CreatePostRequest {\n string title = 1 [(buf.validate.field).string = {min_len: 1, max_len: 200}];\n string content = 2 [(buf.validate.field).string.min_len = 1];\n repeated string tags = 3 [(buf.validate.field).repeated.max_items = 10];\n optional string featured_image_url = 4;\n}\n\nmessage CreatePostResponse {\n Post post = 1;\n}\n\nmessage ListPostsRequest {\n int32 page_size = 1 [(buf.validate.field).int32 = {gte: 1, lte: 100}];\n string page_token = 2;\n optional PostStatus status_filter = 3;\n}\n\nmessage ListPostsResponse {\n repeated Post posts = 1;\n string next_page_token = 2;\n int32 total_count = 3;\n}\n\nmessage UpdatePostRequest {\n string id = 1 [(buf.validate.field).string.min_len = 1];\n optional string title = 2;\n optional string content = 3;\n repeated string tags = 4;\n optional PostStatus status = 5;\n google.protobuf.FieldMask update_mask = 6;\n}\n\nmessage UpdatePostResponse {\n Post post = 1;\n}\n\n// Server streaming, real-time feed\nmessage StreamPostsRequest {\n repeated string author_ids = 1;\n repeated PostStatus status_filter = 2;\n}\n\nservice BlogService {\n rpc CreatePost(CreatePostRequest) returns (CreatePostResponse);\n rpc ListPosts(ListPostsRequest) returns (ListPostsResponse);\n rpc UpdatePost(UpdatePostRequest) returns (UpdatePostResponse);\n rpc StreamPosts(StreamPostsRequest) returns (stream Post);\n}\n```\n\nA few things to notice. The ` optional ` keyword is doing real work here, in proto3, all fields have zero-value defaults. A ` string ` defaults to ` \"\" `, an ` int32 ` to ` 0 `. Without ` optional `, you can't tell if the client sent an empty string or didn't send the field at all. For update operations, that distinction matters enormously.\n\nThe ` buf.validate ` annotations are from the Buf ecosystem, they generate validation code alongside your types. No separate validation layer.\n\nThe ` FieldMask ` on the update request is the proto way of doing partial updates. Instead of the REST debate about PATCH semantics, the client explicitly says \"I'm updating these fields\" and the server respects it.\n\n## Code Generation: The Part That Actually Saves Time\n\nUsing ` buf ` (the modern protobuf toolchain):\n\n```yaml\n# buf.gen.yaml\nversion: v2\nplugins:\n # Go server + client\n - remote: buf.build/protocolbuffers/go\n out: gen/go\n opt: paths=source_relative\n - remote: buf.build/connectrpc/go\n out: gen/go\n opt: paths=source_relative\n # TypeScript client\n - remote: buf.build/connectrpc/es\n out: gen/ts\n opt: target=ts\n - remote: buf.build/bufbuild/es\n out: gen/ts\n opt: target=ts\n # Validation\n - remote: buf.build/bufbuild/protovalidate-go\n out: gen/go\n opt: paths=source_relative\n```\n\n```bash\nbuf generate\n```\n\nThat generates fully typed Go server interfaces, Go client stubs, TypeScript client stubs, and validation helpers. From one ` .proto ` file. When the proto changes, regenerating shows every location that broke at compile time. Try getting that from an OpenAPI spec.\n\n## The Go Server: ConnectRPC in Practice\n\n```go\npackage main\n\nimport (\n \"context\"\n \"fmt\"\n \"log\"\n \"net/http\"\n \"time\"\n\n \"connectrpc.com/connect\"\n \"connectrpc.com/grpcreflect\"\n \"connectrpc.com/validate\"\n \"golang.org/x/net/http2\"\n \"golang.org/x/net/http2/h2c\"\n\n blogv1 \"myapp/gen/go/blog/v1\"\n \"myapp/gen/go/blog/v1/blogv1connect\"\n)\n\ntype BlogServer struct {\n db *Database\n}\n\nfunc (s *BlogServer) CreatePost(\n ctx context.Context,\n req *connect.Request[blogv1.CreatePostRequest],\n) (*connect.Response[blogv1.CreatePostResponse], error) {\n // Validation already happened via the interceptor\n post := &blogv1.Post{\n Id: generateID(),\n Title: req.Msg.Title,\n Content: req.Msg.Content,\n AuthorId: getUserID(ctx),\n Tags: req.Msg.Tags,\n Status: blogv1.PostStatus_POST_STATUS_DRAFT,\n CreatedAt: timestamppb.Now(),\n }\n\n if err := s.db.Posts.Create(ctx, post); err != nil {\n return nil, connect.NewError(connect.CodeInternal, err)\n }\n\n return connect.NewResponse(&blogv1.CreatePostResponse{\n Post: post,\n }), nil\n}\n\nfunc (s *BlogServer) StreamPosts(\n ctx context.Context,\n req *connect.Request[blogv1.StreamPostsRequest],\n stream *connect.ServerStream[blogv1.Post],\n) error {\n // Subscribe to the post change feed\n ch, cleanup := s.db.Posts.Subscribe(ctx, req.Msg.AuthorIds)\n defer cleanup()\n\n // Send a keepalive every 30 seconds to prevent LB timeout\n ticker := time.NewTicker(30 * time.Second)\n defer ticker.Stop()\n\n for {\n select {\n case <-ctx.Done():\n return nil\n case post, ok := <-ch:\n if !ok {\n return nil\n }\n // Filter by status if specified\n if len(req.Msg.StatusFilter) > 0 {\n if !containsStatus(req.Msg.StatusFilter, post.Status) {\n continue\n }\n }\n if err := stream.Send(post); err != nil {\n return err\n }\n case <-ticker.C:\n // ConnectRPC doesn't have native pings, send an empty-ish message\n // or use HTTP/2 PING frames at the transport level\n }\n }\n}\n\nfunc main() {\n blog := &BlogServer{db: NewDatabase()}\n\n // Validation interceptor, rejects invalid requests before your handler runs\n validateInterceptor, err := validate.NewInterceptor()\n if err != nil {\n log.Fatal(err)\n }\n\n mux := http.NewServeMux()\n\n // Register the service\n path, handler := blogv1connect.NewBlogServiceHandler(\n blog,\n connect.WithInterceptors(\n validateInterceptor,\n loggingInterceptor(),\n authInterceptor(),\n ),\n )\n mux.Handle(path, handler)\n\n // Enable reflection for grpcurl / grpcui\n reflector := grpcreflect.NewStaticReflector(\n blogv1connect.BlogServiceName,\n )\n mux.Handle(grpcreflect.NewHandlerV1(reflector))\n mux.Handle(grpcreflect.NewHandlerV1Alpha(reflector))\n\n // h2c allows HTTP/2 without TLS, needed for gRPC protocol\n // In production, terminate TLS at the load balancer\n server := &http.Server{\n Addr: \":8080\",\n Handler: h2c.NewHandler(mux, &http2.Server{}),\n }\n\n log.Printf(\"Server listening on :8080\")\n log.Fatal(server.ListenAndServe())\n}\n```\n\n[ConnectRPC](<https://connectrpc.com/>) is just an HTTP handler. It plugs into the standard ` net/http ` mux. No special server binary, no custom runtime. You can mount it alongside your REST endpoints, your health checks, your metrics endpoint. That composability is a major advantage over raw gRPC-Go, which wants to own the entire server lifecycle.\n\n## The TypeScript Client: Browser + Node.js\n\n```typescript\nimport { createClient } from \"@connectrpc/connect\";\nimport { createConnectTransport } from \"@connectrpc/connect-web\";\nimport { BlogService } from \"./gen/ts/blog/v1/blog_pb\";\n\n// Browser client, uses Connect protocol (HTTP/1.1 compatible)\nconst transport = createConnectTransport({\n baseUrl: \"https://api.myapp.com\",\n // Uses JSON by default for debugging ease\n // Switch to binary for production:\n // useBinaryFormat: true,\n});\n\nconst client = createClient(BlogService, transport);\n\n// Unary call, fully typed\nasync function createPost(title: string, content: string) {\n const response = await client.createPost({\n title,\n content,\n tags: [\"engineering\"],\n });\n console.log(\"Created:\", response.post?.id);\n return response.post;\n}\n\n// Server streaming, real-time feed\nasync function watchPosts(authorIds: string[]) {\n for await (const post of client.streamPosts({ authorIds })) {\n console.log(\"New post:\", post.title);\n updateUI(post);\n }\n // Stream ended (server closed it or connection dropped)\n}\n\n// With abort controller for cleanup\nasync function watchPostsWithCancel(authorIds: string[]) {\n const controller = new AbortController();\n\n try {\n for await (const post of client.streamPosts(\n { authorIds },\n { signal: controller.signal }\n )) {\n updateUI(post);\n }\n } catch (err) {\n if (err instanceof ConnectError && err.code === Code.Canceled) {\n // Expected, we cancelled it\n return;\n }\n throw err;\n }\n\n // Call this to cancel the stream\n return () => controller.abort();\n}\n```\n\nEvery field is typed. Every method is typed. The ` createPost ` call won't compile if you pass a number for ` title ` or forget a required field. The streaming API uses async iterators, which is the most natural JavaScript pattern for consuming a stream of messages.\n\n## The Honest Comparison: gRPC vs REST vs tRPC\n\nBased on production experience with all three, here is the unvarnished truth:\n\n**Type safety across the wire**: gRPC/ConnectRPC wins. The contract is defined once in ` .proto ` and generated code is guaranteed to match. REST with OpenAPI codegen gets close but specs drift. tRPC has excellent type safety but only within TypeScript.\n\n**Performance**: Binary protobuf is 3-10x smaller than JSON for typical payloads. On high-throughput service-to-service calls, this matters. For a browser loading a single page, the difference is negligible.\n\n```\nBenchmark: 1000 requests, payload: list of 100 blog posts\n\nREST + JSON:\n Payload size: 48.2 KB (gzip: 8.1 KB)\n Serialization: 2.3ms avg\n Deserialization: 3.1ms avg\n\ngRPC + Protobuf:\n Payload size: 12.7 KB (no further compression needed)\n Serialization: 0.4ms avg\n Deserialization: 0.6ms avg\n\nConnectRPC + JSON:\n Payload size: 48.2 KB (gzip: 8.1 KB)\n Serialization: 2.3ms avg\n Deserialization: 3.1ms avg\n (same as REST, it's JSON over HTTP)\n\nConnectRPC + Protobuf binary:\n Payload size: 12.7 KB\n Serialization: 0.4ms avg\n Deserialization: 0.6ms avg\n```\n\n**Streaming**: gRPC has first-class streaming, server streaming, client streaming, bidirectional. REST requires WebSockets or SSE, which are bolted-on protocols. tRPC supports subscriptions via WebSockets but it feels like an afterthought.\n\n**Ecosystem**: REST wins overwhelmingly. Every tool, every proxy, every monitoring system understands HTTP/JSON. gRPC requires HTTP/2-aware infrastructure. ConnectRPC bridges this gap by supporting both protocols.\n\n**Learning curve**: REST is the lowest. Everyone understands HTTP verbs and JSON. tRPC is next, if you know TypeScript, you can be productive in hours. gRPC/Protobuf has a steeper ramp, you need to learn the proto language, the buf toolchain, and understand the different transport protocols.\n\n## Load Balancing: The Gotcha That Wrecks Deployments\n\ngRPC uses HTTP/2, which multiplexes many requests over a single TCP connection. This is great for performance but terrible for load balancing.\n\nA standard L4 load balancer (like [AWS](<https://aws.com/>) NLB) balances at the connection level. If your gRPC client opens one connection to the load balancer, all requests go to the same backend server. Your \"load balanced\" service has all traffic hitting one pod.\n\nSolutions:\n\n**L7 load balancing**: Use an L7 proxy that understands HTTP/2 frames, Envoy, nginx (with gRPC module), or cloud-native L7 LBs like AWS ALB or GCP's gRPC-aware LB.\n\n**Client-side balancing**: The gRPC client maintains connections to multiple backends and distributes requests. This is the standard approach in Kubernetes with headless services.\n\n```go\n// Client-side round-robin with ConnectRPC\nimport (\n \"connectrpc.com/connect\"\n \"net/http\"\n)\n\n// Custom transport that round-robins across backends\ntype roundRobinTransport struct {\n backends []string\n current atomic.Int64\n client *http.Client\n}\n\nfunc (t *roundRobinTransport) RoundTrip(req *http.Request) (*http.Response, error) {\n idx := t.current.Add(1) % int64(len(t.backends))\n req.URL.Host = t.backends[idx]\n return t.client.Do(req)\n}\n```\n\n**Service mesh**: If you're on Kubernetes, Linkerd or Istio handle gRPC load balancing transparently. This is the path of least resistance if you're already running a mesh.\n\n## Reflection: Making Your API Self-Documenting\n\ngRPC reflection lets tools like ` grpcurl ` and ` grpcui ` discover your API at runtime without the ` .proto ` files:\n\n```bash\n# List all services\ngrpcurl -plaintext localhost:8080 list\n\n# Describe a service\ngrpcurl -plaintext localhost:8080 describe blog.v1.BlogService\n\n# Call an RPC\ngrpcurl -plaintext -d '{\"title\": \"Hello\", \"content\": \"World\"}' \\\n localhost:8080 blog.v1.BlogService/CreatePost\n\n# Stream responses\ngrpcurl -plaintext -d '{\"author_ids\": [\"user-123\"]}' \\\n localhost:8080 blog.v1.BlogService/StreamPosts\n```\n\n` grpcui ` is even better, it gives you a web UI that auto-generates forms from your proto definitions. Think Swagger UI but for gRPC.\n\nFor production, disable reflection. It exposes your service contract to anyone who can reach the endpoint. In development and staging, it's invaluable.\n\n## Error Handling: The gRPC Way\n\ngRPC has a fixed set of status codes (16 of them), and they're more precise than HTTP status codes for RPC scenarios:\n\n```go\nfunc (s *BlogServer) UpdatePost(\n ctx context.Context,\n req *connect.Request[blogv1.UpdatePostRequest],\n) (*connect.Response[blogv1.UpdatePostResponse], error) {\n post, err := s.db.Posts.FindByID(ctx, req.Msg.Id)\n if err != nil {\n return nil, connect.NewError(connect.CodeNotFound,\n fmt.Errorf(\"post %s not found\", req.Msg.Id))\n }\n\n // Authorization check\n userID := getUserID(ctx)\n if post.AuthorId != userID {\n return nil, connect.NewError(connect.CodePermissionDenied,\n fmt.Errorf(\"you don't own this post\"))\n }\n\n // Optimistic concurrency\n if req.Msg.ExpectedVersion != post.Version {\n return nil, connect.NewError(connect.CodeAborted,\n fmt.Errorf(\"post was modified by another request\"))\n }\n\n // Rich error details\n if len(req.Msg.Title) > 200 {\n detail, _ := connect.NewErrorDetail(&errdetails.BadRequest{\n FieldViolations: []*errdetails.BadRequest_FieldViolation{\n {Field: \"title\", Description: \"must be 200 characters or less\"},\n },\n })\n connErr := connect.NewError(connect.CodeInvalidArgument,\n fmt.Errorf(\"validation failed\"))\n connErr.AddDetail(detail)\n return nil, connErr\n }\n\n // ... apply updates ...\n}\n```\n\nThe error detail mechanism is underused but powerful. You can attach structured metadata to errors, field validation details, retry-after hints, localized messages. The client gets typed error information, not just a string.\n\n## When to Reach for This Stack\n\ngRPC + ConnectRPC is the right choice when:\n\n- Building service-to-service communication in a polyglot system\n- Streaming is required (real-time feeds, long-running operations)\n- The API surface is well-defined and won't change shape frequently\n- A single contract that generates clients for web, mobile, and server is desired\n\nREST remains the better choice when:\n\n- The API is public-facing and needs maximum compatibility\n- The team is small and doesn't want to learn protobuf\n- The API is CRUD-heavy with simple request/response patterns\n- Webhooks and third-party integrations are the primary consumers\n\ntRPC fits best when:\n\n- The entire stack is TypeScript (Next.js monorepo)\n- Speed of development matters more than cross-language support\n- The API is internal and the team is small\n\nThe tooling has matured enough that the \"gRPC is too complex\" argument doesn't hold anymore. ` buf ` makes proto management as easy as ` npm `. ConnectRPC makes browser clients trivial. The type safety and code generation payoff is real and compounds over time. Every new endpoint you add is a generated function call away from being usable, not a hand-written fetch wrapper with manual type assertions.\n\nStart with a ` .proto ` file. Everything else follows.\n\n## Frequently asked questions\n\n### Should I use gRPC or REST for a new API?\n\nIf your clients are all server-side (microservices, internal tools), gRPC wins on type safety, performance, and code generation. If you need browser clients, ConnectRPC gives you the gRPC developer experience with HTTP/JSON fallback. Pure public-facing REST APIs still make sense when you need maximum ecosystem compatibility.\n\n### How does ConnectRPC compare to tRPC?\n\ntRPC is TypeScript-only and tightly coupled to the TS ecosystem. ConnectRPC generates clients for Go, TypeScript, Java, Swift, Kotlin, and more from a single .proto file. If you're polyglot or need mobile clients, ConnectRPC wins. If you're all-TypeScript monorepo, tRPC is simpler.\n\n### What about GraphQL?\n\nGraphQL solves a different problem, flexible client-driven queries. gRPC solves RPC with strong contracts. If your clients need to query arbitrary combinations of fields across entities, GraphQL. If you have well-defined operations, gRPC/ConnectRPC.\n\n## Tips\n\n1. **Tip:** Always use ` optional ` fields in proto3 instead of relying on zero-value defaults. Without ` optional `, you can't distinguish between 'field was set to 0' and 'field was not sent', this bites hard in PATCH-style updates.\n2. **Tip:** Enable gRPC reflection in all non-production environments. Tools like ` grpcurl ` and ` grpcui ` become useless without it, and your team will waste hours guessing at service definitions.\n3. **Tip:** ConnectRPC supports both gRPC and JSON over HTTP, use the JSON mode for browser clients and mobile debugging, and binary protobuf for server-to-server. Same service definition, zero code changes.\n4. **Tip:** When using streaming RPCs, always implement a ` keepalive ` ping. Load balancers (especially AWS ALB) will silently terminate idle HTTP/2 streams after 60 seconds by default.\n\n- grpc\n- protobuf\n- connectrpc\n- api-design\n- streaming\n- rpc\n- typescript\n- golang\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"gRPC + Protobuf + ConnectRPC: Type-Safe Streaming APIs with Code Generation","url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design#title"},{"level":2,"text":"Protocol Buffers: Your API's Source of Truth","url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design#protocol-buffers-your-apis-source-of-truth"},{"level":2,"text":"Code Generation: The Part That Actually Saves Time","url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design#code-generation-the-part-that-actually-saves-time"},{"level":2,"text":"The Go Server: ConnectRPC in Practice","url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design#the-go-server-connectrpc-in-practice"},{"level":2,"text":"The TypeScript Client: Browser + Node.js","url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design#the-typescript-client-browser-nodejs"},{"level":2,"text":"The Honest Comparison: gRPC vs REST vs tRPC","url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design#the-honest-comparison-grpc-vs-rest-vs-trpc"},{"level":2,"text":"Load Balancing: The Gotcha That Wrecks Deployments","url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design#load-balancing-the-gotcha-that-wrecks-deployments"},{"level":2,"text":"Reflection: Making Your API Self-Documenting","url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design#reflection-making-your-api-self-documenting"},{"level":2,"text":"Error Handling: The gRPC Way","url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design#error-handling-the-grpc-way"},{"level":2,"text":"When to Reach for This Stack","url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design#when-to-reach-for-this-stack"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design#questions"},{"level":3,"text":"Should I use gRPC or REST for a new API?","url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design#question-1"},{"level":3,"text":"How does ConnectRPC compare to tRPC?","url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design#question-2"},{"level":3,"text":"What about GraphQL?","url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design#article-tips"}],"sections":[{"heading":"gRPC + Protobuf + ConnectRPC: Type-Safe Streaming APIs with Code Generation","level":1,"url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design","content":"API Design"},{"heading":"gRPC + Protobuf + ConnectRPC: Type-Safe Streaming APIs with Code Generation","level":1,"url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design#title","content":"2026-01-0611 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/grpc-protobuf-connectrpc-api-design>)"},{"heading":"Protocol Buffers: Your API's Source of Truth","level":2,"url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design#protocol-buffers-your-apis-source-of-truth","content":"The API contract is a ` .proto ` file, and everything else is generated from it. This replaces OpenAPI YAML and Swagger editors.\n\n```protobuf\n// proto/blog/v1/blog.proto\nsyntax = \"proto3\";\n\npackage blog.v1;\n\nimport \"google/protobuf/timestamp.proto\";\nimport \"google/protobuf/field_mask.proto\";\nimport \"buf/validate/validate.proto\";\n\nmessage Post {\n string id = 1;\n string title = 2 [(buf.validate.field).string.min_len = 1];\n string content = 3;\n string author_id = 4;\n repeated string tags = 5;\n PostStatus status = 6;\n google.protobuf.Timestamp created_at = 7;\n google.protobuf.Timestamp updated_at = 8;\n optional string featured_image_url = 9;\n}\n\nenum PostStatus {\n POST_STATUS_UNSPECIFIED = 0;\n POST_STATUS_DRAFT = 1;\n POST_STATUS_PUBLISHED = 2;\n POST_STATUS_ARCHIVED = 3;\n}\n\nmessage CreatePostRequest {\n string title = 1 [(buf.validate.field).string = {min_len: 1, max_len: 200}];\n string content = 2 [(buf.validate.field).string.min_len = 1];\n repeated string tags = 3 [(buf.validate.field).repeated.max_items = 10];\n optional string featured_image_url = 4;\n}\n\nmessage CreatePostResponse {\n Post post = 1;\n}\n\nmessage ListPostsRequest {\n int32 page_size = 1 [(buf.validate.field).int32 = {gte: 1, lte: 100}];\n string page_token = 2;\n optional PostStatus status_filter = 3;\n}\n\nmessage ListPostsResponse {\n repeated Post posts = 1;\n string next_page_token = 2;\n int32 total_count = 3;\n}\n\nmessage UpdatePostRequest {\n string id = 1 [(buf.validate.field).string.min_len = 1];\n optional string title = 2;\n optional string content = 3;\n repeated string tags = 4;\n optional PostStatus status = 5;\n google.protobuf.FieldMask update_mask = 6;\n}\n\nmessage UpdatePostResponse {\n Post post = 1;\n}\n\n// Server streaming, real-time feed\nmessage StreamPostsRequest {\n repeated string author_ids = 1;\n repeated PostStatus status_filter = 2;\n}\n\nservice BlogService {\n rpc CreatePost(CreatePostRequest) returns (CreatePostResponse);\n rpc ListPosts(ListPostsRequest) returns (ListPostsResponse);\n rpc UpdatePost(UpdatePostRequest) returns (UpdatePostResponse);\n rpc StreamPosts(StreamPostsRequest) returns (stream Post);\n}\n```\n\nA few things to notice. The ` optional ` keyword is doing real work here, in proto3, all fields have zero-value defaults. A ` string ` defaults to ` \"\" `, an ` int32 ` to ` 0 `. Without ` optional `, you can't tell if the client sent an empty string or didn't send the field at all. For update operations, that distinction matters enormously.\n\nThe ` buf.validate ` annotations are from the Buf ecosystem, they generate validation code alongside your types. No separate validation layer.\n\nThe ` FieldMask ` on the update request is the proto way of doing partial updates. Instead of the REST debate about PATCH semantics, the client explicitly says \"I'm updating these fields\" and the server respects it."},{"heading":"Code Generation: The Part That Actually Saves Time","level":2,"url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design#code-generation-the-part-that-actually-saves-time","content":"Using ` buf ` (the modern protobuf toolchain):\n\n```yaml\n# buf.gen.yaml\nversion: v2\nplugins:\n # Go server + client\n - remote: buf.build/protocolbuffers/go\n out: gen/go\n opt: paths=source_relative\n - remote: buf.build/connectrpc/go\n out: gen/go\n opt: paths=source_relative\n # TypeScript client\n - remote: buf.build/connectrpc/es\n out: gen/ts\n opt: target=ts\n - remote: buf.build/bufbuild/es\n out: gen/ts\n opt: target=ts\n # Validation\n - remote: buf.build/bufbuild/protovalidate-go\n out: gen/go\n opt: paths=source_relative\n```\n\n```bash\nbuf generate\n```\n\nThat generates fully typed Go server interfaces, Go client stubs, TypeScript client stubs, and validation helpers. From one ` .proto ` file. When the proto changes, regenerating shows every location that broke at compile time. Try getting that from an OpenAPI spec."},{"heading":"The Go Server: ConnectRPC in Practice","level":2,"url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design#the-go-server-connectrpc-in-practice","content":"```go\npackage main\n\nimport (\n \"context\"\n \"fmt\"\n \"log\"\n \"net/http\"\n \"time\"\n\n \"connectrpc.com/connect\"\n \"connectrpc.com/grpcreflect\"\n \"connectrpc.com/validate\"\n \"golang.org/x/net/http2\"\n \"golang.org/x/net/http2/h2c\"\n\n blogv1 \"myapp/gen/go/blog/v1\"\n \"myapp/gen/go/blog/v1/blogv1connect\"\n)\n\ntype BlogServer struct {\n db *Database\n}\n\nfunc (s *BlogServer) CreatePost(\n ctx context.Context,\n req *connect.Request[blogv1.CreatePostRequest],\n) (*connect.Response[blogv1.CreatePostResponse], error) {\n // Validation already happened via the interceptor\n post := &blogv1.Post{\n Id: generateID(),\n Title: req.Msg.Title,\n Content: req.Msg.Content,\n AuthorId: getUserID(ctx),\n Tags: req.Msg.Tags,\n Status: blogv1.PostStatus_POST_STATUS_DRAFT,\n CreatedAt: timestamppb.Now(),\n }\n\n if err := s.db.Posts.Create(ctx, post); err != nil {\n return nil, connect.NewError(connect.CodeInternal, err)\n }\n\n return connect.NewResponse(&blogv1.CreatePostResponse{\n Post: post,\n }), nil\n}\n\nfunc (s *BlogServer) StreamPosts(\n ctx context.Context,\n req *connect.Request[blogv1.StreamPostsRequest],\n stream *connect.ServerStream[blogv1.Post],\n) error {\n // Subscribe to the post change feed\n ch, cleanup := s.db.Posts.Subscribe(ctx, req.Msg.AuthorIds)\n defer cleanup()\n\n // Send a keepalive every 30 seconds to prevent LB timeout\n ticker := time.NewTicker(30 * time.Second)\n defer ticker.Stop()\n\n for {\n select {\n case <-ctx.Done():\n return nil\n case post, ok := <-ch:\n if !ok {\n return nil\n }\n // Filter by status if specified\n if len(req.Msg.StatusFilter) > 0 {\n if !containsStatus(req.Msg.StatusFilter, post.Status) {\n continue\n }\n }\n if err := stream.Send(post); err != nil {\n return err\n }\n case <-ticker.C:\n // ConnectRPC doesn't have native pings, send an empty-ish message\n // or use HTTP/2 PING frames at the transport level\n }\n }\n}\n\nfunc main() {\n blog := &BlogServer{db: NewDatabase()}\n\n // Validation interceptor, rejects invalid requests before your handler runs\n validateInterceptor, err := validate.NewInterceptor()\n if err != nil {\n log.Fatal(err)\n }\n\n mux := http.NewServeMux()\n\n // Register the service\n path, handler := blogv1connect.NewBlogServiceHandler(\n blog,\n connect.WithInterceptors(\n validateInterceptor,\n loggingInterceptor(),\n authInterceptor(),\n ),\n )\n mux.Handle(path, handler)\n\n // Enable reflection for grpcurl / grpcui\n reflector := grpcreflect.NewStaticReflector(\n blogv1connect.BlogServiceName,\n )\n mux.Handle(grpcreflect.NewHandlerV1(reflector))\n mux.Handle(grpcreflect.NewHandlerV1Alpha(reflector))\n\n // h2c allows HTTP/2 without TLS, needed for gRPC protocol\n // In production, terminate TLS at the load balancer\n server := &http.Server{\n Addr: \":8080\",\n Handler: h2c.NewHandler(mux, &http2.Server{}),\n }\n\n log.Printf(\"Server listening on :8080\")\n log.Fatal(server.ListenAndServe())\n}\n```\n\n[ConnectRPC](<https://connectrpc.com/>) is just an HTTP handler. It plugs into the standard ` net/http ` mux. No special server binary, no custom runtime. You can mount it alongside your REST endpoints, your health checks, your metrics endpoint. That composability is a major advantage over raw gRPC-Go, which wants to own the entire server lifecycle."},{"heading":"The TypeScript Client: Browser + Node.js","level":2,"url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design#the-typescript-client-browser-nodejs","content":"```typescript\nimport { createClient } from \"@connectrpc/connect\";\nimport { createConnectTransport } from \"@connectrpc/connect-web\";\nimport { BlogService } from \"./gen/ts/blog/v1/blog_pb\";\n\n// Browser client, uses Connect protocol (HTTP/1.1 compatible)\nconst transport = createConnectTransport({\n baseUrl: \"https://api.myapp.com\",\n // Uses JSON by default for debugging ease\n // Switch to binary for production:\n // useBinaryFormat: true,\n});\n\nconst client = createClient(BlogService, transport);\n\n// Unary call, fully typed\nasync function createPost(title: string, content: string) {\n const response = await client.createPost({\n title,\n content,\n tags: [\"engineering\"],\n });\n console.log(\"Created:\", response.post?.id);\n return response.post;\n}\n\n// Server streaming, real-time feed\nasync function watchPosts(authorIds: string[]) {\n for await (const post of client.streamPosts({ authorIds })) {\n console.log(\"New post:\", post.title);\n updateUI(post);\n }\n // Stream ended (server closed it or connection dropped)\n}\n\n// With abort controller for cleanup\nasync function watchPostsWithCancel(authorIds: string[]) {\n const controller = new AbortController();\n\n try {\n for await (const post of client.streamPosts(\n { authorIds },\n { signal: controller.signal }\n )) {\n updateUI(post);\n }\n } catch (err) {\n if (err instanceof ConnectError && err.code === Code.Canceled) {\n // Expected, we cancelled it\n return;\n }\n throw err;\n }\n\n // Call this to cancel the stream\n return () => controller.abort();\n}\n```\n\nEvery field is typed. Every method is typed. The ` createPost ` call won't compile if you pass a number for ` title ` or forget a required field. The streaming API uses async iterators, which is the most natural JavaScript pattern for consuming a stream of messages."},{"heading":"The Honest Comparison: gRPC vs REST vs tRPC","level":2,"url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design#the-honest-comparison-grpc-vs-rest-vs-trpc","content":"Based on production experience with all three, here is the unvarnished truth:\n\n**Type safety across the wire**: gRPC/ConnectRPC wins. The contract is defined once in ` .proto ` and generated code is guaranteed to match. REST with OpenAPI codegen gets close but specs drift. tRPC has excellent type safety but only within TypeScript.\n\n**Performance**: Binary protobuf is 3-10x smaller than JSON for typical payloads. On high-throughput service-to-service calls, this matters. For a browser loading a single page, the difference is negligible.\n\n```\nBenchmark: 1000 requests, payload: list of 100 blog posts\n\nREST + JSON:\n Payload size: 48.2 KB (gzip: 8.1 KB)\n Serialization: 2.3ms avg\n Deserialization: 3.1ms avg\n\ngRPC + Protobuf:\n Payload size: 12.7 KB (no further compression needed)\n Serialization: 0.4ms avg\n Deserialization: 0.6ms avg\n\nConnectRPC + JSON:\n Payload size: 48.2 KB (gzip: 8.1 KB)\n Serialization: 2.3ms avg\n Deserialization: 3.1ms avg\n (same as REST, it's JSON over HTTP)\n\nConnectRPC + Protobuf binary:\n Payload size: 12.7 KB\n Serialization: 0.4ms avg\n Deserialization: 0.6ms avg\n```\n\n**Streaming**: gRPC has first-class streaming, server streaming, client streaming, bidirectional. REST requires WebSockets or SSE, which are bolted-on protocols. tRPC supports subscriptions via WebSockets but it feels like an afterthought.\n\n**Ecosystem**: REST wins overwhelmingly. Every tool, every proxy, every monitoring system understands HTTP/JSON. gRPC requires HTTP/2-aware infrastructure. ConnectRPC bridges this gap by supporting both protocols.\n\n**Learning curve**: REST is the lowest. Everyone understands HTTP verbs and JSON. tRPC is next, if you know TypeScript, you can be productive in hours. gRPC/Protobuf has a steeper ramp, you need to learn the proto language, the buf toolchain, and understand the different transport protocols."},{"heading":"Load Balancing: The Gotcha That Wrecks Deployments","level":2,"url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design#load-balancing-the-gotcha-that-wrecks-deployments","content":"gRPC uses HTTP/2, which multiplexes many requests over a single TCP connection. This is great for performance but terrible for load balancing.\n\nA standard L4 load balancer (like [AWS](<https://aws.com/>) NLB) balances at the connection level. If your gRPC client opens one connection to the load balancer, all requests go to the same backend server. Your \"load balanced\" service has all traffic hitting one pod.\n\nSolutions:\n\n**L7 load balancing**: Use an L7 proxy that understands HTTP/2 frames, Envoy, nginx (with gRPC module), or cloud-native L7 LBs like AWS ALB or GCP's gRPC-aware LB.\n\n**Client-side balancing**: The gRPC client maintains connections to multiple backends and distributes requests. This is the standard approach in Kubernetes with headless services.\n\n```go\n// Client-side round-robin with ConnectRPC\nimport (\n \"connectrpc.com/connect\"\n \"net/http\"\n)\n\n// Custom transport that round-robins across backends\ntype roundRobinTransport struct {\n backends []string\n current atomic.Int64\n client *http.Client\n}\n\nfunc (t *roundRobinTransport) RoundTrip(req *http.Request) (*http.Response, error) {\n idx := t.current.Add(1) % int64(len(t.backends))\n req.URL.Host = t.backends[idx]\n return t.client.Do(req)\n}\n```\n\n**Service mesh**: If you're on Kubernetes, Linkerd or Istio handle gRPC load balancing transparently. This is the path of least resistance if you're already running a mesh."},{"heading":"Reflection: Making Your API Self-Documenting","level":2,"url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design#reflection-making-your-api-self-documenting","content":"gRPC reflection lets tools like ` grpcurl ` and ` grpcui ` discover your API at runtime without the ` .proto ` files:\n\n```bash\n# List all services\ngrpcurl -plaintext localhost:8080 list\n\n# Describe a service\ngrpcurl -plaintext localhost:8080 describe blog.v1.BlogService\n\n# Call an RPC\ngrpcurl -plaintext -d '{\"title\": \"Hello\", \"content\": \"World\"}' \\\n localhost:8080 blog.v1.BlogService/CreatePost\n\n# Stream responses\ngrpcurl -plaintext -d '{\"author_ids\": [\"user-123\"]}' \\\n localhost:8080 blog.v1.BlogService/StreamPosts\n```\n\n` grpcui ` is even better, it gives you a web UI that auto-generates forms from your proto definitions. Think Swagger UI but for gRPC.\n\nFor production, disable reflection. It exposes your service contract to anyone who can reach the endpoint. In development and staging, it's invaluable."},{"heading":"Error Handling: The gRPC Way","level":2,"url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design#error-handling-the-grpc-way","content":"gRPC has a fixed set of status codes (16 of them), and they're more precise than HTTP status codes for RPC scenarios:\n\n```go\nfunc (s *BlogServer) UpdatePost(\n ctx context.Context,\n req *connect.Request[blogv1.UpdatePostRequest],\n) (*connect.Response[blogv1.UpdatePostResponse], error) {\n post, err := s.db.Posts.FindByID(ctx, req.Msg.Id)\n if err != nil {\n return nil, connect.NewError(connect.CodeNotFound,\n fmt.Errorf(\"post %s not found\", req.Msg.Id))\n }\n\n // Authorization check\n userID := getUserID(ctx)\n if post.AuthorId != userID {\n return nil, connect.NewError(connect.CodePermissionDenied,\n fmt.Errorf(\"you don't own this post\"))\n }\n\n // Optimistic concurrency\n if req.Msg.ExpectedVersion != post.Version {\n return nil, connect.NewError(connect.CodeAborted,\n fmt.Errorf(\"post was modified by another request\"))\n }\n\n // Rich error details\n if len(req.Msg.Title) > 200 {\n detail, _ := connect.NewErrorDetail(&errdetails.BadRequest{\n FieldViolations: []*errdetails.BadRequest_FieldViolation{\n {Field: \"title\", Description: \"must be 200 characters or less\"},\n },\n })\n connErr := connect.NewError(connect.CodeInvalidArgument,\n fmt.Errorf(\"validation failed\"))\n connErr.AddDetail(detail)\n return nil, connErr\n }\n\n // ... apply updates ...\n}\n```\n\nThe error detail mechanism is underused but powerful. You can attach structured metadata to errors, field validation details, retry-after hints, localized messages. The client gets typed error information, not just a string."},{"heading":"When to Reach for This Stack","level":2,"url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design#when-to-reach-for-this-stack","content":"gRPC + ConnectRPC is the right choice when:\n\n- Building service-to-service communication in a polyglot system\n- Streaming is required (real-time feeds, long-running operations)\n- The API surface is well-defined and won't change shape frequently\n- A single contract that generates clients for web, mobile, and server is desired\n\nREST remains the better choice when:\n\n- The API is public-facing and needs maximum compatibility\n- The team is small and doesn't want to learn protobuf\n- The API is CRUD-heavy with simple request/response patterns\n- Webhooks and third-party integrations are the primary consumers\n\ntRPC fits best when:\n\n- The entire stack is TypeScript (Next.js monorepo)\n- Speed of development matters more than cross-language support\n- The API is internal and the team is small\n\nThe tooling has matured enough that the \"gRPC is too complex\" argument doesn't hold anymore. ` buf ` makes proto management as easy as ` npm `. ConnectRPC makes browser clients trivial. The type safety and code generation payoff is real and compounds over time. Every new endpoint you add is a generated function call away from being usable, not a hand-written fetch wrapper with manual type assertions.\n\nStart with a ` .proto ` file. Everything else follows."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design#questions","content":""},{"heading":"Should I use gRPC or REST for a new API?","level":3,"url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design#question-1","content":"If your clients are all server-side (microservices, internal tools), gRPC wins on type safety, performance, and code generation. If you need browser clients, ConnectRPC gives you the gRPC developer experience with HTTP/JSON fallback. Pure public-facing REST APIs still make sense when you need maximum ecosystem compatibility."},{"heading":"How does ConnectRPC compare to tRPC?","level":3,"url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design#question-2","content":"tRPC is TypeScript-only and tightly coupled to the TS ecosystem. ConnectRPC generates clients for Go, TypeScript, Java, Swift, Kotlin, and more from a single .proto file. If you're polyglot or need mobile clients, ConnectRPC wins. If you're all-TypeScript monorepo, tRPC is simpler."},{"heading":"What about GraphQL?","level":3,"url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design#question-3","content":"GraphQL solves a different problem, flexible client-driven queries. gRPC solves RPC with strong contracts. If your clients need to query arbitrary combinations of fields across entities, GraphQL. If you have well-defined operations, gRPC/ConnectRPC."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design#article-tips","content":"1. **Tip:** Always use ` optional ` fields in proto3 instead of relying on zero-value defaults. Without ` optional `, you can't distinguish between 'field was set to 0' and 'field was not sent', this bites hard in PATCH-style updates.\n2. **Tip:** Enable gRPC reflection in all non-production environments. Tools like ` grpcurl ` and ` grpcui ` become useless without it, and your team will waste hours guessing at service definitions.\n3. **Tip:** ConnectRPC supports both gRPC and JSON over HTTP, use the JSON mode for browser clients and mobile debugging, and binary protobuf for server-to-server. Same service definition, zero code changes.\n4. **Tip:** When using streaming RPCs, always implement a ` keepalive ` ping. Load balancers (especially AWS ALB) will silently terminate idle HTTP/2 streams after 60 seconds by default.\n\n- grpc\n- protobuf\n- connectrpc\n- api-design\n- streaming\n- rpc\n- typescript\n- golang\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Español","url":"https://lucioduran.com/es/blog/grpc-protobuf-connectrpc-api-design"},{"text":"ConnectRPC","url":"https://connectrpc.com/"},{"text":"AWS","url":"https://aws.com/"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design"},"headline":"gRPC + Protobuf + ConnectRPC: Type-Safe Streaming APIs with Code Generation","description":"A practical guide to gRPC, Protocol Buffers, and ConnectRPC for building type-safe streaming APIs, with comparisons to REST and tRPC, reflection setup, load balancing considerations, and production-ready code.","datePublished":"2026-01-06","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://lucioduran.com/favicons/og-default.png","url":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"API Design","keywords":"grpc, protobuf, connectrpc, api-design, streaming, rpc, typescript, golang","wordCount":2006,"timeRequired":"PT11M","workTranslation":[{"@type":"BlogPosting","inLanguage":"es","url":"https://lucioduran.com/es/blog/grpc-protobuf-connectrpc-api-design"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"gRPC + Protobuf + ConnectRPC: Type-Safe Streaming APIs with Code Generation","item":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Should I use gRPC or REST for a new API?","acceptedAnswer":{"@type":"Answer","text":"If your clients are all server-side (microservices, internal tools), gRPC wins on type safety, performance, and code generation. If you need browser clients, ConnectRPC gives you the gRPC developer experience with HTTP/JSON fallback. Pure public-facing REST APIs still make sense when you need maximum ecosystem compatibility."}},{"@type":"Question","name":"How does ConnectRPC compare to tRPC?","acceptedAnswer":{"@type":"Answer","text":"tRPC is TypeScript-only and tightly coupled to the TS ecosystem. ConnectRPC generates clients for Go, TypeScript, Java, Swift, Kotlin, and more from a single .proto file. If you're polyglot or need mobile clients, ConnectRPC wins. If you're all-TypeScript monorepo, tRPC is simpler."}},{"@type":"Question","name":"What about GraphQL?","acceptedAnswer":{"@type":"Answer","text":"GraphQL solves a different problem, flexible client-driven queries. gRPC solves RPC with strong contracts. If your clients need to query arbitrary combinations of fields across entities, GraphQL. If you have well-defined operations, gRPC/ConnectRPC."}}]}]}],"contentHash":"95506c306d21f814b780640e7a570f8460ff469182b0ba5277d10b9e146dab82","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/grpc-protobuf-connectrpc-api-design","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":74924,"contentBytes":17995}}
