Streaming RPCs
Streams are the part of gRPC where it stops resembling REST. Server-streaming, client-streaming, and bidirectional — three shapes that change what kind of services you can build.
The four call shapes from chapter 1:
rpc Unary (Req) returns (Resp); // 1 → 1
rpc ServerStream(Req) returns (stream Resp); // 1 → N
rpc ClientStream(stream Req) returns (Resp); // N → 1
rpc BidiStream (stream Req) returns (stream Resp); // N ↔ M Each is one HTTP/2 stream. Each is first-class in the runtime — no fallback hacks, no SSE, no WebSocket polyfill. This chapter ships all three of the streaming shapes in Go, then walks the failure modes that bite you in production.
Real-World Analogy
gRPC streaming is like a live sports score feed versus refreshing the scoreboard page — the server pushes updates as they happen.
A streaming-flavored proto
// proto/feed/v1/feed.proto
syntax = "proto3";
package feed.v1;
option go_package = "example.com/mygrpc/gen/feed/v1;feedv1";
import "google/protobuf/timestamp.proto";
service FeedService {
// server-streaming: one query, many events
rpc Watch(WatchRequest) returns (stream Event);
// client-streaming: many writes, one ack
rpc UploadEvents(stream Event) returns (UploadResult);
// bidi: chat-like protocol
rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}
message WatchRequest { string topic = 1; }
message Event {
string id = 1;
string topic = 2;
string body = 3;
google.protobuf.Timestamp at = 4;
}
message UploadResult { int32 received = 1; }
message ChatMessage {
string from = 1;
string text = 2;
} After regenerating with the chapter-4 incantation, feedv1.FeedServiceServer exposes the streaming methods.
Server-streaming — one request, many responses
The most common streaming shape. A query that yields many results over time.
// Watch is a server-streaming RPC.
func (s *Server) Watch(req *pb.WatchRequest, stream pb.FeedService_WatchServer) error {
sub := s.broker.Subscribe(req.GetTopic())
defer s.broker.Unsubscribe(sub)
for {
select {
case ev := <-sub.Ch:
if err := stream.Send(&pb.Event{
Id: ev.ID,
Topic: ev.Topic,
Body: ev.Body,
At: timestamppb.New(ev.At),
}); err != nil {
return err
}
case <-stream.Context().Done():
return stream.Context().Err()
}
}
} Three things to read carefully.
1. The function returns error, not a response. All the data flows out via stream.Send(). Returning nil ends the stream cleanly; returning an error ends it with a status code (chapter 7).
2. stream.Context() is your lifeline. When the client disconnects (network drop, Ctrl-C, deadline expired), the context is canceled. You must select on it or the goroutine leaks, the broker subscription leaks, and your service slowly degrades.
3. stream.Send() blocks on flow control backpressure. If the client is slow, Send waits for the HTTP/2 window to open. That is a feature — it means a slow consumer naturally throttles the producer. Don’t try to “fix” it with goroutines that push into a channel; you reinvent backpressure badly.
Client side:
stream, err := client.Watch(ctx, &pb.WatchRequest{Topic: "alerts"})
if err != nil {
return err
}
for {
ev, err := stream.Recv()
if errors.Is(err, io.EOF) {
return nil // server closed the stream cleanly
}
if err != nil {
return err
}
fmt.Printf("[%s] %s\n", ev.Topic, ev.Body)
} stream.Recv() returns io.EOF when the server’s handler returned nil. Any other error is a real error.
Client-streaming — many requests, one response
Useful for bulk uploads or aggregations: the client streams chunks, the server gives a single result at the end.
func (s *Server) UploadEvents(stream pb.FeedService_UploadEventsServer) error {
var count int32
for {
ev, err := stream.Recv()
if errors.Is(err, io.EOF) {
return stream.SendAndClose(&pb.UploadResult{Received: count})
}
if err != nil {
return err
}
if err := s.repo.Insert(stream.Context(), ev); err != nil {
return status.Errorf(codes.Internal, "insert: %v", err)
}
count++
}
} Two pieces of vocabulary: stream.Recv() reads one client message; stream.SendAndClose(resp) ends the stream with the single response. Do not call Send then return — SendAndClose is the pair to client-streaming.
Client:
stream, err := client.UploadEvents(ctx)
if err != nil {
return err
}
for _, e := range events {
if err := stream.Send(e); err != nil {
return err
}
}
result, err := stream.CloseAndRecv()
if err != nil {
return err
}
fmt.Printf("uploaded %d\n", result.GetReceived()) CloseAndRecv signals “no more sends” and waits for the server’s single response. Always pair Send (client-stream) with CloseAndRecv.
Bidirectional — full duplex
Both sides send and receive freely. Order is per-stream, not coordinated between the two directions. The classic example is a chat:
func (s *Server) Chat(stream pb.FeedService_ChatServer) error {
for {
msg, err := stream.Recv()
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return err
}
// Echo with a server prefix; in real life, fan out to other subscribers.
reply := &pb.ChatMessage{
From: "server",
Text: "echo: " + msg.GetText(),
}
if err := stream.Send(reply); err != nil {
return err
}
}
} Client:
stream, err := client.Chat(ctx)
if err != nil {
return err
}
// Receive in a goroutine, send from main.
done := make(chan struct{})
go func() {
defer close(done)
for {
msg, err := stream.Recv()
if errors.Is(err, io.EOF) {
return
}
if err != nil {
log.Println("recv:", err)
return
}
fmt.Printf("[%s] %s\n", msg.GetFrom(), msg.GetText())
}
}()
for _, line := range []string{"hello", "world", "bye"} {
stream.Send(&pb.ChatMessage{From: "client", Text: line})
}
stream.CloseSend()
<-done stream.CloseSend() says “no more from me” while still allowing receives. The server’s Recv then returns io.EOF and the goroutine exits cleanly.
The send and receive directions are independent — read with one goroutine, write with another. Coordinate via channels if needed.
Bidi streams are easy to leak. A goroutine that calls Recv() and never sees io.EOF (because the server forgot to return) lives forever. Always pair: client CloseSend() → server Recv returns EOF → server returns → client Recv returns EOF → goroutine exits. Test the cancellation path by killing the client mid-stream and checking server logs for orphaned handlers.
Backpressure across streams
HTTP/2 flow control (chapter 3) gives you per-stream backpressure for free, with one nuance: each direction has its own window.
For server-streaming, if your producer is faster than the network or the client, stream.Send() blocks until the client’s window opens. This is what you want.
For client-streaming, if the server is slow to read, stream.Send() on the client blocks. Same dynamic, opposite direction.
For bidirectional, both windows operate independently. A slow receiver in one direction does not throttle the other.
The default HTTP/2 window is 64 KiB per stream. For high-throughput streams (logs, telemetry, video), raise it on both server and client (chapter 3 has the dial options). Without that, throughput tops out at one window per round-trip — a hard ceiling.
Cancellation patterns
Three places a stream can end:
- Server returns — handler done, stream closes cleanly. Client
Recvreturnsio.EOF. - Client cancels (
ctx.cancel()or deadline expired). Both sides’ctx.Done()fires; pendingSendandRecvreturncontext.Canceledorcontext.DeadlineExceeded. - Network died. TCP layer detects (eventually).
Send/Recvreturn a non-EOF error.
Your handler must handle all three. The pattern:
for {
select {
case <-stream.Context().Done():
return stream.Context().Err()
case ev := <-source:
if err := stream.Send(ev); err != nil {
return err
}
}
} Send returning an error usually means the stream is dead — break out, clean up.
Deadlines on streams
Setting a deadline on a streaming RPC means the whole stream must finish in that window:
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
stream, _ := client.Watch(ctx, req) // server-streaming Thirty seconds after client.Watch, the stream is forcibly closed. For long-lived streams (log tails, dashboards), use a much longer deadline — or none at all (context.Background()), and rely on cancellation when the consumer is done.
Streaming + tight deadlines is a common bug: a client meant to run for hours dies after one minute because the deadline was meant for unary calls.
Resumability — gRPC does not give it to you
If a server-streaming RPC dies mid-stream and the client reconnects, the new stream starts from the beginning of the new request — the server has no idea what the client already saw. gRPC has no built-in stream resumption.
If you need resumable streams, build it in the application layer:
- The server includes a
cursororseqfield in each event. - The client sends
WatchRequest{Topic: "alerts", AfterCursor: "abc"}on reconnect. - The server resumes from after that cursor.
This is the same pattern as event store readers. Build it once, use it everywhere streaming matters.
Multiplexing means cheap streams
Because every stream is just an HTTP/2 stream, opening a thousand of them on one connection is fine. There is no per-stream TCP handshake, no per-stream TLS work, no per-stream HTTP/1.1 head-of-line blocking.
That makes patterns possible that are awkward elsewhere:
- One long-lived stream per subscription topic, hundreds per client.
- Per-tab subscriptions for a real-time dashboard.
- Per-user agent connections that hold many streams indefinitely.
The cost is mostly memory (one buffer per stream) and GC pressure. Profile if you push past tens of thousands per process.
When not to use streaming
- The data is small and one-shot. Use unary; streaming is overhead.
- You need browser support without a proxy. gRPC-Web supports server-streaming but not client-streaming or bidirectional.
- The consumers are humans with curl. Streaming is hard to inspect by hand.
grpcurldoes it, but you lose the ad-hoc-friendly story. - The data is request-response with a delay. Use unary with a longer deadline. Streaming is for genuinely incremental data.
Most services have one or two genuinely streaming endpoints (logs, notifications, live updates) and a hundred unary RPCs. That is a healthy mix.
Recap
- Four shapes: unary, server-stream, client-stream, bidi. Each is one HTTP/2 stream.
- Server-streaming returns an error, sends data via
stream.Send(). Always select onstream.Context().Done(). - Client-streaming pairs
SendwithSendAndClose(server) andCloseAndRecv(client). - Bidi streams: send and receive independently.
CloseSendends the client direction. - Backpressure is HTTP/2 flow control. Raise windows for high-throughput streams.
- Cancellations end via context done. Handle all three end states (server return, cancel, network).
- Deadlines apply to whole streams — use long ones (or none) for long-lived streams.
- Resumability is your job. Cursors, server-side replay.
- Multiplexing makes many concurrent streams cheap. Use them where the data is genuinely incremental.
Next: Errors, deadlines, metadata — status codes, deadline propagation, and the headers gRPC carries on every call.