gRPC Streaming RPCs in Go, Server, Client, and Bidirectional
TL;DR — gRPC has four RPC kinds: unary, server-streaming, client-streaming, bidi / Pick streaming when payload size or latency-sensitive event delivery forces your hand — not because it sounds cool / Streams are stateful goroutines on both ends; treat them like long-lived connections, not RPCs.
Most teams I’ve worked with reach for streaming RPCs before they need them, then spend two sprints unwinding the complexity. The trade-off is real: streams are stateful, they hold goroutines on both sides, they complicate retries, and they make load balancing harder. The benefit is also real: server-streaming for export endpoints, bidi for genuine real-time interactivity, client-streaming for chunked uploads. Each maps to a problem REST handles badly.
I’m building on the unary example from the gRPC basics post. Same billing.v1 package, same module layout. If you haven’t read that one and you’re new to the toolchain, start there.
This post covers each streaming kind with running code, the wire semantics that matter, and the failure modes I see most often in production.
The Four RPC Shapes
In the proto, the stream keyword on a side makes it streaming. Combine them four ways:
service ReportService {
// Unary: one request, one response
rpc GetSummary(SummaryRequest) returns (Summary);
// Server-streaming: one request, N responses
rpc ExportInvoices(ExportRequest) returns (stream Invoice);
// Client-streaming: N requests, one response
rpc BulkCreate(stream CreateInvoiceRequest) returns (BulkResult);
// Bidirectional: N requests, N responses, interleaved
rpc Reconcile(stream ReconcileEvent) returns (stream ReconcileAck);
}
A rough decision tree I use:
- Single request, single response, no incremental progress: unary. Default to this.
- Pagination over a large result set, or a long-running export: server-streaming.
- Large upload that doesn’t fit in one message, or per-record validation feedback isn’t needed: client-streaming.
- Interactive bidirectional flow (chat, presence, live reconciliation): bidi.
Bidi is the one people misuse most. If your bidi stream is really a series of independent request/response pairs, you wanted unary calls with a shared context, not bidi.
Server-Streaming: The Export Endpoint
Server-streaming maps cleanly to “give me everything that matches” without forcing pagination tokens onto the client. The server pushes messages until it’s done, then closes.
func (s *reportServer) ExportInvoices(
req *reportv1.ExportRequest,
stream reportv1.ReportService_ExportInvoicesServer,
) error {
ctx := stream.Context()
rows, err := s.repo.Iterate(ctx, req.GetCustomerId())
if err != nil {
return status.Errorf(codes.Internal, "iterate: %v", err)
}
defer rows.Close()
for rows.Next() {
if err := ctx.Err(); err != nil {
return status.FromContextError(err).Err()
}
inv, err := rows.Scan()
if err != nil {
return status.Errorf(codes.Internal, "scan: %v", err)
}
if err := stream.Send(inv); err != nil {
return err
}
}
return nil
}
Three things to internalize:
stream.Context() is your lifeline. Check it inside the loop. If the client disconnects, the context cancels, and your iteration should abort. I’ve seen export endpoints that ignore this and continue paging through Postgres for minutes after the client is gone, holding a connection from the pool the whole time.
stream.Send returns an error when the underlying HTTP/2 stream is closed. Don’t swallow it. Return it. The framework handles status code mapping.
The function returns when you’re done. There’s no “close” call. Returning nil signals successful completion; returning an error sends a non-OK status to the client.
On the client side:
stream, err := client.ExportInvoices(ctx, &reportv1.ExportRequest{CustomerId: "cust_123"})
if err != nil {
return err
}
for {
inv, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
return err
}
handle(inv)
}
io.EOF is the success signal. Any other error is a real error. Standard Go pattern, but new gRPC users always treat EOF as failure the first time.
Client-Streaming: The Bulk Upload
Less common, but useful when you want one transactional response after N inputs. The server reads until the client closes the stream, then sends a single response.
func (s *invoiceServer) BulkCreate(
stream billingv1.InvoiceService_BulkCreateServer,
) error {
var created int32
var totalCents int64
for {
req, err := stream.Recv()
if err == io.EOF {
return stream.SendAndClose(&billingv1.BulkResult{
CreatedCount: created,
TotalCents: totalCents,
})
}
if err != nil {
return err
}
inv, err := s.svc.Create(stream.Context(), req)
if err != nil {
return status.Errorf(codes.Internal, "create: %v", err)
}
created++
totalCents += inv.TotalAmountCents
}
}
The asymmetric API here trips people up: server uses SendAndClose, client uses CloseAndRecv. The naming makes sense once you internalize that the client is the one who decides when the input is done.
Bidirectional: When You Really Need It
Bidi streams let both sides send messages independently. The server can push without waiting for a request. The client can send without waiting for a response.
func (s *reconcileServer) Reconcile(
stream reconcilev1.ReconcileService_ReconcileServer,
) error {
ctx := stream.Context()
events := make(chan *reconcilev1.ReconcileEvent, 16)
g, gctx := errgroup.WithContext(ctx)
g.Go(func() error {
defer close(events)
for {
ev, err := stream.Recv()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
select {
case events <- ev:
case <-gctx.Done():
return gctx.Err()
}
}
})
g.Go(func() error {
for ev := range events {
ack, err := s.process(gctx, ev)
if err != nil {
return err
}
if err := stream.Send(ack); err != nil {
return err
}
}
return nil
})
return g.Wait()
}
A few non-obvious points:
A bidi stream isn’t goroutine-safe for concurrent Send from multiple goroutines. Same for Recv. If you want concurrent producers, funnel them through a channel and have one goroutine call Send. The gRPC-Go documentation is explicit about this and people miss it.
errgroup is the cleanest way to coordinate the two halves. When either fails, the context cancels and the other half unwinds. Don’t try to manage this with raw goroutines and sync.WaitGroup — you’ll get the cancellation semantics wrong.
Backpressure happens at the HTTP/2 flow-control level. If the client stops reading, eventually stream.Send will block. Don’t unbounded-buffer in your channel; the buffered channel above is sized at 16 deliberately.
Common Pitfalls
The things I see go wrong with streams, in rough order of frequency:
- Treating streams as fire-and-forget. Streams hold a goroutine, an HTTP/2 stream, and (often) a database resource. Always have a deadline on the parent context, even if it’s an hour. An unbounded stream is a leak waiting to happen.
- Calling
Sendfrom multiple goroutines. Already covered above. The race detector will catch it locally; production might tolerate it for days before something weird happens. - Ignoring
stream.Context(). The stream’s context is canceled when the peer goes away. If you’re not checking it, you’re computing for nobody. - Server-streaming when pagination would do. A streaming endpoint can’t be cached, is harder to load balance, and complicates retries. If clients can resume from a cursor, pagination is usually the right call.
- Bidi for non-interactive flows. If your bidi stream sends one request and receives one response per “logical operation,” you wanted N unary RPCs over the same connection. The connection is already multiplexed.
- Forgetting
MaxRecvMsgSize/MaxSendMsgSize. Default is 4 MB. Cross that and you get cryptic resource-exhausted errors. Set explicitly viagrpc.MaxRecvMsgSize(n)on either side when you know your payloads.
Wrapping Up
Streams give you primitives that REST genuinely can’t match — but they cost more to operate. My rule: start unary, move to server-streaming when the user-facing latency of buffering a whole result becomes a problem, move to bidi only when you have a real interactive flow. The next post in this series covers concurrency patterns on the server side, which is where streaming code lives or dies. After that I’ll get into deadlines and context propagation, which is the actual hardest thing about distributed Go services.