DEV Community

Cover image for Implementing Bidirectional gRPC Streaming : A Practical Guide
Yash Mahakal
Yash Mahakal

Posted on

Implementing Bidirectional gRPC Streaming : A Practical Guide

Table of Contents

Introduction

As systems growing to be the distributed,efficient communication becomes critical.While REST has long been the default, it often falls short in performance and flexibility—especially for real-time, service-to-service communication.

That's where gRPC comes in to play !
Developed by Google, gRPC offers fast, reliable communication using Protocol Buffers, with support for features like streaming and strong API contracts.

In this blog, we’ll briefly explore gRPC and Protobuf, compare them to traditional approaches, and demonstrate how bidirectional streaming can power real-time interactions in modern backend and cloud-native systems.

What is gRPC?

Modern applications often needs to communicate across services, languages, or machines. Traditional RESTAPI's can fall a bit short when its about- Low latency, high throughput, and efficient serialization

Built on top of HTTP/2 and using Protocol Buffers for data serialization, gRPC allows for fast, efficient, and strongly typed communication between services.

in gRPC, a client application written in a language (for example, Go) can directly call a method from the server residing on other machine and maybe written in another language than client, as if that method is its own local method.

Understanding Protocol Buffers

Protocol Buffers are a language-neutral, platform-neutral extensible mechanism for serializing structured data.Protocol buffers are the most commonly-used data format at Google. They are used extensively in inter-server communications as well as for archival storage of data on disk.

similar to JSON, but it is smaller and faster.. plus it generates native language bindings !

At its core, protobuff lets you define your data's structure once using a .proto file and then automatically generate code to read and write that data in various languages.

How do protocol buffer works ?

 image credits : https://protobuf.dev/overview/

The code generated by protocol buffers provides utility methods to retrieve data from files and streams, extract individual values from the data, check if data exists, serialize data back to a file or stream, and other useful functions.
Let’s say you want to define a simple User object that holds basic information like name and age. In Protocol Buffers, you would first create a .proto file to define this structure:
Step 1: Define the Data Structure

// person.proto
syntax = "proto3";

message Person {
  string name = 1;
  int32 age = 2;
}

Enter fullscreen mode Exit fullscreen mode

This defines a Person message with a name and age.

Step 2: Compile the .proto
Use protoc to generate code

protoc --go_out=. person.proto

Enter fullscreen mode Exit fullscreen mode

Here i have compiled .proto file for a Go code (as have used --go_out), but protobuff facilitates compiling into your required language.

How gRPC and Protobuf Work Together

By default, gRPC uses Protocol Buffers, Google’s mature open source mechanism for serializing structured data (although it can be used with other data formats such as JSON).

Defining Your Data
The first step when working with Protocol Buffers is to define the structure for the data you want to serialize in a .proto file. This is an ordinary text file that serves as your data contract. Protocol buffer data is structured as messages, where each message is a small logical record of information containing a series of name-value pairs called fields.
Here's one example :

message User {
  string user_id = 1;
  string name = 2;
  string email = 3;
}
Enter fullscreen mode Exit fullscreen mode

Once you’ve specified your data structures, you use the protocol buffer compiler, protoc, to generate data access classes in your preferred language (e.g., Python, Java, Go) from your .proto definition. These classes provide simple accessors for each field, like get_username() and set_username(), as well as methods to serialize the entire structure to and from raw bytes. So, if your chosen language is Python, running the compiler on the example above will generate a class called UserProfile.

Defining a Service
You define gRPC services in the same .proto files, specifying RPC methods with their parameter and return types as protocol buffer messages. This is where gRPC and Protobuf come together to define a complete API contract.
Here's an implementation of a service :

// The service definition for managing users.
service UserManagement {
  // Retrieves a user's profile by their ID.
  rpc GetUser(GetUserRequest) returns (User) {}
}

// The request message containing the user's ID.
message GetUserRequest {
  string user_id = 1;
}
Enter fullscreen mode Exit fullscreen mode

As you can see, the GetUser RPC method takes a GetUserRequest as its input and directly returns the User message we defined earlier as its output.

For gRPC, you use protoc with a special gRPC plugin. This process generates not only the regular protocol buffer code for your messages (User and GetUserRequest) but also the generated gRPC client and server code. This includes the client "stubs" you'll use to make remote calls and the server interfaces you'll implement to bring your service to life.

gRPC Service Methods

gRPC provides 4 kinds of service methods as listed below -

1. Unary RPC
This is the classic and most common type of RPC, working just like a standard function call.

How it works: The client sends a single request message to the server and waits for the response. The server processes the request and returns a single response message.

Example: rpc GetUser(GetUserRequest) returns (User) {}

2. Server Streaming RPC
This method is useful when the server needs to send a large amount of data or a series of notifications to the client.

How it works: The client sends a single request message to the server. In response, the server sends back a stream of messages. The client can read from the stream until there are no more messages.

Example: rpc ListUsers(ListUsersRequest) returns (stream User) {}

The stream keyword before the response type (User) indicates this is a server stream.

3. Client Streaming RPC
This is the reverse of server streaming and is ideal for sending large datasets from the client to the server.

How it works: The client sends a stream of messages to the server. Once the client has finished writing the messages, it waits for the server to process them and return a single response.

Example: rpc CreateUsersInBulk(stream CreateUserRequest) returns (BulkCreateResponse) {}

The stream keyword before the request type (CreateUserRequest) indicates this is a client stream.

4. Bidirectional Streaming RPC
This is the most flexible method, allowing for a full-duplex conversation between the client and server.

How it works: The client initiates the call and both the client and server can send a stream of messages to each other. The two streams operate independently, so the client and server can read and write in any order they like. It's often used for things like live chat or real-time data exchange.

Example: rpc LiveStatusUpdate(stream StatusRequest) returns (stream StatusResponse) {}

The stream keyword is used on both the request and the response types.

Implementing Bidirectional gRPC Streaming

This project is a real-time, terminal-based chat application built to demonstrate the language-agnostic capabilities of gRPC. It consists of three main components:

A Go-based Server (server/main.go): This is the central hub of the application. It listens for client connections and uses a bidirectional gRPC stream to manage the flow of chat messages. When a message is received from one client, the server broadcasts it to all other connected clients.

package main

import (
    "log"
    "net"
    "sync"

    pb "grpc-chat/proto"
    "google.golang.org/grpc"
)

type chatServer struct {
    pb.UnimplementedChatServiceServer
    mu      sync.Mutex
    streams map[string]pb.ChatService_ChatStreamServer
}

func newChatServer() *chatServer {
    return &chatServer{
        streams: make(map[string]pb.ChatService_ChatStreamServer),
    }
}

func (s *chatServer) ChatStream(stream pb.ChatService_ChatStreamServer) error {
    var user string

    for {
        msg, err := stream.Recv()
        if err != nil {
            log.Printf("Client disconnected or error: %v", err)
            s.mu.Lock()
            delete(s.streams, user)
            s.mu.Unlock()
            return err
        }

        user = msg.User
        log.Printf("[%s]: %s", user, msg.Message)

        s.mu.Lock()
        s.streams[user] = stream
        for u, st := range s.streams {
            if u != user {
                _ = st.Send(msg) // ignore error for simplicity
            }
        }
        s.mu.Unlock()
    }
}

func main() {
    lis, err := net.Listen("tcp", ":50053")
    if err != nil {
        log.Fatalf("Failed to listen: %v", err)
    }
    grpcServer := grpc.NewServer()
    pb.RegisterChatServiceServer(grpcServer, newChatServer())

    log.Println("Chat server running on port 50053...")
    if err := grpcServer.Serve(lis); err != nil {
        log.Fatalf("Failed to serve: %v", err)
    }
}


Enter fullscreen mode Exit fullscreen mode

A Go-based Client (client/main.go): This is theclient application that communicates with the Go server. It establishes a connection and opens a stream to both send user input and receive messages from the server.

package main

import (
    "bufio"
    "context"
    "fmt"
    "log"
    "os"
    "time"

    pb "grpc-chat/proto"
    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
)

func main() {
    conn, err := grpc.Dial("localhost:50053", grpc.WithTransportCredentials(insecure.NewCredentials()))
    if err != nil {
        log.Fatalf("Failed to connect: %v", err)
    }
    defer conn.Close()

    client := pb.NewChatServiceClient(conn)
    stream, err := client.ChatStream(context.Background())
    if err != nil {
        log.Fatalf("Failed to open stream: %v", err)
    }

    reader := bufio.NewReader(os.Stdin)

    fmt.Print("Enter your name: ")
    user, _ := reader.ReadString('\n')
    user = user[:len(user)-1]

    // Receiving goroutine
    go func() {
        for {
            msg, err := stream.Recv()
            if err != nil {
                log.Printf("Receive error: %v", err)
                break
            }
            fmt.Printf("\n[%s]: %s\n", msg.User, msg.Message)
        }
    }()

    // Sending loop
    for {
        fmt.Print("You: ")
        text, _ := reader.ReadString('\n')
        stream.Send(&pb.ChatMessage{
            User:      user,
            Message:   text,
            Timestamp: time.Now().Unix(),
        })
    }
}


Enter fullscreen mode Exit fullscreen mode

A Python-based Client (client.py): This is the python client, created to prove the core concept. It connects to the exact same Go server and performs the same functions as the Go client.

import grpc
import threading
import time

# Import the classes generated from your .proto file
import chat_pb2
import chat_pb2_grpc

def listen_for_messages(stub, response_iterator):
    """
    This function runs in a separate thread, continuously listening for messages
    from the server and printing them to the console.
    """
    try:
        # The 'for' loop will block and wait for new messages from the server
        for message in response_iterator:
            # When a message is received, print it and then re-print the input prompt
            print(f"\n[{message.user}]: {message.message.strip()}")
            print("You: ", end="", flush=True)
    except grpc.RpcError as e:
        # Handle cases where the server connection is lost
        print(f"\nConnection lost. Error receiving message: {e}")

def run():
    """
    Main function to set up the gRPC channel and manage the chat session.
    """
    # Establish a connection to the gRPC server (your existing Go server)
    with grpc.insecure_channel('localhost:50053') as channel:
        # Create a client "stub"
        stub = chat_pb2_grpc.ChatServiceStub(channel)

        user = input("Enter your name: ")

        def message_iterator():
            """
            An iterator that yields messages from the user's input.
            This is passed to the ChatStream method.
            """
            while True:
                text = input("You: ")
                message = chat_pb2.ChatMessage(
                    user=user,
                    message=text,
                    timestamp=int(time.time())
                )
                yield message

        # Call the ChatStream RPC. This returns an iterator for server responses.
        response_iterator = stub.ChatStream(message_iterator())

        # Start a new thread to listen for incoming messages from the server.
        # This allows you to type messages while simultaneously receiving them.
        threading.Thread(target=listen_for_messages, args=(stub, response_iterator), daemon=True).start()

        # Keep the main thread alive to allow the background thread to continue running.
        # The program will exit if the main thread finishes.
        try:
            while threading.main_thread().is_alive():
                time.sleep(1)
        except KeyboardInterrupt:
            print("\nExiting chat.")

if __name__ == '__main__':
    run()

Enter fullscreen mode Exit fullscreen mode

The entire system is defined by a single Protobuf file (proto/chat.proto), which acts as a universal contract for the service's API and message structure. The key takeaway of this project is to show that as long as both the client and server adhere to this contract, their underlying programming languages don't matter, enabling seamless, high-performance communication in a mixed-language environment.

syntax = "proto3";

package chat;

option go_package = "grpc-chat/proto";

service ChatService {
  rpc ChatStream(stream ChatMessage) returns (stream ChatMessage);
}

message ChatMessage {
  string user = 1;
  string message = 2;
  int64 timestamp = 3;
}


Enter fullscreen mode Exit fullscreen mode

Project Directory :

Running the Project Locally

Before you start, make sure you have the following installed on your computer:

Go: Version 1.20 or later.

Python: Version 3.6 or later.

Install Dependencies & Generate Code
You'll need to install the dependencies for both Go and Python and then generate the Python-specific gRPC code from the .proto file.

Go Dependencies
Open your terminal in the root directory and run the following command. This will download the Go modules (like gRPC and Protobuf) listed in your go.mod file.

go mod tidy
Enter fullscreen mode Exit fullscreen mode

Python Dependencies & Code Generation
Next, set up the Python environment.

# 1. Create and activate a Python virtual environment
python -m venv venv
source venv/bin/activate  # On Windows, use: .\\venv\\Scripts\\activate

# 2. Install the required Python packages
pip install grpcio grpcio-tools

# 3. Generate the Python gRPC code from the .proto file
python -m grpc_tools.protoc -I./proto --python_out=. --grpc_python_out=. ./proto/chat.proto
Enter fullscreen mode Exit fullscreen mode

After running the last command, you will see two new files in your project's root directory: chat_pb2.py and chat_pb2_grpc.py.

Start the Go Server (Terminal 1)

go run server/main.go

# Expected output:
# [DATE] [TIME] Chat server running on port 50053...
Enter fullscreen mode Exit fullscreen mode

Run the Clients (Terminal 2 and beyond)
To run the Python client, open a new terminal, activate the virtual environment, and run:

# Make sure you are in the venv
# source venv/bin/activate 

python client.py
Enter fullscreen mode Exit fullscreen mode

To run the original Go client, open another new terminal and run:

go run client/main.go
Enter fullscreen mode Exit fullscreen mode

Bidirectional Streaming in Action

Go Server

Go Client

Python Client

You can start as many clients as you like. Messages sent from any client will appear in all other connected clients, demonstrating that your Go server is successfully communicating with clients written in different languages.

Why gRPC Is A Better Choice ?

Here's why many teams pick gRPC over other options for their services.

1. It's Fast. Really Fast. gRPC uses a modern protocol (HTTP/2) and sends data in a compact binary format. This means less data over the wire and faster communication compared to sending bulky JSON text.

2. Clear Rules, Fewer Bugs. The .proto file acts as a strict contract between the server and client. It gets rid of guesswork about data types and structures, so you spend less time debugging mismatches.

3. Built for Real-Time. Unlike traditional APIs, gRPC is designed for streaming. It can handle continuous data flows in either direction, which is perfect for live updates, chat apps, or IoT devices.

4. Works With Any Language. As you saw in this project, you can have a server in Go, a client in Python, and another service in Java, and they all talk to each other without a hitch. It gives teams the freedom to use the best tool for the job.

5.Built-in Deadline and Cancellation Control. gRPC lets clients specify how long they are willing to wait for a response. If the deadline is exceeded, the request is automatically canceled on both the client and server. This "cancellation propagation" prevents wasted resources on abandoned requests, making the entire system more resilient and predictable.

References

https://grpc.io/docs/
https://protobuf.dev/overview/

I'd love to hear your thoughts in the comments below. What has your experience been with building polyglot systems?

Thanks for following along, and happy coding!

Top comments (1)

Collapse
 
atharvaralegankar profile image
Info Comment hidden by post author - thread only accessible via permalink
Atharva Ralegankar

Heyyy remember me? The other person in this project? Yeah, still waiting on that ZIP file you’ve been hiding like national treasure

Some comments have been hidden by the post's author - find out more