gRPC Streaming Patterns — Server-Side, Client-Side, and Bidirectional Best Practices
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
- 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.
- Not handling stream cancellation — when a client disconnects, the stream context is canceled. Check ctx.Err() before sending the next message.
- 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.
- Reusing stream objects — each stream is a one-time use object. Create a new stream for each logical session.
- Not setting max message size — streaming large messages requires increasing maxReceiveMessageSize and maxSendMessageSize.
Practice Questions
- When would you choose server-streaming over paginated unary calls?
- How does flow control work in gRPC bidirectional streams?
- What happens when a client disconnects mid-stream?
- How do you handle partial failures in client-streaming RPCs?
- 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
What's Next
Learn more about bidirectional streaming
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro