Mastering gRPC in Go: A Deep Dive into Communication Patterns


gRPC has emerged as a powerful framework for building high-performance, cross-platform APIs. At its core, it leverages HTTP/2 for transport and Protocol Buffers as its interface definition language, enabling efficient and strongly-typed communication between microservices. While many developers start with the basic request-response pattern, the true power of gRPC lies in its four distinct communication models. Understanding these patterns is key to designing robust and scalable systems.

In this article, we'll explore each of the four gRPC communication patterns—Unary, Server Streaming, Client Streaming, and Bidirectional Streaming—by implementing a simple chat service in Go. We will walk through the .proto definition, server-side handlers, and client-side logic for each pattern.


Prerequisites & Setup

Before we dive in, make sure you have the Go programming language and the Protocol Buffers compiler (protoc) installed. You'll also need the Go plugins for gRPC and Protobufs:

go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.28
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.2

First, let's define our service in a .proto file. We'll create a ChatService with a method for each of the four communication types.

chat/chat.proto

syntax = "proto3";
 
package chat;
 
option go_package = "[github.com/your-repo/grpc-blog/chat](https://github.com/your-repo/grpc-blog/chat)";
 
// The request message containing the user's name and message.
message Message {
  string user = 1;
  string content = 2;
}
 
// The service definition.
service ChatService {
  // Unary RPC: Sends a single message and gets a single response.
  rpc SayHello(Message) returns (Message) {}
 
  // Server Streaming RPC: Sends a single message and gets a stream of responses.
  rpc BroadcastMessages(Message) returns (stream Message) {}
 
  // Client Streaming RPC: Sends a stream of messages and gets a single response.
  rpc UploadStream(stream Message) returns (Message) {}
 
  // Bidirectional Streaming RPC: Sends a stream of messages and gets a stream of responses.
  rpc ChatStream(stream Message) returns (stream Message) {}
}

After defining the service, we generate the Go code using protoc:

protoc --go_out=. --go_opt=paths=source_relative \
    --go-grpc_out=. --go-grpc_opt=paths=source_relative \
    chat/chat.proto

This command generates chat/chat.pb.go and chat/chat_grpc.pb.go, which contain the necessary interfaces and structs for our server and client.


1. Unary RPC

The Unary RPC is the simplest pattern, mirroring a traditional function call. The client sends a single request message to the server and receives a single response message back.

Server Implementation

On the server, we implement the SayHello method defined in our ChatServiceServer interface. It takes a context and the request message as arguments and returns a response message and an error.

// server/main.go
type server struct {
    chat.UnimplementedChatServiceServer
}
 
func (s *server) SayHello(ctx context.Context, in *chat.Message) (*chat.Message, error) {
    log.Printf("Received Unary message from %s: %s", in.User, in.Content)
    return &chat.Message{
        User:    "Server",
        Content: "Hello, " + in.User + "! Thanks for your message.",
    }, nil
}

Client Implementation

The client creates a connection, instantiates a new client stub, and calls the remote method just like a local function.

// client/main.go
c := chat.NewChatServiceClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
 
res, err := c.SayHello(ctx, &chat.Message{User: "Marko", Content: "This is a unary call."})
if err != nil {
    log.Fatalf("could not greet: %v", err)
}
log.Printf("Unary Response from %s: %s", res.User, res.Content)

Use Cases: Perfect for simple, self-contained operations like creating a resource, fetching a user profile, or performing a status check.


2. Server Streaming RPC

In a Server Streaming RPC, the client sends a single request, but the server responds with a stream of messages. The server keeps the connection open and sends multiple messages back until it has no more data to send.

Server Implementation

The server method receives the request and a stream object. It can then use the stream's Send() method multiple times to send back responses.

// server/main.go
func (s *server) BroadcastMessages(in *chat.Message, stream chat.ChatService_BroadcastMessagesServer) error {
    log.Printf("Received Broadcast request from %s", in.User)
    messages := []chat.Message{
        {User: "Server", Content: "Welcome to the broadcast!"},
        {User: "Server", Content: "Here is message #1."},
        {User: "Server", Content: "And here is message #2."},
        {User: "Server", Content: "Broadcast finished."},
    }
 
    for _, msg := range messages {
        if err := stream.Send(&msg); err != nil {
            return err
        }
        time.Sleep(500 * time.Millisecond) // Simulate work
    }
    return nil
}

Client Implementation

The client calls the method and receives a stream object in return. It then enters a loop, calling Recv() to read messages from the stream until it receives an io.EOF error, which signals that the stream has been closed by the server.

// client/main.go
stream, err := c.BroadcastMessages(ctx, &chat.Message{User: "Marko"})
if err != nil {
    log.Fatalf("could not open broadcast stream: %v", err)
}
 
for {
    msg, err := stream.Recv()
    if err == io.EOF {
        // Stream finished
        break
    }
    if err != nil {
        log.Fatalf("failed to receive broadcast message: %v", err)
    }
    log.Printf("Broadcast from %s: %s", msg.User, msg.Content)
}

Use Cases: Ideal for scenarios where a server needs to push updates to a client, such as stock tickers, live sports scores, or progress notifications for a long-running task.


3. Client Streaming RPC

The Client Streaming RPC is the inverse of server streaming. The client sends a sequence of messages to the server using a stream. Once the client has finished writing messages, it waits for the server to process them and return a single response.

Server Implementation

The server's handler receives a stream object. It uses a loop with Recv() to read all incoming messages from the client. When the client closes the stream, Recv() returns an io.EOF error. The server can then perform its final calculations and send a single response using SendAndClose().

// server/main.go
func (s *server) UploadStream(stream chat.ChatService_UploadStreamServer) error {
    var messageCount int32
    var lastUser string
    for {
        msg, err := stream.Recv()
        if err == io.EOF {
            log.Printf("Upload stream finished. Received %d messages.", messageCount)
            return stream.SendAndClose(&chat.Message{
                User:    "Server",
                Content: fmt.Sprintf("Finished receiving stream from %s. Got %d messages.", lastUser, messageCount),
            })
        }
        if err != nil {
            return err
        }
        lastUser = msg.User
        messageCount++
        log.Printf("Received message from client stream: %s", msg.Content)
    }
}

Client Implementation

The client obtains a stream object by calling the method. It then uses a loop to send multiple messages with Send(). After finishing, it calls CloseAndRecv() to notify the server that it's done and to receive the final summary response.

// client/main.go
stream, err := c.UploadStream(ctx)
if err != nil {
    log.Fatalf("could not open upload stream: %v", err)
}
 
messages := []chat.Message{
    {User: "Marko", Content: "Log entry 1"},
    {User: "Marko", Content: "Log entry 2"},
    {User: "Marko", Content: "Log entry 3"},
}
 
for _, msg := range messages {
    if err := stream.Send(&msg); err != nil {
        log.Fatalf("failed to send message to stream: %v", err)
    }
    time.Sleep(300 * time.Millisecond)
}
 
res, err := stream.CloseAndRecv()
if err != nil {
    log.Fatalf("failed to receive response from upload stream: %v", err)
}
log.Printf("Upload Stream Response: %s", res.Content)

Use Cases: Excellent for large data uploads, such as sending log files, IoT device metrics, or chunks of a video file.


4. Bidirectional Streaming RPC

The Bidirectional Streaming RPC is the most flexible model. Both the client and server can send a stream of messages to each other independently over a single gRPC connection. The order of reads and writes is not guaranteed and depends on the application logic.

Server Implementation

The server handler receives a stream that can be used for both reading and writing. It can read a message with Recv() and write a response with Send() at any time.

// server/main.go
func (s *server) ChatStream(stream chat.ChatService_ChatStreamServer) error {
    for {
        in, err := stream.Recv()
        if err == io.EOF {
            return nil
        }
        if err != nil {
            return err
        }
        log.Printf("Received message in ChatStream from %s: %s", in.User, in.Content)
 
        // Echo the message back
        if err := stream.Send(&chat.Message{User: "Server", Content: "Echo: " + in.Content}); err != nil {
            return err
        }
    }
}

Client Implementation

The client-side logic is more complex as it needs to handle sending and receiving concurrently. A common pattern is to use two separate goroutines: one for sending messages and one for receiving them. A channel or sync.WaitGroup is used to coordinate and signal completion.

// client/main.go
stream, err := c.ChatStream(context.Background()) // Use a background context
if err != nil {
    log.Fatalf("could not open chat stream: %v", err)
}
 
var wg sync.WaitGroup
wg.Add(2)
 
// Goroutine for sending messages
go func() {
    defer wg.Done()
    messages := []chat.Message{
        {User: "Marko", Content: "Hi!"},
        {User: "Marko", Content: "How are you?"},
        {User: "Marko", Content: "I'm using a bidi stream!"},
    }
    for _, msg := range messages {
        log.Printf("Sending message: %s", msg.Content)
        if err := stream.Send(&msg); err != nil {
            log.Fatalf("failed to send message: %v", err)
        }
        time.Sleep(time.Second)
    }
    stream.CloseSend() // IMPORTANT: Close the sending side of the stream
}()
 
// Goroutine for receiving messages
go func() {
    defer wg.Done()
    for {
        res, err := stream.Recv()
        if err == io.EOF {
            return // Server closed the stream
        }
        if err != nil {
            log.Fatalf("failed to receive message: %v", err)
        }
        log.Printf("Received from server: %s", res.Content)
    }
}()
 
wg.Wait()
log.Println("Bidirectional stream finished.")

Use Cases: The foundation for complex, interactive applications like real-time chat services, collaborative document editing, or live gaming sessions.


Conclusion

By mastering gRPC's four communication patterns, you unlock the ability to build highly efficient and specialized APIs tailored to your exact needs. While Unary RPCs cover many standard use cases, the streaming patterns provide the power to handle real-time data flow, large datasets, and fully interactive services. Choosing the right pattern is a critical step in designing a resilient and performant microservices architecture with Go and gRPC.


By Marko Leinikka

03 May 2025 at 03:00

Word count: 1514
7 min read