gRPC’s streaming modes are underused. Most teams use unary (request-response) and never explore the streaming shapes that map cleanly to live updates, uploads, and bidirectional RPC. This post is the working guide.
The four modes
service OrderService {
rpc CreateOrder (CreateOrderRequest) returns (Order); // unary
rpc StreamUpdates (Filter) returns (stream OrderEvent); // server-stream
rpc UploadEvents (stream Event) returns (UploadResult); // client-stream
rpc Watch (stream WatchRequest) returns (stream WatchResponse); // bidi
}
Each maps to a distinct use case.
Server-streaming — live updates
The killer use case. Client opens a stream; server pushes events as they happen.
func (s *OrderServer) StreamUpdates(req *pb.Filter, stream pb.OrderService_StreamUpdatesServer) error {
sub := s.broker.Subscribe(req.UserId)
defer sub.Close()
for {
select {
case ev := <-sub.Events():
if err := stream.Send(ev); err != nil {
return err // client disconnected
}
case <-stream.Context().Done():
return stream.Context().Err()
}
}
}
Replaces SSE for service-to-service. See SSE vs WebSockets in 2026 for the browser-side equivalent.
Client-streaming — uploads
Client streams many messages; server returns one summary.
func (s *Server) UploadEvents(stream pb.Service_UploadEventsServer) error {
n := 0
for {
ev, err := stream.Recv()
if err == io.EOF {
return stream.SendAndClose(&pb.UploadResult{Count: int32(n)})
}
if err != nil { return err }
if err := s.persist(ev); err != nil { return err }
n++
}
}
Useful for bulk ingest where you want one ack at the end.
Bidi streaming — true conversation
Both sides send and receive concurrently. Each side reads in its own goroutine; the streams are independent in-flight.
func (s *Server) Watch(stream pb.Service_WatchServer) error {
requests := make(chan *pb.WatchRequest)
go func() {
defer close(requests)
for {
req, err := stream.Recv()
if err != nil { return }
requests <- req
}
}()
for {
select {
case req, ok := <-requests:
if !ok { return nil }
// start a watch for req
case ev := <-events:
if err := stream.Send(ev); err != nil { return err }
case <-stream.Context().Done():
return stream.Context().Err()
}
}
}
Bidi is the right shape for chat, real-time game state, agent interactions where both sides drive.
Deadlines
Always set them client-side:
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
stream, err := client.StreamUpdates(ctx, &pb.Filter{...})
The deadline propagates through the stream and to downstream calls (gRPC is built for it). When the deadline expires, the server’s stream.Context().Done() fires; clean up.
Keep-alive
For long-lived streams:
keepalive := keepalive.ClientParameters{
Time: 10 * time.Second,
Timeout: 5 * time.Second,
PermitWithoutStream: true,
}
conn, _ := grpc.NewClient(addr,
grpc.WithKeepaliveParams(keepalive),
grpc.WithTransportCredentials(creds))
Detects dead peers (NAT timeouts, network drops). Without it, streams can hang for hours waiting on a TCP that’s been silently broken.
Error handling
resp, err := stream.Recv()
if err != nil {
if errors.Is(err, io.EOF) {
// normal end
}
s, ok := status.FromError(err)
if ok {
switch s.Code() {
case codes.Canceled: // client cancelled
case codes.DeadlineExceeded:
case codes.Unavailable: // network / server issue
case codes.ResourceExhausted: // backpressure
}
}
return err
}
The status codes are precise. Map each to a behavior.
Backpressure
The streaming sides have built-in HTTP/2 flow control. If the consumer is slow, gRPC’s flow control slows the producer. Don’t fight it; let it work.
If you need explicit backpressure (drop oldest, drop newest), buffer outside the stream:
// Bounded buffer; drop oldest if full
buf := newRingBuffer(1000)
for ev := range source {
buf.Push(ev) // drops oldest if full
}
go func() {
for ev := buf.Iter() {
stream.Send(ev)
}
}()
Connect — gRPC for the browser
Connect supports streaming over HTTP/2 with browser-friendly fallbacks. For services that need both backend gRPC and browser streaming, Connect is the one server. See Go + gRPC + Protocol Buffers and GraphQL vs tRPC vs gRPC vs REST .
Common mistakes
1. No deadlines
Streams hang forever when peers disconnect ungracefully. Always set deadlines.
2. Forgetting stream.Context().Done()
The server doesn’t know the client disconnected unless it checks the context. Loops without a context check leak goroutines.
3. Sending from many goroutines
stream.Send is not safe for concurrent calls. Funnel sends through a single goroutine + channel.
4. No keep-alive on long streams
A 4-hour stream that silently breaks at minute 47 needs keep-alive to detect.
5. Logging every message
Streams can be high-volume. Per-message logs DDoS your log aggregator.
Read this next
- Go + gRPC + Protocol Buffers
- GraphQL vs tRPC vs gRPC vs REST
- SSE vs WebSockets in 2026
- Distributed Systems Fundamentals
If you want a Go server demonstrating all four gRPC modes, it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .