Bidirectional Streaming in gRPC with .NET
1. What is Bidirectional Streaming?
In a bidirectional (bidi) streaming call, both the client and the server can send multiple messages simultaneously. The two streams operate independently — the server does not need to wait for the client to finish sending, and vice versa.
Think of bidirectional streaming like a phone call. Both people can speak and listen at the same time. You do not take turns like in a formal debate — the conversation flows freely in both directions simultaneously.
| Call Type | Client Sends | Server Responds | Simultaneous? |
|---|---|---|---|
| Unary | 1 message | 1 message | No |
| Client Streaming | Many messages | 1 message | No (server waits) |
| Server Streaming | 1 message | Many messages | No (client waits) |
| Bidirectional Streaming | Many messages | Many messages | Yes — both at once |
Common use cases:
- Real-time chat applications
- Multiplayer game state synchronization
- Long-running jobs with progress and metadata exchange
- Interactive data processing pipelines
2. Defining Bidirectional Streaming in Proto
To declare a bidirectional streaming RPC, add the stream keyword before both the request and the response type.
The rule is simple: both stream HelloRequest and stream HelloReply have the stream keyword, making this a bidirectional streaming call.
3. Server Implementation
On the server side (GrpcService1/Services/GreeterService.cs), the method receives both a reader and a writer: an IAsyncStreamReader<T> for incoming client messages, and an IServerStreamWriter<T> for outgoing responses.
This is an "echo" pattern: for every message the client sends, the server immediately sends one message back. This is the simplest bidirectional pattern.
4. How the Server Handles Both Streams
The server's two streams do not have to be locked in a one-for-one exchange. There are several patterns depending on your use case:
| Pattern | Description | Example Use Case |
|---|---|---|
| Echo (one-for-one) | Read one request, write one response, repeat | Real-time translation or processing |
| Read all then respond | Collect all requests first, then write responses | Batch processing with results |
| Independent streams | Read and write on separate concurrent tasks | Chat systems, monitoring dashboards |
The echo pattern (reading then immediately writing) is the most straightforward to implement. For truly independent streams, use Task.Run to run reading and writing concurrently.
5. Client Implementation
On the client side (BasicGrpcClient/Program.cs), calling a bidirectional streaming method returns an AsyncDuplexStreamingCall<HelloRequest, HelloReply>. The client has both a request stream (to send) and a response stream (to read).
The challenge is that both streams run at the same time. If you try to read and write in sequence, you may deadlock — the client waits for a response while the server waits for more requests.
The solution is to use two concurrent tasks: one for reading, one for writing.
6. The Background Reader Pattern
The standard pattern for handling bidirectional streaming on the client is: start a background task to read responses, then write requests on the main thread, and finally close the stream and wait for the reader to finish.
Think of it like two workers in an assembly line. One worker (background reader) picks up completed items from the output tray continuously. Another worker (main thread) puts new items into the input tray. Both work at the same time — they do not wait for each other.
The key insight: CompleteAsync() closes the sending side. The response stream stays open until the server finishes writing its responses. await readTask ensures we wait for all responses to be received before moving on.
7. The AsyncDuplexStreamingCall Object
client.Chat() returns an AsyncDuplexStreamingCall<HelloRequest, HelloReply>. This object combines the features of both AsyncClientStreamingCall and AsyncServerStreamingCall.
| Property / Method | Type | What it does |
|---|---|---|
.RequestStream | IClientStreamWriter<HelloRequest> | Used to send messages to the server |
.RequestStream.WriteAsync(msg) | Task | Sends one message to the server |
.RequestStream.CompleteAsync() | Task | Closes the send side — tells server no more messages |
.ResponseStream | IAsyncStreamReader<HelloReply> | Used to read messages from the server |
.ResponseStream.ReadAllAsync() | IAsyncEnumerable<HelloReply> | Reads all server messages asynchronously |
.ResponseStream.MoveNext() | Task<bool> | Manual read — returns false when server closes stream |
.ResponseHeadersAsync | Task<Metadata> | Response headers from the server |
8. Closing the Call Gracefully
Proper shutdown of a bidirectional streaming call requires these steps in order:
- Stop writing — Call
await call.RequestStream.CompleteAsync(). This signals to the server that the client will not send any more messages. - Wait for the server to finish — The server, upon seeing the request stream close, finishes its work and closes the response stream.
- Wait for the reader task — Call
await readTaskto ensure all server responses have been received and processed.
Do not call await readTask before CompleteAsync(). If you do, the readTask loop will block forever because the server is also waiting for more client messages before it finishes writing.
9. Handling Deadlines
Deadlines work for bidirectional streaming just like for server streaming. You pass the deadline when opening the call:
If the deadline expires while the call is in progress:
- The client's
ReadAllAsync()orMoveNext()throws anRpcExceptionwithStatusCode.DeadlineExceeded. - On the server,
context.CancellationTokenis triggered.
10. Full Working Example
GrpcService1/Services/GreeterService.cs
BasicGrpcClient/Program.cs
Expected output:
Note: because reading and writing happen concurrently, the exact order of "Sending:" and "Server says:" lines may interleave slightly differently depending on timing.
Summary
- Bidirectional streaming uses the
streamkeyword on both the request and response in the proto. - The server receives both
IAsyncStreamReader<TRequest>andIServerStreamWriter<TResponse>. - The client receives
AsyncDuplexStreamingCall<TRequest, TResponse>with both.RequestStreamand.ResponseStream. - Use the background reader pattern: start a
Task.Runto read responses, write requests on the main thread. - Always call
RequestStream.CompleteAsync()when done sending, thenawait readTaskto drain remaining responses. - Never
await readTaskbeforeCompleteAsync()— this causes a deadlock. - Pass a
deadlineparameter when opening the call to limit total call duration. - Catch
RpcExceptionwithStatusCode.DeadlineExceededto handle timeout gracefully.