Client and Bidirectional Streaming in Go Explained
Client streaming and bidirectional streaming gRPC in Go: half-close, io.EOF, separate send and receive goroutines, and how errors surface on each side.

Server streaming covered one request and many responses. The other two shapes complete the set. Client streaming reverses it: many requests and one response, useful for uploads, batches and aggregations. Bidirectional streaming lets both sides send at any time, independently: chat, collaborative editing, a pipeline where results flow back while input is still flowing in.
Tested with Go 1.25.6, grpc-go v1.75.0, protobuf-go v1.36.8, protoc 29.3, macOS on Apple Silicon (arm64)
These are where gRPC differs most from request-response HTTP, and where the mistakes are subtle: who closes what, what an error looks like on each side, and why Send and Recv usually need separate goroutines. This article walks through both shapes with a runnable service. It follows server streaming and unary calls, and the same ideas apply to WebSocket protocol design, which solves a similar problem over a different transport. It ran on Go 1.25.6.
The contract
syntax = "proto3";
package chat.v1;
option go_package = "example.test/lab/chatpb";
service Chat {
// Client streaming: many chunks in, one summary out.
rpc Upload(stream Chunk) returns (Summary);
// Bidirectional streaming: both sides send independently.
rpc Talk(stream Message) returns (stream Message);
}
message Chunk { bytes data = 1; }
message Summary { int64 chunks = 1; int64 bytes = 2; string sha_prefix = 3; }
message Message { string from = 1; string text = 2; }Upload takes a stream of chunks and returns one Summary. Talk takes a stream of messages and returns a stream of messages. The syntax is the keyword stream on whichever side streams.
//go:build tools
package main
import (
_ "google.golang.org/grpc/cmd/protoc-gen-go-grpc"
_ "google.golang.org/protobuf/cmd/protoc-gen-go"
)The server
The server's two handlers show the two idioms.
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"log"
"net"
"strings"
"sync"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
pb "example.test/lab/chatpb"
)
type server struct{ pb.UnimplementedChatServer }
// Upload reads until the client half-closes (io.EOF), then sends the single response.
func (server) Upload(stream grpc.ClientStreamingServer[pb.Chunk, pb.Summary]) error {
h := sha256.New()
var chunks, size int64
for {
c, err := stream.Recv()
if errors.Is(err, io.EOF) {
return stream.SendAndClose(&pb.Summary{Chunks: chunks, Bytes: size, ShaPrefix: hex.EncodeToString(h.Sum(nil))[:8]})
}
if err != nil {
return err
}
chunks++
size += int64(len(c.GetData()))
h.Write(c.GetData())
}
}
// Talk answers every message with an upper-case echo, and ends the conversation when it hears "bye".
func (server) Talk(stream grpc.BidiStreamingServer[pb.Message, pb.Message]) error {
for {
m, err := stream.Recv()
if errors.Is(err, io.EOF) {
return nil // the client closed its side; closing ours finishes the call
}
if err != nil {
return err
}
if m.GetText() == "bye" {
return status.Error(codes.OK, "") // an OK status ends the stream cleanly from the server side
}
if m.GetText() == "forbidden" {
return status.Error(codes.PermissionDenied, "that word is not allowed")
}
if err := stream.Send(&pb.Message{From: "server", Text: strings.ToUpper(m.GetText())}); err != nil {
return err
}
}
}
func main() {
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
log.Fatal(err)
}
srv := grpc.NewServer()
pb.RegisterChatServer(srv, server{})
go srv.Serve(lis)
defer srv.Stop()
conn, err := grpc.NewClient(lis.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatal(err)
}
defer conn.Close()
c := pb.NewChatClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// 1. Client streaming: send chunks, then CloseAndRecv sends the half-close and waits for the one response.
up, _ := c.Upload(ctx)
for i := 0; i < 5; i++ {
up.Send(&pb.Chunk{Data: []byte(strings.Repeat("x", 1000*(i+1)))})
}
sum, err := up.CloseAndRecv()
fmt.Printf("Upload: %v chunks, %v bytes, sha256 prefix %s, err=%v\n", sum.GetChunks(), sum.GetBytes(), sum.GetShaPrefix(), err)
// 2. Bidirectional: sending and receiving are independent, so they run in separate goroutines.
talk, _ := c.Talk(ctx)
words := []string{"hello", "streams", "are", "independent"}
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for _, w := range words {
talk.Send(&pb.Message{From: "client", Text: w})
}
talk.CloseSend() // half-close: no more messages from us, but we keep reading
}()
var got []string
for {
m, err := talk.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
log.Fatal(err)
}
got = append(got, m.GetText())
}
wg.Wait()
fmt.Println("Talk: replies arrive in send order:", got)
// 3. The server ends the stream with an error; the client sees it from Recv, and Send reports only io.EOF.
talk, _ = c.Talk(ctx)
talk.Send(&pb.Message{Text: "forbidden"})
time.Sleep(50 * time.Millisecond)
sendErr := talk.Send(&pb.Message{Text: "anyone there?"})
_, recvErr := talk.Recv()
fmt.Printf("Talk after a server error: Send returned %v; Recv returned %s (%v)\n", sendErr, status.Code(recvErr), status.Convert(recvErr).Message())
// 4. The server finishes first with OK ("bye"): Recv sees io.EOF.
talk, _ = c.Talk(ctx)
talk.Send(&pb.Message{Text: "bye"})
_, err = talk.Recv()
fmt.Println("Talk after the server said bye: Recv returned io.EOF:", errors.Is(err, io.EOF))
}mkdir -p bin
go build -o bin/protoc-gen-go google.golang.org/protobuf/cmd/protoc-gen-go
go build -o bin/protoc-gen-go-grpc google.golang.org/grpc/cmd/protoc-gen-go-grpc
PATH="$PWD/bin:$PATH" protoc --go_out=. --go_opt=module=example.test/lab --go-grpc_out=. --go-grpc_opt=module=example.test/lab proto/*.proto
go run .Upload: 5 chunks, 15000 bytes, sha256 prefix 0c9031e3, err=<nil>
Talk: replies arrive in send order: [HELLO STREAMS ARE INDEPENDENT]
Talk after a server error: Send returned EOF; Recv returned PermissionDenied (that word is not allowed)
Talk after the server said bye: Recv returned io.EOF: trueClient streaming (Upload). The handler loops on stream.Recv(). Each call returns the next message from the client. When the client has no more to send and calls CloseAndRecv (or CloseSend), the server's Recv returns io.EOF. That is the cue to compute the result and call stream.SendAndClose(&reply), which sends the single response and ends the call. Returning from the handler after that finishes the stream. Any other error from Recv means the stream broke (cancellation, network failure): return it.
Bidirectional streaming (Talk). The handler reads messages with Recv and writes with Send, and the two are independent: the server may send zero, one or many messages per received message, or send without having received anything. The lab's server answers each message with an upper-case echo, which is request-response in disguise, so a loop of Recv then Send is enough. A server that needs to push messages on its own schedule (notifications while the client is idle) uses a separate goroutine for Send, since it cannot be blocked waiting for Recv. Receiving io.EOF means the client half-closed; returning nil then closes the server's side and finishes the call.
The client
The client side has matching calls.
For client streaming: call c.Upload(ctx) to get a stream, Send each chunk, then CloseAndRecv() which half-closes and waits for the reply. The output shows five chunks of 1,000 to 5,000 bytes becoming 5 chunks, 15000 bytes with a SHA-256 prefix computed on the server: the server saw exactly what was sent, in order.
For bidirectional streaming, the client usually needs two goroutines, one sending and one receiving, because a single goroutine that sends everything first and then reads can deadlock: if the server's replies fill the flow-control window before the client reads any, the server blocks on Send, stops reading, the client blocks on Send, and neither moves. The lab does it correctly: a goroutine sends the four words and calls CloseSend(), while the main goroutine loops on Recv until io.EOF. The result is [HELLO STREAMS ARE INDEPENDENT], in send order.
Two rules summarise the concurrency model of a stream:
- Messages in one direction are ordered. What you
Sendin sequence arrives in that sequence. - There is no ordering between directions. A server reply may arrive before, during or after the client's next message. If you need to correlate replies with requests, put an id in the messages.
And on thread safety: it is safe to call Send from one goroutine and Recv from another at the same time, but not Send from two goroutines at once, nor Recv from two. Serialise each direction.
Half-close and the end of a stream
A stream has two directions and each ends on its own.
CloseSend() says "I will send nothing more", but the stream stays open for reading. It is the HTTP/2 end-of-stream flag on the client's side. The server sees it as io.EOF from Recv. The server finishes its side by returning from the handler, which sends the status in the trailers; the client sees io.EOF from Recv if the status was OK, or a status error otherwise.
So io.EOF has one meaning in both directions: the other side ended normally. It is never a failure. Every other error is.
Scenario 4 in the output shows the server ending a conversation by itself. When it receives "bye" it returns an OK status, and the client's Recv returned io.EOF without having called CloseSend. The client does not need to initiate the end.
How errors surface
The most confusing behaviour in gRPC streaming is in scenario 3. The server received the word "forbidden" and returned PermissionDenied. The client, a moment later, called Send again and got... EOF. Not PermissionDenied. Then Recv returned the real status: PermissionDenied (that word is not allowed).
The reason is that Send reports only whether the message was accepted by the transport, and once the stream is finished that is no longer possible. It returns io.EOF to say "the stream is over", and leaves the explanation to Recv, which is where the status arrives. The practical rule: when Send returns an error, call Recv to learn why, or let your receive loop handle the status, because it is already waiting for it. Code that logs the error from Send and nothing else shows EOF in the logs and hides the cause.
Patterns that work
Request-response over a stream. Each message gets a reply, as in the lab. Use ids if replies can come out of order or the server can process messages concurrently.
Fan-in. Many clients, one server: each has its own stream. The server handler for a stream is its own goroutine; shared state needs locks or channels.
Pipelines. A client streams records for processing and receives results as they complete. The two goroutines on the client and the flow-control backpressure keep both ends bounded.
Subscriptions with commands. A server streams events and the client sends control messages on the same stream (subscribe, unsubscribe, ack). The server needs a goroutine for Recv and one for Send, joined by an errgroup so either failing cancels the other (errgroup).
Client streaming with acknowledgements. For reliable uploads, prefer bidirectional streaming where the server acknowledges chunk numbers, so the client can resume from the last acknowledged chunk after a reconnect.
Designing the messages of a bidirectional stream
A unary rpc has a request type and a response type, and the method name says what the call means. A bidirectional stream carries many kinds of message over one long call, so the message type itself has to say what each one is. The idiomatic way is a oneof:
message ClientEvent {
oneof event {
Subscribe subscribe = 1;
Unsubscribe unsubscribe = 2;
Ack ack = 3;
Ping ping = 4;
}
}
message ServerEvent {
int64 seq = 1;
oneof event {
Update update = 2;
Error error = 3;
Pong pong = 4;
}
}In Go the generated field is an interface, and the receive loop becomes a type switch over m.GetEvent(). Adding a new kind of event later is a compatible schema change: old servers see an unknown oneof member and ignore it, old clients likewise. That evolution story is the main advantage over inventing a stringly-typed type field (protobuf evolution).
Keep protocol-level concepts in the schema, not in prose: a seq on server events so a client can acknowledge and resume, a Ping and Pong pair for liveness at the application level, and an explicit Error message for problems that should not end the whole stream. A status error ends the stream, which is right for a broken protocol and wrong for "that one subscription id does not exist".
Resuming after a break
A long stream will break: a deployment, a load balancer idle timeout, a laptop waking up. Design for it from the first version. The pattern is the one used by server-sent events and WebSocket protocols:
- The server numbers the events it sends on a stream.
- The client records the highest number it has processed.
- On reconnect, the client's first message says "resume after N".
- The server keeps a bounded buffer of recent events and replays from N, or tells the client to start from a fresh snapshot if N is too old.
Without it, every blip loses events silently or forces a full resync. With it, a reconnect costs one extra message. Decide where the buffer lives (in memory for one process, in a shared store for several), and what "too old" means, and write that into the proto comments as part of the contract.
Streams and load balancing
A stream is a single HTTP/2 stream on a single connection to a single server. Once it has started, every message of that call goes to that backend. A client-side load balancer that picks a backend per call therefore spreads streams across servers, which is fine for many short streams and uneven for a few long ones: a handful of clients with hour-long streams pin load to whichever backend they landed on, and new backends added during a deployment receive nothing until clients reconnect.
Two things help. Have servers close long streams after a maximum age with a clean status (Unavailable or a dedicated "please reconnect" code) so clients rebalance. And set MaxConnectionAge with a grace period on the server so connections, and the streams on them, are recycled gradually (keepalive). Clients must treat these closures as routine and reconnect with backoff and jitter. See gRPC load balancing for the mechanics.
When not to stream
Streaming is more state to manage than a unary call. It is the wrong choice when each interaction is independent and short (use unary calls), when the work is large but not continuous (use unary calls with pagination, or chunked uploads through presigned URLs), and when you need durable delivery (use a queue). It is the right choice when the data is a sequence over time, when latency per item matters, when you want backpressure, or when both sides really talk at once.
Start with unary calls and move a method to streaming when a concrete problem demands it. The change is not a compatible one for clients, since a method's streaming shape is part of its identity, so adding a streaming variant as a new rpc and deprecating the old one is the safe path.
A note on testing the hard cases
Streaming bugs hide in timing, so the tests that matter manufacture the timing on purpose. A fake client that sends one message and then goes silent shows whether the server's memory stays bounded. A client that disconnects in the middle of a Send shows whether the handler returns and the goroutines disappear. A server handler that fails after three messages shows whether the client reports the status instead of EOF. Run these under the race detector, and count goroutines before and after to catch leaks. They take a few lines each once an in-memory server helper exists, and they are the difference between a streaming feature that works in the demo and one that works at three in the morning.
Pitfalls
Forgetting to close. A client that never calls CloseSend (or CloseAndRecv) leaves the server's Recv waiting. Do it in a defer or at the end of the sending goroutine.
Leaked goroutines. A receive loop that never exits because the stream is never cancelled. Tie the stream to a context you cancel when you are done: ctx, cancel := context.WithCancel(...), defer cancel(). Cancelling the context aborts the call from the client side.
Ignoring flow control. The deadlock described above. Always send and receive independently when volumes can be large.
Unbounded streams without keepalive. A long-lived idle stream behind a proxy or NAT can be silently dropped. Use keepalive pings and application-level heartbeats (keepalive).
Large messages. A stream is a way to avoid single huge messages: chunk your uploads at a few hundred kilobytes, well under the 4 MiB default receive limit (message size limits).
Treating a stream like a queue. A stream is a connection. When it breaks, messages in flight are lost. For durability, number your messages, acknowledge them and resume, or use a queue.
Testing. Streams are straightforward to test with an in-memory connection: send a few messages, close, assert on the replies and on the final status. Add tests for cancellation midway, for a server error with the client still sending, and for a client that stops reading (testing gRPC).
The semantics of all four call types are described in the gRPC core concepts page, and the Go API for streams is in the grpc package documentation.


