๐Ÿ” The Four RPC Types

One keyword โ€” stream โ€” on either side of the arrow gives you four call shapes. Each is still a single HTTP/2 stream underneath.

The Whole Picture

Put stream before the request type, the response type, both, or neither. That's the entire taxonomy:

flowchart LR subgraph Request direction TB R1["one"] ; R2["many (stream)"] end subgraph Response direction TB P1["one"] ; P2["many (stream)"] end R1 --> U["Unary"] R1 --> SS["Server streaming"] R2 --> CS["Client streaming"] R2 --> BD["Bidirectional"]
service ChatService {
  rpc GetProfile(ProfileRequest) returns (Profile);                       // unary
  rpc ListMessages(RoomRequest) returns (stream Message);                 // server streaming
  rpc UploadPhotos(stream PhotoChunk) returns (UploadSummary);            // client streaming
  rpc Chat(stream ChatEvent) returns (stream ChatEvent);                  // bidirectional
}
Message ordering is guaranteed within a stream. gRPC delivers messages in the order they were sent because they ride one ordered HTTP/2 stream. Across different RPCs there is no ordering.

1 ยท Unary โ€” Request โ†’ Response

The ordinary function call. One message in, one message out. 90% of your methods.

sequenceDiagram participant C as Client participant S as Server C->>S: ProfileRequest S-->>C: Profile Note over C,S: stream closes with grpc-status
# Server
class ChatService(chat_pb2_grpc.ChatServiceServicer):
    def GetProfile(self, request, context):
        return chat_pb2.Profile(user_id=request.user_id, name="Ada")

# Client
resp = stub.GetProfile(chat_pb2.ProfileRequest(user_id="u1"))
print(resp.name)

2 ยท Server Streaming โ€” Request โ†’ many Responses

Client sends one request; server writes a sequence of messages, then closes. Great for large result sets, feeds, and progress updates โ€” the client processes each item as it lands instead of waiting for the whole batch.

sequenceDiagram participant C as Client participant S as Server C->>S: RoomRequest S-->>C: Message 1 S-->>C: Message 2 S-->>C: Message N Note over S: server closes stream
# Server: yield each message
def ListMessages(self, request, context):
    for msg in db.iter_messages(request.room_id):
        yield chat_pb2.Message(id=msg.id, body=msg.body)

# Client: iterate the response
for msg in stub.ListMessages(chat_pb2.RoomRequest(room_id="r1")):
    print(msg.body)
Real-world: exporting a million rows. Unary would buffer them all in memory on both ends; server streaming lets each side hold one row at a time. Pair with a deadline so a stuck stream can't hang forever.

3 ยท Client Streaming โ€” many Requests โ†’ Response

Client writes a sequence, server responds once at the end. Ideal for uploads, batch ingestion, or aggregations where the server only needs the final answer.

sequenceDiagram participant C as Client participant S as Server C->>S: PhotoChunk 1 C->>S: PhotoChunk 2 C->>S: PhotoChunk N Note over C: client half-closes S-->>C: UploadSummary
# Server: request_iterator drains the client's stream
def UploadPhotos(self, request_iterator, context):
    total = 0
    for chunk in request_iterator:
        total += len(chunk.data)
    return chat_pb2.UploadSummary(bytes_received=total)

# Client: pass an iterator/generator of requests
def gen():
    for part in read_file_in_chunks("photo.jpg"):
        yield chat_pb2.PhotoChunk(data=part)

summary = stub.UploadPhotos(gen())
print(summary.bytes_received)
Half-close: the client signals "I'm done sending" by ending its iterator. That's the trigger for the server to compute and return its single response.

4 ยท Bidirectional Streaming โ€” many โ†” many

Both sides read and write independently on the same stream, in any interleaving. This is full-duplex: the server can push before the client finishes sending. Chat, live collaboration, and streaming RPC pipelines.

sequenceDiagram participant C as Client participant S as Server C->>S: ChatEvent (join) S-->>C: ChatEvent (welcome) C->>S: ChatEvent (message) S-->>C: ChatEvent (broadcast) S-->>C: ChatEvent (other user) C->>S: ChatEvent (message)
# Server: read and write concurrently on one context
def Chat(self, request_iterator, context):
    for event in request_iterator:          # incoming from client
        for out in room.route(event):       # may fan out
            yield out                        # outgoing to client

# Client: send a generator, receive an iterator, both live at once
responses = stub.Chat(outgoing_events())
for event in responses:
    render(event)
Ordering caveat: the two directions are independent. Message k the client sends and message k the server sends are not correlated by position โ€” if you need request/response pairing inside a bidi stream, put a correlation id in the message.

Cheat Sheet

Type.protoClient sendsServer sendsUse for
Unary(Req) returns (Res)11Normal calls
Server stream(Req) returns (stream Res)1NFeeds, large results
Client stream(stream Req) returns (Res)N1Uploads, aggregation
Bidirectional(stream Req) returns (stream Res)NNChat, live sync
Real-world: streaming isn't free concurrency โ€” a single stream is still ordered and single-threaded per direction. If you want parallel independent work, use many unary calls (HTTP/2 multiplexes them anyway). Reach for streaming when the data is genuinely a sequence.