Skip to content

gRPC Streaming Patterns — Server-Side, Client-Side, and Bidirectional Best Practices

DodaTech Updated 2026-06-28 4 min read

In this tutorial, you will learn about grpc streaming patterns. We cover key concepts, practical examples, and best practices to help you master this topic.

gRPC streaming patterns enable efficient data transfer for large datasets (server-streaming), batch submissions (client-streaming), and real-time exchanges (bidirectional streaming) beyond simple request-response unary calls.

What You'll Learn

  • When to use each streaming pattern
  • Server-side streaming for large result sets
  • Client-side streaming for batch uploads
  • Bidirectional streaming for real-time communication
  • Error handling and flow control in streams
  • Graceful shutdown and cancellation

Why It Matters

Unary RPCs don't scale for large data transfers. Streaming reduces memory usage, enables real-time communication, and provides flow control. DodaTech's Durga Antivirus Pro uses server-streaming to send 10 million device status updates, client-streaming for batch threat report uploads, and bidirectional streaming for real-time threat alerts.

Real-World Use

A security agent on 10,000 devices sends heartbeat messages every 5 seconds via a bidirectional stream. The server acknowledges each heartbeat and sends back configuration updates when needed. This pattern uses a single TCP connection per device instead of a new connection every 5 seconds.

flowchart TB
    subgraph Patterns
        A["Unary\nReq → Resp"] 
        B["Server-Stream\nReq → Stream"]
        C["Client-Stream\nStream → Resp"]
        D["Bidirectional\nStream → Stream"]
    end
    A --> E["Simple queries"]
    B --> F["Large datasets\n(device list, logs)"]
    C --> G["Batch uploads\n(threat reports)"]
    D --> H["Real-time comm\n(alerts, chat)"]
    style D fill:#dbeafe,stroke:#2563eb

Code Examples

Example 1: Server-Side Streaming in Go

package main

func (s *server) ListThreats(req *pb.ThreatListRequest,
    stream pb.ThreatService_ListThreatsServer) error {
    
    // Query database in batches
    cursor := db.Threats.Find(context.Background(), bson.M{
        "severity": req.Severity,
    })
    defer cursor.Close(context.Background())
    
    batchSize := 100
    var batch []*pb.Threat
    
    for cursor.Next(context.Background()) {
        var threat Threat
        if err := cursor.Decode(&threat); err != nil {
            return err
        }
        
        batch = append(batch, threat.ToProto())
        
        if len(batch) >= batchSize {
            if err := stream.Send(&pb.ThreatListResponse{
                Threats: batch,
            }); err != nil {
                return err
            }
            batch = batch[:0]
        }
    }
    
    // Send remaining
    if len(batch) > 0 {
        return stream.Send(&pb.ThreatListResponse{
            Threats: batch,
        })
    }
    
    return nil
}

Example 2: Client-Side Streaming in Python

def upload_threat_reports(stub, reports):
    def generate_reports():
        for report in reports:
            yield pb.ThreatReport(
                device_id=report.device_id,
                threat_name=report.threat_name,
                severity=report.severity,
                raw_data=report.raw_data,
            )
    
    # Send all reports, receive single summary
    response = stub.UploadReports(generate_reports())
    print(f"Uploaded {response.total_reports} reports")
    print(f"Threats found: {response.threats_found}")
    
    # Handle partial failures
    if response.failed_reports:
        print(f"Failed reports: {response.failed_reports}")

# Client with progress tracking
class ProgressReportingUploader:
    def __init__(self, stub, total_reports):
        self.stub = stub
        self.total = total_reports
        self.uploaded = 0
    
    def upload(self, reports):
        def generate():
            for report in reports:
                yield report.ToProto()
                self.uploaded += 1
                print(f"Progress: {self.uploaded}/{self.total}")
        
        return self.stub.UploadReports(generate())

Example 3: Bidirectional Streaming in Node.js

const grpc = require('@grpc/grpc-js');

class HeartbeatServer {
  handleHeartbeat(call) {
    console.log('Client connected for heartbeats');
    
    call.on('data', (heartbeat) => {
      console.log(`Heartbeat from ${heartbeat.deviceId}`);
      
      // Send acknowledgment
      call.write({
        deviceId: heartbeat.deviceId,
        acknowledged: true,
        timestamp: Date.now(),
        configUpdate: this.checkForUpdates(heartbeat.deviceId),
      });
    });
    
    call.on('end', () => {
      console.log('Client disconnected');
      call.end();
    });
    
    call.on('error', (error) => {
      console.error('Stream error:', error.message);
    });
  }
  
  checkForUpdates(deviceId) {
    // Check if there are pending config changes
    return null; // or return { updateConfig: true }
  }
}

// Server setup
const server = new grpc.Server();
server.addService(HeartbeatService.service, {
  heartBeat: (call) => new HeartbeatServer().handleHeartbeat(call),
});

Common Mistakes

  1. Blocking on stream.Send — if the client isn't reading fast enough, stream.Send blocks. Use a buffered channel or drop messages with a timeout.
  2. Not handling stream cancellation — when a client disconnects, the stream context is canceled. Check ctx.Err() before sending the next message.
  3. Sending too many messages too fast — without flow control, a fast server can overwhelm a slow client. Implement backpressure using gRPC's built-in flow control.
  4. Reusing stream objects — each stream is a one-time use object. Create a new stream for each logical session.
  5. Not setting max message size — streaming large messages requires increasing maxReceiveMessageSize and maxSendMessageSize.

Practice Questions

  1. When would you choose server-streaming over paginated unary calls?
  2. How does flow control work in gRPC bidirectional streams?
  3. What happens when a client disconnects mid-stream?
  4. How do you handle partial failures in client-streaming RPCs?
  5. Why is buffering important in server-streaming implementations?

Challenge: Design a bidirectional streaming protocol for a threat alert system where the server sends real-time alerts and the client sends acknowledgments and status updates. Include reconnection logic, message ordering, and flow control.

Mini Project

Build a bidirectional streaming heartbeat system for 10,000 IoT devices running Durga Antivirus Pro agents. Each device sends heartbeats every 5 seconds, the server acknowledges and pushes config updates, and the system handles reconnection and backpressure.

FAQ

How many messages can a gRPC stream handle?

There's no hard limit, but performance depends on message size and network. Typical production streams handle thousands of messages per second.

What happens if a streaming message exceeds the size limit?

gRPC returns a ResourceExhausted error. Configure maxSendMessageSize and maxReceiveMessageSize for large messages (default 4MB).

Can I use streaming with HTTP/1.1?

No. gRPC requires HTTP/2 for streaming. HTTP/2 provides multiplexed streams, flow control, and server push that HTTP/1.1 lacks.

How do I test bidirectional streaming?

Create a mock server that echoes messages back, start it on bufconn, and verify the client receives responses in the correct order.

When should I close a streaming connection?

Close the stream when: the client disconnects, the server is shutting down, an unrecoverable error occurs, or the session times out.

What's Next

Learn more about bidirectional streaming

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro