How to Use Bidirectional Streaming RPC in Go

Web
Implement bidirectional streaming RPC in Go by defining server and client methods that loop through Send and Recv calls on the generated stream interface.

How to Use Bidirectional Streaming RPC in Go

You're building a real-time chat room. A user types a message and hits send. At the exact same moment, three other users are typing messages that need to appear on the screen. A standard HTTP request waits for a response and closes. That works for fetching a profile picture. It fails for a conversation. You need a pipe that stays open, where data flows both ways without waiting for the other side to finish. That's bidirectional streaming.

The concept: a pipe, not a mailbox

Think of a standard RPC call like a letter in the mail. You write a letter, drop it in the box, and wait for a reply. The channel is closed until the reply arrives. Bidirectional streaming is like a phone call. Both parties can speak and listen at the same time. The connection stays open. You can send a message, hear a response, send another, and receive another, all interleaved.

In gRPC, this maps to a single HTTP/2 stream. HTTP/2 multiplexes multiple streams over one TCP connection, so you can have dozens of bidirectional streams active without opening new sockets. The server generates code for a stream interface. Your implementation handles the loop. The client gets a stream object. Both sides call Send and Recv on that object.

Go functions that accept a context always take it as the first parameter. The convention is to name it ctx. If your stream method takes a context, put it first. gRPC streams carry a context internally, accessible via stream.Context(). Use that context to respect cancellation.

Streams keep the conversation alive. Don't force a phone call into a mailbox.

Minimal server loop

Here's the simplest server loop: read a message, transform it, send it back, repeat until the client hangs up.

func (s *server) EchoStream(stream pb.EchoService_EchoStreamServer) error {
    // Loop forever until the client closes the stream or an error occurs
    for {
        // Block until a message arrives or the stream ends
        msg, err := stream.Recv()
        if err == io.EOF {
            // Client finished sending. Clean exit.
            return nil
        }
        if err != nil {
            // Network error or protocol violation.
            return err
        }
        // Echo the message back immediately.
        if err := stream.Send(&pb.Message{Text: msg.Text}); err != nil {
            return err
        }
    }
}

The loop is the heart. Recv blocks, Send pushes, EOF ends.

What happens at runtime

The server method returns a stream interface. The loop calls Recv. This blocks. The goroutine sleeps until data arrives. When data arrives, Recv returns the message and nil error. The server processes it and calls Send. Send pushes data to the wire.

If the client closes its side, Recv returns io.EOF. The server sees this and returns nil, closing its side gracefully. If the network drops, Recv returns a non-nil error that isn't EOF. The server returns that error, and gRPC tears down the connection.

Each call to the streaming RPC runs in its own goroutine on the server. The framework handles the multiplexing. You just write the loop. Receiver names in Go are usually one or two letters matching the type. Use (s *server) or (e *Echo). Avoid (this *server) or (self *server). The community expects short names.

Realistic client with goroutines

Real clients rarely send and receive in the same goroutine. You usually spawn a goroutine to send messages while the main loop receives responses. This prevents the sender from blocking on a slow receiver.

Here's the client setup and the sender goroutine.

func runClient(conn *grpc.ClientConn) {
    // Open the bidirectional stream.
    stream, err := pb.NewEchoServiceClient(conn).EchoStream(context.Background())
    if err != nil {
        log.Fatalf("stream failed: %v", err)
    }

    // Spawn a sender goroutine to push messages independently.
    go func() {
        // Ensure the send side closes when this goroutine exits.
        defer stream.CloseSend()

        for i := 0; i < 3; i++ {
            // Send blocks if the internal buffer is full.
            if err := stream.Send(&pb.Message{Text: fmt.Sprintf("hello %d", i)}); err != nil {
                return
            }
        }
    }()
}

Back in the main goroutine, receive responses.

// Main goroutine receives responses.
for {
    resp, err := stream.Recv()
    if err == io.EOF {
        // Server finished sending.
        break
    }
    if err != nil {
        log.Fatalf("recv failed: %v", err)
    }
    fmt.Println("received:", resp.Text)
}

Goroutines split the work. CloseSend signals the end. Context kills the stream.

Pitfalls and errors

Forgetting CloseSend is a common leak. If you finish sending but forget to call stream.CloseSend(), the server never sees the end-of-stream signal. The server's Recv loop keeps waiting. The goroutine leaks. The server holds resources forever. Always call CloseSend when the client is done sending. Use defer stream.CloseSend() in the sender goroutine to guarantee cleanup.

Recv returns io.EOF when the peer closes the stream. It returns an error when something breaks. You must check io.EOF before checking for a generic error. If you check err != nil first, you might treat a clean close as a failure and crash the client. The pattern is if err == io.EOF { break } if err != nil { return err }.

If you pass the wrong type to Send, the compiler rejects it with cannot use x (type *MyMessage) as type *pb.Message in argument. The type system catches mismatches early.

What happens if the server sends faster than the client can receive? The client's internal buffer fills up. Eventually, Send on the server blocks. This is backpressure. It slows the server down to match the client. This prevents memory exhaustion. If the client is dead, the context times out and the stream breaks.

if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Don't try to hide errors in streams. Return them immediately.

Leaking a stream leaks a goroutine. Always close what you open.

When to use bidirectional streaming

Use a unary RPC when you send one request and get one response. This covers most API calls like fetching a user profile or submitting a form.

Use server streaming when the client sends one request and the server sends a sequence of responses. Reach for this when downloading a large file in chunks or receiving live updates for a single query.

Use client streaming when the client sends a sequence of requests and the server sends one response. Pick this for uploading large files or batching logs where the server aggregates data before replying.

Use bidirectional streaming when both sides need to exchange multiple messages independently. Choose this for chat applications, collaborative editing, or sensor networks where data flows both ways without a strict request-response pairing.

Streaming is powerful. Unary is simple. Pick the tool that matches the data flow.

Where to go next