14 — Server streaming and live updates

Goal

Let a Flutter client watch one project's tasks and receive changes while the screen is open.

Step 1: Define the stream contract

Add these messages and RPC to task.proto:

message WatchTasksRequest { string project_id = 1; }
message TaskEvent {
  string event_id = 1;
  Task task = 2;
}
service TaskService {
  // Keep the existing RPCs here.
  rpc WatchTasks(WatchTasksRequest) returns (stream TaskEvent);
}

Regenerate the Go and Dart clients. A server stream has one request and many responses; it does not make those events durable.

Use a server stream when the client needs a sequence of updates from one subscription. It is different from polling, where the client repeatedly asks for changes, and different from a durable queue, where events can be replayed after disconnection. The request scopes the subscription; each response carries one event.

Step 2: Create a bounded in-memory event hub

For the learning implementation, create a hub that stores subscribers by project_id. Each subscriber gets a bounded channel (for example, capacity 32). When a task changes, publish to subscribers for that same project. If a subscriber's queue is full, remove it or close the stream with ResourceExhausted; never allow an unbounded queue to grow in memory.

Protect the subscriber registry with a mutex. Copy the target subscribers under the lock, then send without holding the registry lock. Remove each subscriber in a defer when its stream ends. Publish only after the task write succeeds.

The hub is a small pub/sub system: publishers announce a task event, and subscribers receive events matching their project. Bounded channels provide backpressure. If a client cannot keep up, the server needs an explicit policy instead of silently consuming more and more memory. Holding a registry mutex while sending can deadlock if a slow client blocks publication, so use the lock only to manage membership.

Step 3: Implement the server stream

Inspect the generated TaskServiceServer interface to see the exact stream type for your pinned grpc-go generator. Implement WatchTasks by validating project_id, registering a subscriber, deferring its removal, then selecting between the stream context cancellation and events from its channel. For each event, call the generated stream's Send method and return any send error.

Step 4: Subscribe in Flutter

The generated Dart stub returns a response stream. In the project screen's state, subscribe in initState, listen to events, and merge each event into local task state by ID. Cancel the StreamSubscription in dispose.

When the stream ends unexpectedly, reconnect with capped exponential backoff (for example, 1, 2, 4, then at most 30 seconds). After reconnecting, call ListTasks to reload a snapshot because updates may have been missed while disconnected. Do not retry forever on PermissionDenied or Unauthenticated; those require a user/auth action.

The stream subscription belongs to the screen or repository that needs the updates. Canceling it when the screen closes releases local resources and causes the Go stream context to end. Backoff adds a growing pause between failed connection attempts, reducing load during an outage. Reloading a snapshot after reconnect repairs gaps that a transient stream cannot fill.

Step 5: Verify lifecycle and isolation

Run two app clients in the same project. Create a task in one and watch the event appear in the other. Open a different project and confirm it receives no event. Navigate away and confirm the server removes the stream subscriber.

Checkpoint

Kill the Go server while Flutter is watching, restart it, and verify the app reconnects and reloads state. Fill a subscriber queue in a test and confirm memory stays bounded.

Important limitation

The in-memory event hub only works inside one server process. Multiple replicas need a shared broker or durable event store. If events must never be missed, persist them and support resume cursors rather than relying on a live stream alone.

results matching ""

    No results matching ""