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

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 .