gRPC in Go: The Service That Doesn't Need a Microservice Framework
The Java gRPC story is a framework: an @Grpc annotation, a Service base class, a Client interface, a config file. The Go story is the opposite, and it is the reason gRPC in Go feels like using stdlib: gRPC is a conn + a generated stub. There is no GrpcClient base class to configure, no service registry to wire. The service is a struct that implements a generated interface; the client is two lines.
This post is the whole round trip: a 12-line .proto (the contract), a server with a unary RPC and a bidirectional stream, and a client that drives both. And the one thing you have to get right in Go gRPC: the Send/Recv pairing in a stream — get it wrong and your Send blocks on a full buffer and the service hangs with 0% CPU, the classic “it works in the demo” failure.
By the end: a working gRPC service with no framework, no DI container, no micro package — just net.Listen, grpc.NewServer, and a stub.
The contract: 12 lines of .proto
| |
That is the entire contract. No annotations, no base class — protoc emits an interface (EchoServer) and a stub (NewEchoClient) from it, and Go’s compile checker enforces the rest.
The server
| |
The client
| |
The stream lifecycle: the one thing you have to get right
The stream above is a two-phase state machine. The failure mode is always the same: someone Sends without a matching Recv, the stream buffer (a few hundred KB) fills, the Send blocks, and the goroutine sits at 0% CPU forever. The fix is not a bigger buffer; it is keeping the pairing in one loop.
sequenceDiagram
participant C as Client
participant T as HTTP/2 (one conn, many streams)
participant S as Server handler (one goroutine per stream)
C-->>T: Stream() open
T-->>S: Recv #1 --> msg-0
S-->>T: Send #1 --> msg-0
T-->>C: msg-0 (client reader prints it)
C-->>T: Recv #2 --> msg-1, Send #2 --> msg-1
C-->>T: Recv #3 --> msg-2, Send #3 --> msg-2
C-->>T: CloseSend() (half-close)
S-->>T: Recv returns io.EOF
S-->>T: status OK
T-->>C: stream closed cleanly
stateDiagram-v2
[*] --> Open: Stream()
Open --> Streaming: client Sends / server Recvs
Streaming --> Streaming: matched Send/Recv pairs
Streaming --> HalfClosed: client CloseSend()
HalfClosed --> Done: server drains (Recv --> io.EOF)
Streaming --> Failed: Send/Recv error (deadline, reset)
Done --> [*]
Failed --> [*]
The three rules, in order of how often they bite:
Recvfirst, thenSend, in one loop — on the server. Your handler is a pump: for every message the client sends, you echo one back. Theio.EOFfromRecvis the only clean exit.- The client half-closes with
CloseSend(), not by closing the conn. A closed conn is an error; a half-closed stream is the normal “I’m done sending, finish up” signal. - Every call takes
ctxas its first argument and honors it. A cancelled context is how a client stops a stream mid-flight — if your handler ignores it, the stream leaks goroutines until the deadline fires.
Note:
grpc.NewClient(1.63+) dials lazily; the oldgrpc.Dialis deprecated. If you are on an older release,grpc.Dialworks identically here — the code in this post does not depend on anything newer.
Warning: do not
Sendfrom more than one goroutine on a stream without a mutex — the generatedSendis not concurrency-safe. One writer per stream (or a small writer goroutine fed by a channel) is the whole synchronization story.
gRPC vs REST/JSON: when each wins
| gRPC (this post) | REST/JSON | |
|---|---|---|
| Wire format | Binary protobuf, length-prefixed frames | Text JSON |
| Contract | .proto → generated types, enforced at compile time | OpenAPI spec, enforced at runtime |
| Streaming | Bidirectional, first-class | Bolted on (WebSocket/SSE) |
| Multiplexing | One HTTP/2 conn, many concurrent streams | One connection per request (HTTP/1.1) |
| Browser support | No (CORS, binary, no fetch) | Native |
| Tooling | protoc + plugins, grpcurl, reflection | curl, everything |
Rule of thumb: gRPC for service-to-service where both ends are Go (or any language with a protoc plugin); REST when a browser or a curl is on the other end. A gateway (Envoy, grpc-gateway) translates at the boundary — you do not choose, the boundary does.
Takeaways
- In Go, gRPC is not a framework. It is a
conn+ a stub:grpc.NewServer,Register*Server,New*Client, done. The 30-line service is the whole story. - The
.protois the contract. protoc generates the interface and the stub; Go’s compiler enforces both. No annotations, no DI. - Streams are a pairing problem.
Recv→Sendin one loop,CloseSendfor the half-close,io.EOFfor the end. Break the pairing and the buffer fills and the goroutine blocks at 0% CPU. ctxis the cancellation contract. Every method takes it, everySend/Recvhonors it.- The only framework you need is the transport — and it is stdlib underneath the stub.
Next steps
- Add
grpcurlto your tooling (grpcurl -d '{"msg":"hi"}" localhost:50051 echo.Echo/Echo) — it is the curl of gRPC and removes 90% of “can I hit this from a shell?” - Enable the reflection service (
reflection.Register) for local dev; disable it in production builds. - Add an interceptor for logging/tracing — one
grpc.UnaryServerInterceptorwithotelgrpcis the standard shape; it is the only “framework” a gRPC service in Go typically adopts, and it is 20 lines.