Build Real-Time SwiftUI Features With gRPC Swift 2
Polling is the default shape I still see in a lot of “live” app features. The order detail screen appears, the app fetches /orders/123, waits three seconds, fetches it again, waits three seconds, and keeps doing that until the user leaves.
It works until it does not. It burns radio time when nothing changed, makes status transitions feel late, and gives you awkward edge cases when the app backgrounds halfway through a request. For a live order tracker, I would rather do this:
- Make one unary call to bootstrap the screen with the current order.
- Open one stream for ordered updates.
- Resume from the last update id after reconnecting.
- Cancel intentionally when the scene leaves the foreground.
That is the kind of feature Apple focuses on in Build real-time apps and services with gRPC and Swift. It also fits the direction of the Swift ecosystem: Swift.org’s Networking Workgroup announcement calls out safe, modular, cross-platform networking APIs as a long-term goal, and the grpc-swift-2 repo is the current home for the Swift gRPC runtime.
One caveat before the code: gRPC Swift 2, grpc-swift-protobuf, and the generated Swift names are still worth checking against the exact package versions in your project. The snippets below follow the current public v2 shape: GRPCCore, GRPCNIOTransportHTTP2, GRPCProtobufGenerator, withGRPCClient, and generated Package_Service.Client(wrapping:) stubs. I did not compile these snippets inside this blog repo, so treat the generated type names as the pattern to verify, not a promise that every symbol is spelled exactly the same in your target.
Start with the service contract
The .proto file should describe the user experience, not just the database table. For an order tracker, the app needs a complete snapshot first, then small incremental updates.
syntax = "proto3";
package orders.v1;
import "google/protobuf/timestamp.proto";
service OrderTracking {
rpc GetOrder(GetOrderRequest) returns (OrderSnapshot);
rpc WatchOrder(WatchOrderRequest) returns (stream OrderUpdate);
// Use this instead of WatchOrder when the client needs to change
// subscriptions without closing the stream.
rpc FollowOrder(stream FollowOrderRequest) returns (stream OrderUpdate);
}
message GetOrderRequest {
string order_id = 1;
}
message WatchOrderRequest {
string order_id = 1;
string since_update_id = 2;
}
message FollowOrderRequest {
string order_id = 1;
string since_update_id = 2;
repeated EventKind include = 3;
}
message OrderSnapshot {
string order_id = 1;
OrderStatus status = 2;
string eta_display = 3;
repeated TimelineEvent timeline = 4;
string latest_update_id = 5;
}
message OrderUpdate {
string update_id = 1;
google.protobuf.Timestamp occurred_at = 2;
oneof event {
StatusChanged status_changed = 3;
EtaChanged eta_changed = 4;
CourierMoved courier_moved = 5;
TimelineEvent timeline_event = 6;
}
}
message StatusChanged {
OrderStatus status = 1;
}
message EtaChanged {
string eta_display = 1;
}
message CourierMoved {
double latitude = 1;
double longitude = 2;
}
message TimelineEvent {
string id = 1;
string title = 2;
string subtitle = 3;
google.protobuf.Timestamp occurred_at = 4;
}
enum OrderStatus {
ORDER_STATUS_UNSPECIFIED = 0;
ORDER_STATUS_RECEIVED = 1;
ORDER_STATUS_PREPARING = 2;
ORDER_STATUS_OUT_FOR_DELIVERY = 3;
ORDER_STATUS_DELIVERED = 4;
ORDER_STATUS_CANCELED = 5;
}
enum EventKind {
EVENT_KIND_UNSPECIFIED = 0;
EVENT_KIND_STATUS = 1;
EVENT_KIND_ETA = 2;
EVENT_KIND_COURIER = 3;
EVENT_KIND_TIMELINE = 4;
}
There are two details here that matter more than the syntax.
First, GetOrder gives the view a stable initial state. Do not make the SwiftUI screen wait for the next stream event before it can render the current status.
Second, every update has an update_id. That lets the app reconnect with since_update_id after the scene becomes active again. Without that, reconnects turn into guesswork: did the courier move once while the app was backgrounded, or did the order move from preparing to delivery to canceled?
Generate the Swift client
In a Swift package target, the dependency shape looks like this:
// swift-tools-version: 6.1
import PackageDescription
let package = Package(
name: "OrderTrackingFeature",
platforms: [
.iOS(.v18)
],
dependencies: [
.package(url: "https://github.com/grpc/grpc-swift-2.git", from: "2.0.0"),
.package(url: "https://github.com/grpc/grpc-swift-nio-transport.git", from: "2.0.0"),
.package(url: "https://github.com/grpc/grpc-swift-protobuf.git", from: "2.4.0")
],
targets: [
.target(
name: "OrderTrackingFeature",
dependencies: [
.product(name: "GRPCCore", package: "grpc-swift-2"),
.product(name: "GRPCNIOTransportHTTP2", package: "grpc-swift-nio-transport"),
.product(name: "GRPCProtobuf", package: "grpc-swift-protobuf")
],
plugins: [
.plugin(name: "GRPCProtobufGenerator", package: "grpc-swift-protobuf")
]
)
]
)
For an Xcode app target, the same idea applies through Package Dependencies and the target’s build tool plugins. Put the .proto file in the target, add the GRPCProtobufGenerator plugin, and include the generator configuration expected by the package version you pinned. Conceptually, you want clients and messages for the app target, not server implementations:
{
"generate": {
"clients": true,
"servers": false,
"messages": true
}
}
For an app target, I usually generate clients and messages only. The backend target can generate servers too, but the iOS app does not need service implementations in its binary.
Treat that JSON as illustrative. The generator config file name and schema can change across grpc-swift-protobuf releases, so copy the current package documentation for the version you actually use.
After generation, a package like orders.v1 with a service named OrderTracking typically gives you names in this family:
Orders_V1_OrderTracking.Client
Orders_V1_GetOrderRequest
Orders_V1_WatchOrderRequest
Orders_V1_OrderSnapshot
Orders_V1_OrderUpdate
That generated code is the payoff. Your SwiftUI feature is no longer hand-building paths, query strings, JSON payloads, and response structs that can drift away from the server contract.
Keep client creation out of the view
The official examples use withGRPCClient with the HTTP/2 NIO transport, then wrap the raw gRPC client in a generated service client. I like to hide that in one small type so views and stores do not know how the transport is made.
import GRPCCore
import GRPCNIOTransportHTTP2
struct OrderClientManager: Sendable {
var host: String
var port: Int
func withOrderClient<Result: Sendable>(
_ operation: (Orders_V1_OrderTracking.Client) async throws -> Result
) async throws -> Result {
try await withGRPCClient(
transport: .http2NIOPosix(
target: .dns(host: host, port: port),
transportSecurity: .plaintext
)
) { grpcClient in
let orderClient = Orders_V1_OrderTracking.Client(wrapping: grpcClient)
return try await operation(orderClient)
}
}
}
Use .plaintext for a local development server only. Production should use TLS, and your real manager is also where I would centralize auth metadata, interceptors, deadlines, logging, and environment-specific hosts.
The important lifecycle rule is that the gRPC client stays alive for the whole operation closure. For a live screen, that closure should cover both the unary bootstrap and the stream.
Bootstrap, then stream
Here is a SwiftUI-facing store that owns the stream task. It starts once, applies the current snapshot, listens for updates, and retries transient failures with a small backoff.
import Observation
import SwiftUI
enum OrderConnectionState: Equatable {
case idle
case connecting
case live
case retrying
case failed(String)
}
@MainActor
@Observable
final class OrderStreamStore {
var order: OrderViewState?
var connectionState: OrderConnectionState = .idle
private let orderID: String
private let manager: OrderClientManager
private var streamTask: Task<Void, Never>?
private var lastUpdateID: String?
init(orderID: String, manager: OrderClientManager) {
self.orderID = orderID
self.manager = manager
}
func start() {
guard streamTask == nil else { return }
streamTask = Task { [weak self] in
await self?.runUntilCancelled()
}
}
func stop() {
streamTask?.cancel()
streamTask = nil
connectionState = .idle
}
private func runUntilCancelled() async {
var attempt = 0
while !Task.isCancelled {
do {
try await connectOnce()
attempt += 1
connectionState = .retrying
try? await Task.sleep(for: retryDelay(for: attempt))
} catch is CancellationError {
break
} catch {
attempt += 1
connectionState = .retrying
let delay = retryDelay(for: attempt)
try? await Task.sleep(for: delay)
}
}
}
private func connectOnce() async throws {
connectionState = .connecting
try await manager.withOrderClient { client in
let snapshot = try await client.getOrder(
Orders_V1_GetOrderRequest.with {
$0.orderID = orderID
}
)
apply(snapshot)
let request = Orders_V1_WatchOrderRequest.with {
$0.orderID = orderID
if let lastUpdateID {
$0.sinceUpdateID = lastUpdateID
}
}
try await client.watchOrder(request) { response in
connectionState = .live
for try await update in response.messages {
try Task.checkCancellation()
apply(update)
}
}
}
}
private func apply(_ snapshot: Orders_V1_OrderSnapshot) {
lastUpdateID = snapshot.latestUpdateID
order = OrderViewState(snapshot)
}
private func apply(_ update: Orders_V1_OrderUpdate) {
lastUpdateID = update.updateID
order?.apply(update)
}
private func retryDelay(for attempt: Int) -> Duration {
let cappedAttempt = min(attempt, 5)
return .seconds(1 << cappedAttempt)
}
}
There are a few choices hiding in that snippet.
The store treats cancellation as normal. Leaving the screen, backgrounding the scene, or switching accounts should not look like an error alert.
The store retries after failures and after a stream ends cleanly. A stream that returns is no longer delivering live updates, so the sample reconnects with backoff instead of spinning immediately. A production app should add jitter, cap the delay, and avoid retrying auth failures that require user action.
The store resumes from lastUpdateID. That means the server needs to keep a short update history or translate the resume token into a fresh snapshot plus remaining events. If the token is too old, the server can return a clear error and the app can run GetOrder again.
The UI model is intentionally separate from generated protobuf messages:
struct OrderViewState: Identifiable, Equatable {
var id: String
var statusText: String
var etaText: String
var timeline: [OrderTimelineRow]
var courierCoordinate: Coordinate?
init(_ snapshot: Orders_V1_OrderSnapshot) {
self.id = snapshot.orderID
self.statusText = displayText(for: snapshot.status)
self.etaText = snapshot.etaDisplay
self.timeline = snapshot.timeline.map(OrderTimelineRow.init)
self.courierCoordinate = nil
}
mutating func apply(_ update: Orders_V1_OrderUpdate) {
switch update.event {
case .statusChanged(let event):
statusText = displayText(for: event.status)
case .etaChanged(let event):
etaText = event.etaDisplay
case .courierMoved(let event):
courierCoordinate = Coordinate(
latitude: event.latitude,
longitude: event.longitude
)
case .timelineEvent(let event):
timeline.append(OrderTimelineRow(event))
case .none:
break
}
}
}
I do not like letting generated transport models leak all the way into SwiftUI views. A tiny view state type gives you freedom to format statuses, hide unknown enum values, collapse duplicate timeline rows, and change your UI without changing the service contract.
Wire it to scene phase
The scene phase should control whether the stream is allowed to exist. iOS can suspend your app in the background, and a delivery tracker should not pretend it has an immortal socket.
struct OrderTrackingView: View {
@Environment(\.scenePhase) private var scenePhase
@State private var store: OrderStreamStore
init(orderID: String, manager: OrderClientManager) {
_store = State(
initialValue: OrderStreamStore(
orderID: orderID,
manager: manager
)
)
}
var body: some View {
List {
if let order = store.order {
Section {
LabeledContent("Status", value: order.statusText)
LabeledContent("ETA", value: order.etaText)
}
Section("Timeline") {
ForEach(order.timeline) { row in
VStack(alignment: .leading) {
Text(row.title)
Text(row.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
} else {
ProgressView()
}
}
.overlay(alignment: .bottom) {
ConnectionBanner(state: store.connectionState)
}
.navigationTitle("Order")
.onChange(of: scenePhase, initial: true) { _, phase in
switch phase {
case .active:
store.start()
case .inactive, .background:
store.stop()
@unknown default:
store.stop()
}
}
.onDisappear {
store.stop()
}
}
}
This is where the polling version usually gets messy. With polling, you are juggling timers, in-flight requests, stale responses, and app lifecycle. With a stream, the rule is simpler: active scene means a stream may run; inactive or background means cancel it. When the scene becomes active again, the store reconnects and asks for anything after the last update it applied.
For background-visible experiences, pair this with push notifications, Live Activities, or another system surface. A foreground gRPC stream is great for an open screen. It is not a replacement for background delivery guarantees.
When bidirectional streaming is worth it
The WatchOrder RPC is server streaming: the client sends one request, then the server sends many updates. That is perfect when the subscription does not change.
Use the FollowOrder bidirectional RPC when the app needs to change its request while the stream is open. For example, the user might open a map sheet that asks for high-frequency courier coordinates, then close it and keep only status and ETA updates.
try await client.followOrder { writer in
for await selection in selectionStream {
try Task.checkCancellation()
try await writer.write(
Orders_V1_FollowOrderRequest.with {
$0.orderID = orderID
$0.sinceUpdateID = lastUpdateID ?? ""
$0.include = selection.eventKinds
}
)
}
} onResponse: { response in
for try await update in response.messages {
try Task.checkCancellation()
apply(update)
}
}
That bidirectional snippet is intentionally schematic. Generated streaming method signatures are one of the areas most likely to differ by package version and generator options; verify the exact writer/response closure shape in the generated client before building your store around it.
In SwiftUI, selectionStream can be an AsyncStream fed by view state changes. The trick is to keep the stream input small and semantic. Send “include courier updates” or “include timeline updates”, not every tiny UI interaction.
Shipping caveats
Authentication should live in metadata or interceptors, not inside the protobuf messages for each call. If a token expires while a stream is active, decide whether the client can refresh silently and reconnect or whether it should stop and ask the user to sign in again.
TLS is table stakes outside local development. Make sure the production transport uses TLS, the server supports HTTP/2 correctly, and any proxy or load balancer in front of it allows long-lived gRPC streams. Watch idle timeouts carefully.
Backoff needs jitter. The simple 1, 2, 4, 8... retry loop above is easy to read, but a real app should avoid synchronized reconnect storms after a deploy or regional network failure.
Deadlines still matter. A stream can be long-lived, but the initial bootstrap call should have a timeout. If the first GetOrder cannot complete, show a useful offline state instead of leaving the screen empty.
Protobuf evolution is forgiving when you are disciplined. Add fields instead of reusing field numbers. Keep enum unknown cases boring in the UI. Make older clients safe when newer servers send events they do not understand.
Finally, choose gRPC for the right reasons. If your feature is a normal request-response settings screen, URLSession or an OpenAPI-generated client may be a better fit. gRPC Swift 2 shines when the contract is shared, the payloads are typed, and the user experience really is live.
For a live order tracker, that is exactly the shape I want: one generated client, one bootstrap call, one stream, and a SwiftUI store that treats connection lifecycle as part of the feature instead of an afterthought.