Bidirectional streaming in gRPC lets a client and server send messages to each other concurrently over a single, long-lived connection, blurring the lines of who’s initiating the communication.
Let’s see it in action. Imagine a chat application. A client connects to a server and can send messages. Simultaneously, the server can push new messages to that same client without the client having to ask for them. This isn’t just two separate one-way streams; it’s a single, duplex channel.
Here’s a simplified .proto definition for such a service:
syntax = "proto3";
package chat;
service ChatService {
rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}
message ChatMessage {
string sender = 1;
string message = 2;
int64 timestamp = 3;
}
The stream ChatMessage on both the client and server sides of Chat is the magic. It signifies that the RPC method will be dealing with a sequence of messages in both directions.
On the server-side (e.g., in Go):
func (s *chatServer) Chat(stream pb.ChatService_ChatServer) error {
// Goroutine to receive messages from the client
go func() {
for {
req, err := stream.Recv()
if err == io.EOF {
return // Client closed the stream
}
if err != nil {
log.Printf("Error receiving from client: %v", err)
return
}
log.Printf("Received from client: %s: %s", req.Sender, req.Message)
// Process the received message...
}
}()
// Goroutine to send messages to the client
for {
// Simulate receiving a message to send to the client
// In a real app, this would come from a message queue, another client, etc.
msgToSend := &pb.ChatMessage{
Sender: "Server",
Message: "Hello from the server!",
Timestamp: time.Now().Unix(),
}
if err := stream.Send(msgToSend); err != nil {
log.Printf("Error sending to client: %v", err)
return err
}
time.Sleep(5 * time.Second) // Send periodically
}
}
And on the client-side (e.g., in Go):
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client, err := pb.NewChatServiceClient(conn).Chat(ctx)
if err != nil {
log.Fatalf("could not start chat stream: %v", err)
}
// Goroutine to send messages to the server
go func() {
for i := 0; i < 3; i++ {
msgToSend := &pb.ChatMessage{
Sender: "Client",
Message: fmt.Sprintf("Hi there! Message %d", i),
Timestamp: time.Now().Unix(),
}
if err := client.Send(msgToSend); err != nil {
log.Printf("Error sending to server: %v", err)
return
}
log.Printf("Sent to server: %s", msgToSend.Message)
time.Sleep(2 * time.Second)
}
client.CloseSend() // Signal that we're done sending
}()
// Receive messages from the server
for {
resp, err := client.Recv()
if err == io.EOF {
log.Println("Server closed the stream.")
return
}
if err != nil {
log.Printf("Error receiving from server: %v", err)
return
}
log.Printf("Received from server: %s: %s", resp.Sender, resp.Message)
}
}
The core mechanism is that stream.Recv() and stream.Send() can be called independently and in any order on both ends of the connection, as long as the underlying RPC is active. The server doesn’t need to wait for a client request to send data, and the client doesn’t need to wait for a server response to send data. This allows for true, simultaneous, full-duplex communication.
The problem this solves is enabling real-time, interactive applications where data flows in both directions without the overhead of constant request-response cycles or managing multiple connections. Think live dashboards, collaborative editing, or real-time gaming.
The most surprising thing is how gRPC handles the underlying HTTP/2 frames. When you Send a message, gRPC serializes it and writes it to the HTTP/2 connection as a DATA frame. When you Recv, it’s reading DATA frames from the same connection. The HTTP/2 protocol itself supports multiplexing streams over a single TCP connection, and gRPC leverages this to create the illusion of two independent message flows over one logical RPC call. The stream keyword in the .proto file tells the gRPC tooling to generate client and server stubs that manage this duplex communication, abstracting away the frame-level details.
The CloseSend() method is critical on the client side. When called, it signals to the server that the client will not send any more messages. The server, upon receiving this signal (which manifests as an io.EOF error on its Recv() call), can then know that the client-side sending is complete. The server can then continue sending its own messages until it decides to close the entire RPC, which the client will then see as an io.EOF on its Recv().
The next concept you’ll likely encounter is how to handle graceful shutdown of these bidirectional streams, ensuring no messages are lost when either the client or server decides to terminate the connection.