gRPC streaming

gRPC streaming requires the google.golang.org/grpc library and proto definitions, as the provided http2 source only handles the underlying transport layer.

When a single request isn't enough

You are building a monitoring dashboard that needs to display log lines from a remote service in real time. A standard HTTP request works fine for fetching a snapshot of data. It falls apart when you need a continuous flow of updates without hammering the server with polling requests. You want the server to push new log lines the moment they arrive, and you want the client to read them as they come in. That is exactly what gRPC streaming solves.

Regular HTTP follows a strict request-response pattern. You send a question, the server sends an answer, and the conversation ends. gRPC streaming turns that single exchange into an open pipeline. Think of it like a garden hose instead of a series of buckets. You attach the hose, water flows continuously, and you control the flow from either end. Under the hood, gRPC uses HTTP/2, which already supports multiplexed streams over a single TCP connection. The google.golang.org/grpc library sits on top of that transport layer and gives you a clean Go API for reading and writing messages over those streams.

There are four distinct streaming patterns. Unary is the default: one request, one response. Server streaming sends one request and a continuous stream of responses. Client streaming sends a stream of requests and gets one response back. Bidirectional streaming opens a two-way pipe where both sides read and write independently. Each pattern maps to a specific Go interface and requires different handling on both ends.

Pick the pattern that matches your data flow. Force a bidirectional pipe when you only need one-way updates, and you will pay for complexity you never use.

How the stream actually works

Protocol Buffers defines the contract. You write a .proto file, mark a method with the stream keyword, and run the protoc compiler with the Go plugins. The generator creates a Go package containing the message structs and the server/client interfaces. You never write the serialization logic yourself. The compiler produces the code that turns your structs into compact binary frames.

The generated code gives you a method signature that accepts a context and a stream interface. On the server side, the interface looks like ServerStream. It exposes Send() for pushing messages and Context() for cancellation signals. On the client side, you get a ClientStream with Recv() and CloseSend(). The interfaces wrap the underlying HTTP/2 transport. You work with typed Go structs, not raw bytes.

The HTTP/2 connection stays open. Each stream gets a unique identifier. Frames travel over the shared TCP socket, tagged with their stream ID. The gRPC runtime multiplexes them, reorders them if they arrive out of sequence, and delivers them to the correct Go method. You get the performance of a single connection with the isolation of independent conversations.

Trust the multiplexer. Focus on your business logic, not frame ordering.

Minimal server implementation

You start by defining the contract in a Protocol Buffers file. The syntax looks like a typed API definition, and the stream keyword tells the code generator to build the plumbing for continuous data flow.

// syntax and imports are handled by the .proto file, but here is the Go structure
// you get after running protoc with the go-grpc plugin.
// The generator creates a ServerStream interface for the server to write to.
type LogService_StreamLogsServer interface {
    Send(*LogEntry) error
    grpc.ServerStream
}

The generated code gives you a method signature that accepts a context and a stream interface. You implement the method to push data.

// StreamLogs handles a server-streaming RPC that pushes log entries.
func (s *LogServer) StreamLogs(req *LogRequest, stream LogService_StreamLogsServer) error {
    // Send the first message to establish the stream and wake the client
    if err := stream.Send(&LogEntry{Message: "Connection established"}); err != nil {
        return err
    }

    // Push updates in a loop until context is cancelled or an error occurs
    for i := 0; i < 5; i++ {
        // Check context to respect client disconnection or deadlines
        if err := stream.Context().Err(); err != nil {
            return err
        }
        // Send a new log entry down the pipe
        if err := stream.Send(&LogEntry{Message: fmt.Sprintf("Log line %d", i)}); err != nil {
            return err
        }
        time.Sleep(1 * time.Second)
    }
    return nil
}

The receiver name s follows Go convention. One or two letters matching the type is standard. You will see (s *LogServer) everywhere in the standard library and popular packages. Stick to it. The compiler does not care, but human readers will.

Keep the receiver name short. Let the type name carry the weight.

Walking through the runtime

When the client calls StreamLogs, the gRPC library opens a new stream on the existing HTTP/2 connection. The server receives the request and your StreamLogs method starts executing in a dedicated goroutine. The stream.Send call does not block until the client reads the message. It buffers the data and pushes it into the HTTP/2 frame queue. The client reads from its own stream interface, pulling messages as they arrive.

The context passed to the method is the control wire. If the client closes the browser tab or hits a timeout, the context gets cancelled. Your server loop must check stream.Context().Err() regularly. If you ignore the context and keep writing, you will leak goroutines and waste network bandwidth. The gRPC runtime will eventually tear down the stream, but relying on that cleanup is a recipe for resource exhaustion under load.

Go handles the stream interfaces with concrete types that wrap the underlying HTTP/2 transport. The grpc.ServerStream interface provides Context(), SendMsg(), and RecvMsg(). The generated code adds type-safe Send() and Recv() methods on top. You never touch raw bytes or HTTP/2 frames. The library serializes your structs into protobuf bytes, frames them, and sends them. On the client side, the process reverses. The client receives frames, deserializes them, and hands you typed structs.

Context is plumbing. Run it through every long-lived call site.

Production-ready client and server

Real production code needs graceful shutdown, proper error wrapping, and concurrent reading/writing for bidirectional streams. Here is a complete server streaming implementation that respects cancellation and handles errors the Go way.

package main

import (
    "context"
    "fmt"
    "log"
    "net"
    "time"

    "google.golang.org/grpc"
    pb "your_project/gen/proto"
)

// LogServer implements the generated LogServiceServer interface.
type LogServer struct {
    pb.UnimplementedLogServiceServer
}

// StreamLogs pushes log entries until the client disconnects or an error occurs.
func (s *LogServer) StreamLogs(req *pb.LogRequest, stream pb.LogService_StreamLogsServer) error {
    // Use a ticker for steady pacing instead of blocking sleep
    ticker := time.NewTicker(500 * time.Millisecond)
    defer ticker.Stop()

    for {
        select {
        case <-stream.Context().Done():
            // Client disconnected or deadline exceeded. Exit cleanly.
            log.Printf("stream cancelled: %v", stream.Context().Err())
            return stream.Context().Err()
        case <-ticker.C:
            // Marshal the message and send it down the stream
            msg := &pb.LogEntry{
                Timestamp: time.Now().Unix(),
                Message:   fmt.Sprintf("heartbeat %d", time.Now().UnixMilli()),
            }
            if err := stream.Send(msg); err != nil {
                // Network error or client closed the stream mid-write
                log.Printf("send failed: %v", err)
                return err
            }
        }
    }
}

// RunServer starts the gRPC listener and blocks until interrupted.
func RunServer() {
    lis, err := net.Listen("tcp", ":50051")
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }

    // Create the gRPC server with default options
    srv := grpc.NewServer()
    pb.RegisterLogServiceServer(srv, &LogServer{})

    log.Println("gRPC server listening on :50051")
    if err := srv.Serve(lis); err != nil {
        log.Fatalf("failed to serve: %v", err)
    }
}

The select statement is the standard Go pattern for reacting to multiple asynchronous events. It keeps the goroutine responsive to context cancellation while maintaining a steady send rate. You wrap the generated server interface with UnimplementedLogServiceServer to satisfy the compiler. The compiler rejects your struct with does not implement pb.LogServiceServer if you miss that embedding, because the generated interface expects every RPC method to be present.

The client side mirrors this structure. You create a stream, send the initial request, and loop over Recv(). You must check the context on the client too. If the server stops sending, Recv() returns io.EOF. You handle it, clean up, and exit. The pattern is symmetric. Both sides own their half of the pipe.

Do not fight the type system. Wrap the value or change the design.

Pitfalls and compiler behavior

Streaming introduces concurrency hazards that unary calls hide. The most common mistake is spawning a goroutine to write to the stream while the main function continues reading. gRPC streams are not safe for concurrent use. If two goroutines call Send on the same stream simultaneously, you will get a race condition and corrupted frames. The runtime panics with concurrent Send on stream or returns a stream error. Serialize your writes through a single goroutine or protect them with a mutex.

Another trap is ignoring the return value of Send. The compiler will complain with assignment to entry in nil map if you accidentally pass a nil stream, but more often you will silently drop errors if you write stream.Send(msg) without checking err. The idiomatic pattern is if err := stream.Send(msg); err != nil { return err }. The verbosity is intentional. It forces you to handle network drops and client disconnections explicitly. The community accepts the boilerplate because it makes the unhappy path visible.

Client streaming requires careful attention to the CloseSend call. The server does not know the client is finished sending until the client explicitly closes the write side of the stream. If you forget to call stream.CloseSend(), the server blocks forever waiting for more data. The compiler will not catch this. It is a logical error that manifests as a hanging request. Bidirectional streams compound this issue. You need one goroutine to read, one to write, and a way to coordinate shutdown when either side finishes.

Context cancellation is the thread that ties everything together. Always pass context.Context as the first parameter to any function that touches a stream. The convention is strict. Functions that accept a context must respect cancellation and deadlines. If you block on a database query or a file read without checking the context, your stream will leak goroutines and hold open HTTP/2 connections long after the client gave up.

The worst goroutine bug is the one that never logs.

Choosing the right pattern

Use unary RPCs when the payload fits in a single request and response cycle. Use server streaming when one request triggers a continuous flow of updates, like live logs or stock tickers. Use client streaming when you need to upload large files or batch sensor readings without loading everything into memory at once. Use bidirectional streaming when both sides need to exchange messages independently, like a chat application or a remote debugging console. Use plain HTTP with Server-Sent Events when you only need one-way updates and want to avoid the protobuf toolchain. Use WebSockets when you need a raw, bidirectional byte stream outside the gRPC ecosystem.

Pick the simplest pattern that solves the problem. Complexity compounds fast in distributed systems.

Where to go next