gRPC With. Net Call Types Supported by gRPC Created: 26 Mar 2026 Updated: 26 Mar 2026

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 TypeClient SendsServer RespondsSimultaneous?
Unary1 message1 messageNo
Client StreamingMany messages1 messageNo (server waits)
Server Streaming1 messageMany messagesNo (client waits)
Bidirectional StreamingMany messagesMany messagesYes — both at once

Common use cases:

  1. Real-time chat applications
  2. Multiplayer game state synchronization
  3. Long-running jobs with progress and metadata exchange
  4. 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.

syntax = "proto3";

option csharp_namespace = "BasicGrpcService";

package greet;

service Greetings {
// Unary — no stream
rpc SayHello (HelloRequest) returns (HelloReply);

// Client streaming — stream on request only
rpc SendMultipleHellos (stream HelloRequest) returns (HelloReply);

// Server streaming — stream on response only
rpc GetMultipleReplies (HelloRequest) returns (stream HelloReply);

// Bidirectional streaming — "stream" on BOTH sides
rpc Chat (stream HelloRequest) returns (stream HelloReply);
}

message HelloRequest {
string name = 1;
}

message HelloReply {
string message = 1;
}

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.

using BasicGrpcService;
using Grpc.Core;

namespace GrpcService1.Services
{
public class GreeterService(ILogger<GreeterService> logger) : Greetings.GreetingsBase
{
// Bidirectional streaming: both sides send streams simultaneously
public override async Task Chat(
IAsyncStreamReader<HelloRequest> requestStream,
IServerStreamWriter<HelloReply> responseStream,
ServerCallContext context)
{
// For each message received from the client, immediately echo it back
await foreach (var request in requestStream.ReadAllAsync())
{
logger.LogInformation("Chat message from: {Name}", request.Name);

await responseStream.WriteAsync(new HelloReply
{
Message = "Echo: " + request.Name
});
}
// When the client closes its request stream, this method returns,
// which closes the server's response stream automatically.
}
}
}

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:

PatternDescriptionExample Use Case
Echo (one-for-one)Read one request, write one response, repeatReal-time translation or processing
Read all then respondCollect all requests first, then write responsesBatch processing with results
Independent streamsRead and write on separate concurrent tasksChat 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.
using var call = client.Chat(deadline: DateTime.UtcNow.AddSeconds(30));

// STEP 1: Start a background task to continuously read server responses
var readTask = Task.Run(async () =>
{
await foreach (var reply in call.ResponseStream.ReadAllAsync())
{
Console.WriteLine("Server says: " + reply.Message);
}
Console.WriteLine("Response stream ended.");
});

// STEP 2: Send messages on the main thread (or current async context)
await call.RequestStream.WriteAsync(new HelloRequest { Name = "Ahmet" });
await call.RequestStream.WriteAsync(new HelloRequest { Name = "Ayse" });
await call.RequestStream.WriteAsync(new HelloRequest { Name = "Mehmet" });

// STEP 3: Signal that we are done sending
await call.RequestStream.CompleteAsync();

// STEP 4: Wait for the background reader to finish consuming all responses
await readTask;

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 / MethodTypeWhat it does
.RequestStreamIClientStreamWriter<HelloRequest>Used to send messages to the server
.RequestStream.WriteAsync(msg)TaskSends one message to the server
.RequestStream.CompleteAsync()TaskCloses the send side — tells server no more messages
.ResponseStreamIAsyncStreamReader<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
.ResponseHeadersAsyncTask<Metadata>Response headers from the server

8. Closing the Call Gracefully

Proper shutdown of a bidirectional streaming call requires these steps in order:

  1. Stop writing — Call await call.RequestStream.CompleteAsync(). This signals to the server that the client will not send any more messages.
  2. Wait for the server to finish — The server, upon seeing the request stream close, finishes its work and closes the response stream.
  3. Wait for the reader task — Call await readTask to ensure all server responses have been received and processed.
// First: close the request stream
await call.RequestStream.CompleteAsync();

// Then: wait for the reader background task to drain all remaining responses
await readTask;

// At this point, all responses have been received and the call is complete
Console.WriteLine("Call completed successfully.");

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:

using var call = client.Chat(deadline: DateTime.UtcNow.AddSeconds(30));

If the deadline expires while the call is in progress:

  1. The client's ReadAllAsync() or MoveNext() throws an RpcException with StatusCode.DeadlineExceeded.
  2. On the server, context.CancellationToken is triggered.
var readTask = Task.Run(async () =>
{
try
{
await foreach (var reply in call.ResponseStream.ReadAllAsync())
{
Console.WriteLine("Server says: " + reply.Message);
}
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.DeadlineExceeded)
{
Console.WriteLine("Deadline exceeded — stopped reading responses.");
}
});

10. Full Working Example

GrpcService1/Services/GreeterService.cs

using BasicGrpcService;
using Grpc.Core;

namespace GrpcService1.Services
{
public class GreeterService(ILogger<GreeterService> logger) : Greetings.GreetingsBase
{
public override Task<HelloReply> SayHello(HelloRequest request, ServerCallContext context)
{
return Task.FromResult(new HelloReply { Message = "Hello " + request.Name });
}

public override async Task<HelloReply> SendMultipleHellos(
IAsyncStreamReader<HelloRequest> requestStream,
ServerCallContext context)
{
var names = new List<string>();
await foreach (var request in requestStream.ReadAllAsync())
names.Add(request.Name);
return new HelloReply { Message = "Hello " + string.Join(", ", names) };
}

public override async Task GetMultipleReplies(
HelloRequest request,
IServerStreamWriter<HelloReply> responseStream,
ServerCallContext context)
{
for (int i = 1; i <= 5; i++)
{
if (context.CancellationToken.IsCancellationRequested)
break;
await responseStream.WriteAsync(new HelloReply
{
Message = $"Hello {request.Name} - message {i} of 5"
});
await Task.Delay(500);
}
}

// Bidirectional streaming: echo every client message back
public override async Task Chat(
IAsyncStreamReader<HelloRequest> requestStream,
IServerStreamWriter<HelloReply> responseStream,
ServerCallContext context)
{
await foreach (var request in requestStream.ReadAllAsync())
{
logger.LogInformation("Chat message from: {Name}", request.Name);
await responseStream.WriteAsync(new HelloReply
{
Message = "Echo: " + request.Name
});
}
}
}
}

BasicGrpcClient/Program.cs

using BasicGrpcService;
using Grpc.Core;
using Grpc.Net.Client;

using var channel = GrpcChannel.ForAddress("http://localhost:5287");
var client = new Greetings.GreetingsClient(channel);

Console.WriteLine("=== Bidirectional Streaming Demo ===");

try
{
// Open the bidirectional streaming call with a 30-second deadline
using var call = client.Chat(deadline: DateTime.UtcNow.AddSeconds(30));

// STEP 1: Start a background task to read server responses concurrently
var readTask = Task.Run(async () =>
{
await foreach (var reply in call.ResponseStream.ReadAllAsync())
{
Console.WriteLine("Server says: " + reply.Message);
}
Console.WriteLine("Response stream ended.");
});

// STEP 2: Send messages from the main thread
var names = new[] { "Ahmet", "Ayse", "Mehmet", "Fatma", "Ali" };
foreach (var name in names)
{
Console.WriteLine("Sending: " + name);
await call.RequestStream.WriteAsync(new HelloRequest { Name = name });
await Task.Delay(400); // simulate real-time input
}

// STEP 3: Close the request stream — no more messages to send
await call.RequestStream.CompleteAsync();

// STEP 4: Wait for the background reader to finish receiving all responses
await readTask;

Console.WriteLine("Chat session complete.");
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.DeadlineExceeded)
{
Console.WriteLine("The chat session timed out.");
}

Console.WriteLine("Press any key to exit...");
Console.ReadKey();

Expected output:

=== Bidirectional Streaming Demo ===
Sending: Ahmet
Server says: Echo: Ahmet
Sending: Ayse
Server says: Echo: Ayse
Sending: Mehmet
Server says: Echo: Mehmet
Sending: Fatma
Server says: Echo: Fatma
Sending: Ali
Server says: Echo: Ali
Response stream ended.
Chat session complete.

Note: because reading and writing happen concurrently, the exact order of "Sending:" and "Server says:" lines may interleave slightly differently depending on timing.

Summary

  1. Bidirectional streaming uses the stream keyword on both the request and response in the proto.
  2. The server receives both IAsyncStreamReader<TRequest> and IServerStreamWriter<TResponse>.
  3. The client receives AsyncDuplexStreamingCall<TRequest, TResponse> with both .RequestStream and .ResponseStream.
  4. Use the background reader pattern: start a Task.Run to read responses, write requests on the main thread.
  5. Always call RequestStream.CompleteAsync() when done sending, then await readTask to drain remaining responses.
  6. Never await readTask before CompleteAsync() — this causes a deadlock.
  7. Pass a deadline parameter when opening the call to limit total call duration.
  8. Catch RpcException with StatusCode.DeadlineExceeded to handle timeout gracefully.
Share this lesson: