How to Use Client Streaming RPC in Go

Web
Client streaming RPCs allow a client to send a stream of messages to the server for a single request, while the server waits to receive all messages before processing and returning a single response.

The batch upload problem

You are building a service that collects telemetry from thousands of IoT sensors. Each sensor records temperature, humidity, and vibration data every second. You do not want the server to respond after every single reading. That would create unnecessary network chatter and waste CPU cycles on tiny round trips. Instead, the sensor batches hundreds of readings, sends them all in one continuous flow, and waits for a single acknowledgment when the batch is complete. This is exactly what a client streaming RPC solves.

How client streaming works

In a standard unary RPC, the conversation follows a strict ping-pong rhythm. The client sends one message, the server replies with one message, and the connection closes. Client streaming breaks that rhythm. The client opens a channel and keeps sending messages. The server listens, accumulates the data, and only speaks once the client signals it is finished. Think of it like recording a voice memo. You talk continuously, pause to gather your thoughts, talk some more, and finally press stop. The recipient plays the entire recording before deciding how to reply.

Under the hood, gRPC uses HTTP/2 to manage these streams. HTTP/2 allows multiple independent streams to share a single TCP connection. The stream keyword in your Protocol Buffers definition tells the code generator to build a client interface that exposes a Send method and a CloseAndRecv method, rather than a single synchronous call. The server receives a corresponding stream interface that exposes Recv and SendAndClose.

Client streaming shifts the responsibility of batching to the client. The server does not need to guess when a logical unit of work is complete. The client explicitly defines the boundary.

Defining the contract

The contract starts in a .proto file. You mark the request parameter with the stream keyword to indicate the client will send multiple messages. The response remains a single message. Protocol Buffers handles the serialization format, so you only define the shape of the data.

syntax = "proto3";

package telemetry;

service DataProcessor {
  // stream on the request side enables client streaming
  rpc ProcessStream (stream DataItem) returns (ProcessResult);
}

message DataItem {
  string value = 1;
}

message ProcessResult {
  int32 total_count = 1;
  string summary = 2;
}

Running protoc with the Go gRPC plugin generates the client and server stubs. The generated client interface will contain a method that returns a stream object instead of a direct response. You do not edit the generated code. You write the logic that drives the stream. The compiler enforces the contract strictly, so mismatched message types or missing stream markers will fail at build time.

Define the shape once. Let the generator handle the wire format.

Walking through the runtime

When the client calls the streaming method, gRPC negotiates a new stream ID over the existing HTTP/2 connection. The client receives a stream object that holds the network socket and the protobuf marshaling logic. Every call to Send serializes the message into a frame and pushes it down the wire. The server receives these frames, deserializes them, and hands them to your handler one by one.

The server handler runs in a loop. It calls Recv to pull the next message. If the client is still sending, Recv returns the message and a nil error. If the client finishes and closes its side of the stream, Recv returns io.EOF. That is your signal to stop waiting and compute the final result. The server then calls SendAndClose to deliver the single response and terminate the stream.

HTTP/2 flow control governs the pace. If the server reads slower than the client writes, the client's Send call will block until the server frees up window space. This built-in backpressure prevents the client from overwhelming the server's memory. You do not need to implement your own rate limiter. The transport layer handles it automatically.

The stream is a single logical conversation. Treat it like a pipe, not a message queue.

Building the client

Real production code requires context propagation, explicit error handling, and proper resource cleanup. Go conventions dictate that context.Context always travels as the first parameter, conventionally named ctx. Functions that accept a context must respect cancellation and deadlines. The receiver name should be short, usually matching the first letter of the struct type.

Here is the connection setup and stream initialization:

package main

import (
    "context"
    "log"
    "time"

    "google.golang.org/grpc"
    pb "your/module/path"
)

func main() {
    // Attach a deadline so the client does not hang on dead servers
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    // DialContext respects the deadline and returns immediately on failure
    conn, err := grpc.DialContext(ctx, "localhost:50051", grpc.WithInsecure())
    if err != nil {
        log.Fatalf("connection failed: %v", err)
    }
    defer conn.Close()

    client := pb.NewDataProcessorClient(conn)

    // Open the streaming RPC. The server will wait here until we send data.
    stream, err := client.ProcessStream(ctx)
    if err != nil {
        log.Fatalf("failed to create stream: %v", err)
    }

The second half handles the send loop and the final handshake. Notice how CloseAndRecv serves two purposes: it tells the server the client is done, and it blocks until the server replies.

    items := []string{"sensor-1", "sensor-2", "sensor-3"}
    for _, item := range items {
        // Send blocks if the server is not reading or the stream is full
        if err := stream.Send(&pb.DataItem{Value: item}); err != nil {
            log.Fatalf("send failed: %v", err)
        }
    }

    // Signal end-of-stream and wait for the single server response
    resp, err := stream.CloseAndRecv()
    if err != nil {
        log.Fatalf("failed to receive response: %v", err)
    }

    log.Printf("Server response: Count=%d, Summary=%s", resp.TotalCount, resp.Summary)
}

The if err != nil check is verbose by design. The Go community accepts the boilerplate because it makes the unhappy path visible and forces you to decide what to do with every error. Wrapping the error with %w preserves the error chain for callers that use errors.Is or errors.As. Run gofmt on save. It aligns the braces and indentation so you never argue about style.

Building the server

The server side reads until the stream ends. It must handle io.EOF explicitly, because Recv returns an error for both network failures and the normal end of the stream. The generated server interface requires you to embed UnimplementedDataProcessorServer to satisfy the compiler while leaving room for future RPC additions.

package main

import (
    "io"
    "log"

    pb "your/module/path"
)

// server implements the generated DataProcessorServer interface.
type server struct {
    pb.UnimplementedDataProcessorServer
}

// ProcessStream handles the incoming client stream.
func (s *server) ProcessStream(stream pb.DataProcessor_ProcessStreamServer) error {
    count := 0
    for {
        // Recv blocks until a message arrives or the stream closes
        item, err := stream.Recv()
        if err == io.EOF {
            break
        }
        if err != nil {
            // Network error or client cancellation
            return fmt.Errorf("stream recv error: %w", err)
        }

        count++
        log.Printf("received item: %s", item.Value)
    }

    // Send the final result and close the server side of the stream
    return stream.SendAndClose(&pb.ProcessResult{
        TotalCount: int32(count),
        Summary:    "Processing complete",
    })
}

The receiver name s matches the first letter of server. This is a Go convention that keeps method signatures readable. The UnimplementedDataProcessorServer embed is a safety net. If you add a new RPC to the .proto file later, the compiler will remind you to implement it instead of silently dropping requests.

Read until EOF. Send once. Close the stream.

Where things go wrong

Client streaming introduces subtle failure modes that unary calls do not have. The most common mistake is forgetting to call CloseAndRecv() on the client. If you skip it, the server sits in the Recv loop forever, waiting for more data. The goroutine handling the RPC leaks, and the client eventually times out or crashes. Always pair Send with CloseAndRecv.

Another trap is assuming Send always succeeds immediately. Send can block if the server is not calling Recv fast enough, or if the underlying HTTP/2 flow control window fills up. If your client sends data faster than the server can process it, the client goroutine will hang. You must respect backpressure. If Send blocks for too long, cancel the context to unblock the call and clean up resources. The context cancellation propagates down to the server, where Recv will return a context.Canceled error instead of io.EOF.

On the server side, confusing io.EOF with a network error causes silent data loss. If you return early on io.EOF, the server stops reading mid-stream. The client will eventually get a broken pipe error because the server closed the connection prematurely. Always check for io.EOF first, then handle other errors.

The compiler will also catch structural mistakes early. If you try to pass a context as the second argument instead of the first, you get cannot use ctx as DataProcessor_ProcessStreamServer value in argument. If you forget to implement the generated server interface, the compiler rejects the program with does not implement DataProcessorServer (missing ProcessStream method). Trust the type system. It enforces the streaming contract strictly.

The worst goroutine bug is the one that never logs. Always attach a context deadline and check for cancellation.

Picking the right RPC pattern

gRPC offers four RPC patterns. Pick the right one based on your data flow.

Use a unary RPC when the client sends one message and expects one immediate response. Use a server streaming RPC when the client sends one request and the server needs to send a continuous stream of updates. Use a client streaming RPC when the client has a large batch of data to send and the server only needs to respond once after receiving everything. Use a bidirectional streaming RPC when both sides need to send and receive messages continuously, like a chat application or a real-time trading system.

Match the pattern to the data direction. Do not force a stream where a simple request-response cycle works.

Where to go next