backend / protocols / grpc / 03_service_types_streaming.md

gRPC Service Types — Unary and Streaming

7 interview angles 7 min read source

gRPC Service Types — Unary and Streaming

gRPC defines four RPC patterns. Unary is the obvious one (call function, get result). The three streaming variants are gRPC’s distinguishing feature — first-class streaming without bolting on WebSocket/SSE.

The four method types

service Example {
  // 1. Unary
  rpc GetUser (GetUserRequest) returns (User);

  // 2. Server streaming
  rpc ListUpdates (ListUpdatesRequest) returns (stream Update);

  // 3. Client streaming
  rpc UploadFile (stream FileChunk) returns (UploadResult);

  // 4. Bidirectional streaming
  rpc Chat (stream ChatMessage) returns (stream ChatMessage);
}

The stream keyword indicates streaming on that side.

1. Unary RPC

rpc GetUser (GetUserRequest) returns (User);

Client sends one request; server sends one response. Like an HTTP call.

# Client
response = stub.GetUser(GetUserRequest(id="42"))
print(response.name)

# Server
class UserServiceServicer(user_pb2_grpc.UserServiceServicer):
    def GetUser(self, request, context):
        user = db.get_user(request.id)
        if not user:
            context.set_code(grpc.StatusCode.NOT_FOUND)
            context.set_details("User not found")
            return User()
        return User(id=user.id, name=user.name, email=user.email)

90% of gRPC calls. Same as REST functionally, just typed and binary.

2. Server streaming

rpc ListUpdates (UpdatesFilter) returns (stream Update);

Client sends one request; server sends many responses over time. Server holds the stream open until done.

# Client — iterator over the stream
for update in stub.ListUpdates(UpdatesFilter(since="2024-01-01")):
    print(update.id, update.body)

# Server — yield from the generator
def ListUpdates(self, request, context):
    for update in db.iter_updates(since=request.since):
        yield Update(id=update.id, body=update.body)
        if context.is_active() is False:
            return    # client disconnected

Use cases:

  • Pagination as a stream (avoid pagination args entirely).
  • Server-pushed updates (price ticks, log lines, sensor readings).
  • Large result sets that you want to process incrementally without buffering.

The stream completes when the server’s generator exits. The client sees the iterator end.

3. Client streaming

rpc UploadFile (stream FileChunk) returns (UploadResult);

Client sends many requests over time; server sends one response when client is done.

# Client — pass an iterable of requests
def chunks():
    with open("big.zip", "rb") as f:
        while data := f.read(64 * 1024):
            yield FileChunk(content=data)

result = stub.UploadFile(chunks())
print(result.bytes_received)

# Server — receive the stream
def UploadFile(self, request_iterator, context):
    total = 0
    with open("/tmp/upload", "wb") as f:
        for chunk in request_iterator:
            f.write(chunk.content)
            total += len(chunk.content)
    return UploadResult(bytes_received=total, file_id="...")

Use cases:

  • File uploads (stream chunks, server writes to disk).
  • Bulk imports.
  • Sensor data ingestion.

The single response comes after the client finishes streaming.

4. Bidirectional streaming

rpc Chat (stream ChatMessage) returns (stream ChatMessage);

Both client and server stream independently. The streams are decoupled — either side can send at any time.

# Client
def outgoing():
    yield ChatMessage(text="hello")
    yield ChatMessage(text="how are you?")
    # ... more

responses = stub.Chat(outgoing())
for msg in responses:
    print(f"server says: {msg.text}")

# Server
def Chat(self, request_iterator, context):
    for msg in request_iterator:
        # process incoming
        ...
        yield ChatMessage(text=f"got: {msg.text}")

In practice the in/out iterators run concurrently. Asyncio-style:

# Async client
async def chat():
    async with stub.Chat() as stream:
        async def send():
            for msg in outgoing_messages:
                await stream.send(msg)
            await stream.done_writing()

        async def receive():
            async for msg in stream:
                print(msg.text)

        await asyncio.gather(send(), receive())

Use cases:

  • Real-time chat / collaboration.
  • Bidirectional sync protocols.
  • Anything you’d use a WebSocket for at the application layer.

When to use streaming

Streaming is the right answer when:

  1. The dataset doesn’t fit in memory — large lists, file uploads, etc. Streaming lets you process incrementally.
  2. You want pushed updates — server tells client when something happens, instead of client polling.
  3. The protocol is conversational — multiple back-and-forth messages, like chat.
  4. Latency matters more than total throughput — get first-byte fast; subsequent messages flow.

When unary is better:

  • Each call is independent — no point streaming if the client just makes one request.
  • The result is small — overhead of streaming vs unary isn’t justified.
  • Caching matters — unary responses are cacheable (kind of); streams aren’t.

Backpressure

When the server sends faster than the client can read, network buffers fill up. gRPC handles this via HTTP/2’s flow control — the receiver advertises its window; the sender doesn’t exceed it.

In code: writing to a stream may block when the window is full. Async clients/servers await these writes; sync ones block the thread.

You don’t usually manage backpressure manually — the library does. But know it exists: a slow consumer slows the producer automatically.

Cancellation

Either side can cancel a streaming RPC:

# Client
call = stub.ListUpdates(filter)
for update in call:
    if some_condition:
        call.cancel()
        break

Server checks context.is_active():

def ListUpdates(self, request, context):
    for u in db.iter_updates():
        if not context.is_active():
            return
        yield u

Cancellation propagates: cancelled stream on one side terminates the other. Important for not wasting work on the server when the client gave up.

Deadlines / timeouts

# Client — set deadline
response = stub.GetUser(GetUserRequest(id="42"), timeout=5.0)

# Server — check deadline
def GetUser(self, request, context):
    if context.time_remaining() < 1.0:
        # not enough time to process; bail
        context.abort(grpc.StatusCode.DEADLINE_EXCEEDED, "Not enough time")
    return User(...)

Deadlines propagate across services. If A calls B with a 5-second deadline and B calls C, C should know B has 3 seconds left, not start a fresh 5-second timer. Pass the deadline along (gRPC does this automatically in metadata).

For streaming: the deadline applies to the entire stream unless you reset it. Long-lived streams (hours) need no deadline (or very large) and rely on cancellation for termination.

Pagination vs server streaming

Two ways to “give me 10000 records”:

Pagination (unary, repeated calls):

rpc ListUsers (ListUsersRequest) returns (ListUsersResponse);

message ListUsersRequest {
  int32 limit = 1;
  string page_token = 2;
}

message ListUsersResponse {
  repeated User users = 1;
  string next_page_token = 2;
}

Server streaming:

rpc StreamUsers (UsersFilter) returns (stream User);

Pagination:

  • Works with HTTP caches.
  • Independent calls (retry safe).
  • Stateful client (track page tokens).
  • Latency: must wait for full page each time.

Server streaming:

  • Push-as-ready: client gets first record fast.
  • Server-side pacing (backpressure).
  • One long-lived connection (state, harder to load-balance).
  • No mid-stream retry.

For “fetch all users to populate a UI list”: server streaming. For “show me 50 users, then maybe 50 more if the user scrolls”: pagination.

gRPC streaming vs WebSocket vs SSE

gRPC streaming WebSocket SSE
Transport HTTP/2 upgraded HTTP HTTP/1 long response
Browser native no (gRPC-Web limited) yes yes
Bidirectional yes (bidi) yes no (server → client only)
Binary yes (Protobuf) yes text only
Multiplexing many streams per conn one per WebSocket one per HTTP conn
Best for service-to-service streaming browser real-time browser one-way push

For browser real-time: WebSocket or SSE. For service-to-service real-time: gRPC streaming.

Common pitfalls

  • Server streaming without cancellation handling — client disconnects, server keeps producing forever.
  • Bidi streaming without explicit close — both sides waiting indefinitely.
  • No deadlines — slow downstream calls cascade into long-held resources upstream.
  • Trying to stream very small results — unary is simpler and same speed.
  • Heavy state per stream — long-lived streams pile up server-side state. Cap concurrent streams; clean up on disconnect.
  • Mixing streaming with REST mindset — streams aren’t request/response with retries; they’re durable pipes.

Common interview confusions

  • “Streaming is faster than unary.” — depends. Streaming’s win is incremental delivery and decoupled timing, not raw throughput.
  • “Client streaming sends multiple responses.” — no, ONE response. The client streams; the server replies once.
  • “All streaming RPCs are bidirectional.” — three flavors: server-streaming (1 req, many resp), client-streaming (many req, 1 resp), bidi (many both).

Interview angle

  • “What are the four gRPC method types?” — unary (1→1), server streaming (1→N), client streaming (N→1), bidirectional streaming (N↔N). Streaming sides use the stream keyword in the .proto.
  • “When would you use server streaming?” — server-pushed updates (price ticks, log streams, sensor data), incremental delivery of large result sets, replacing pagination when caching isn’t important.
  • “When would you use client streaming?” — file uploads (chunked), bulk import (stream records to ingest), telemetry batching where the client knows when it’s done.
  • “Bidi streaming use case?” — real-time interactive sessions: chat, collaborative editing, anything where both sides send messages at independent times. Replaces WebSockets for service-to-service.
  • “What’s backpressure in gRPC?” — HTTP/2 flow control: the receiver advertises a window; the sender can’t send beyond it. Slow consumers automatically slow producers; you don’t manage manually.
  • “How are deadlines different from timeouts?” — a deadline is an absolute point in time, propagated through service calls. A 5-second deadline set by A is shared with B and C downstream — they each see how much time remains, not a fresh 5 seconds each.
  • “Streaming vs pagination — which when?” — streaming for “process everything as it comes” with no need to resume. Pagination for “give me a chunk, I’ll ask for more if I scroll” — independent calls, retryable, cacheable.