Using gRPC Bidirectional Streaming Without Dropping Idle Connections
Bidirectional gRPC streaming keeps one HTTP/2 connection open for two-way messages, but idle timeouts can close it silently.
12 Jul 2025, 07:42 UTC

The problem: real-time updates over a request that never ends
You need a client and server to exchange messages as they happen—chat messages, telemetry, or cursor positions in a collaborative editor. A unary gRPC call handles one request and one response, but the client would have to poll or open new connections for every update. That adds latency and head-of-line blocking.
gRPC bidirectional streaming lets both sides send a sequence of messages over one long-lived HTTP/2 connection. HTTP/2 is the protocol gRPC uses over TCP; it allows multiple streams to share a single connection. The trade-off is operational: long-lived streams are more likely to be killed by idle timeouts, load balancers, or proxy settings that assume short request-response lifecycles.
Takeaway: use bidirectional streaming when both sides need to push data without waiting for a request, but configure keep-alive and timeouts explicitly or the connection will drop silently.
Defining a bidirectional stream in Protocol Buffers
A .proto file marks both request and response as streams using the stream keyword:
syntax = "proto3";
package chat;
service ChatService {
rpc SendMessage(stream ChatMessage) returns (stream ChatMessage);
}
message ChatMessage {
string user = 1;
string text = 2;
int64 sent_at = 3;
}
The stream keyword on both sides means the client can send many messages and the server can send many messages, independently and interleaved, over a single HTTP/2 stream.
Worked example: server and client handlers
Generate language-specific stubs from the proto file. Run this in your project root:
protoc --go_out=. --go-grpc_out=. chat.proto
Required permissions: write access to the output directory. Required tools: protoc, the Go protobuf plugin, and the Go gRPC plugin. Risk: generated code overwrites existing files with the same package and service names.
On the server, the handler receives a stream object. It reads incoming messages in a loop and writes replies back:
func (s *server) SendMessage(stream chat.ChatService_SendMessageServer) error {
for {
msg, err := stream.Recv()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
// process or broadcast msg here
if err := stream.Send(&chat.ChatMessage{User: "server", Text: "ack"}); err != nil {
return err
}
}
}
On the client, open the stream once, then run a sender goroutine and a receiver goroutine:
stream, err := client.SendMessage(ctx)
// sender goroutine: stream.Send(msg)
// receiver goroutine: stream.Recv()
Expected check: messages sent by the client arrive at the server, and server replies arrive at the client without opening a new RPC call for each message. You can verify this with BloomRPC or grpcurl by opening a bidirectional call and observing interleaved messages.
Operational limits: keep-alive, proxies, and load balancing
Because the stream stays open, idle connections are the most common failure mode. Intermediate proxies often close TCP connections after a few minutes of silence. If your client stops sending but expects server pushes, the connection may close without either side noticing until the next send fails.
Configure client keep-alive so the transport sends HTTP/2 PING frames periodically:
conn, err := grpc.NewClient(
"localhost:50051",
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 15 * time.Second,
Timeout: 5 * time.Second,
PermitWithoutStream: true,
}),
)
PermitWithoutStream: true allows pings even when no application messages are in flight. Place this where the client creates its gRPC connection, typically during service startup. Risk: overly frequent keep-alive can waste bandwidth and may be rejected by some load balancers.
Load balancing is another limit. A long-lived stream usually stays pinned to one backend pod, so round-robin at connection setup does not spread message load evenly across pods. If one client streams heavily, that pod carries more load. Consider client-side load balancing or a service mesh that understands gRPC routing.
Memory usage grows with concurrent streams. Each open stream holds buffers and goroutine state. Set server-side limits on concurrent streams and message size to protect against runaway memory consumption.
Practical verification
- Open a bidirectional call with BloomRPC or grpcurl and send messages from both sides. Confirm messages arrive in near real time.
- Stop sending from the client for longer than your proxy’s idle timeout. Confirm the connection stays alive due to keep-alive pings, or observe the exact error code when it does close.
- Check server memory and goroutine count under sustained concurrent streams to validate your resource limits.
When to choose a different pattern
Use unary RPCs for request-response actions like creating a record or fetching a profile. Use server streaming when only the server pushes updates. Use bidirectional streaming when both sides need to send freely. If your infrastructure cannot support long-lived HTTP/2 connections or your load balancer does not pass gRPC headers correctly, fall back to polling unary calls or WebSockets.
Closing
Bidirectional streaming is powerful for low-latency, two-way communication, but it shifts complexity from the application protocol to network operations. Define the stream in proto, generate code, implement reader/writer loops, then tune keep-alive and timeouts before deploying to production.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.