“Why was I charged this fee?” — Streaming Server‑Driven UI into SwiftUI with Four Numbers and a Timeline
When a user asks “Why was I charged this fee?”, a wall of text isn’t enough. They want a clear breakdown: four numbers and a timeline — amount, date, reason…

When a user asks “Why was I charged this fee?”, a wall of text isn’t enough. They want a clear breakdown: four numbers and a timeline — amount, date, reason, and resolution status — presented as a native SwiftUI flow.
This tutorial walks through how to stream server-driven UI over WebSockets or SSE into a SwiftUI client, using diffs and solid state management to keep your dynamic user interface responsive under continuous change.
Uzori’s own POV is that AI answers should be your app’s interface, not just text. If you haven’t yet, you may also want to read the related pillar guide: “Where did my money go?” — a transaction dispute is a four-step status answer, which dives deep into modeling dispute flows.
Overview: From “Why this fee?” to Streaming SwiftUI Screens
For a bank-style fee explanation, the ideal UX is:
- A conversational prompt: “Why was I charged this fee?”
- A generated, native SwiftUI screen:
- A timeline of events (authorization, posting, dispute, resolution)
- Four key numbers (original charge, fee, reversed amount, current balance impact)
- Contextual actions (dispute, view terms, contact support)
- Continuous updates as the backend re-evaluates the case or an AI agent refines the explanation.
To ship this kind of UX in a production iOS app, you need:
- Transport: WebSocket or server-sent events (SSE)
- Schema: a typed server-driven UI model the client can render
- Diffing: applying partial UI updates without rebuilding everything
- State management: keeping SwiftUI responsive under continuous updates
This tutorial assumes you’re familiar with SwiftUI and have a modern backend (REST/OpenAPI or GraphQL).
Prerequisites
Before you start, make sure you have:
- Platform versions:
- iOS 15+ for
URLSession.AsyncBytes(SSE) or iOS 13+ forURLSessionWebSocketTask - iOS 17+ if you want to use SwiftUI’s new
Observationsystem
- iOS 15+ for
- Backend capabilities:
- A WebSocket or SSE endpoint that streams JSON events
- A server-driven UI schema (e.g.,
Screen,Section,Row,TimelineEvent) - Optional: OpenAPI descriptions of your APIs if you’re integrating with Uzori’s engine
- Client structure:
- A dedicated
StreamingUIStore(observable model) - A root SwiftUI view that renders from your server-driven UI model
- A dedicated
If you want to lean on a production-ready AI-driven UI layer, the Uzori iOS SDK already handles the generative side:
- AI agents turn the user’s question into SwiftUI screens.
- Screens are validated on your server.
- The SDK streams changes into a single SwiftUI integration point.
Step 1: Design a Server‑Driven UI Schema for “Four Numbers and a Timeline”
First, define what you’re streaming. For our fee explanation, we can model a minimal schema:
{
"screenId": "fee_explanation",
"title": "Why was I charged this fee?",
"sections": [
{
"type": "summary",
"items": [
{ "type": "number", "label": "Fee", "value": 35.00 },
{ "type": "number", "label": "Original charge", "value": 120.00 },
{ "type": "number", "label": "Reversed", "value": 0.00 },
{ "type": "number", "label": "Balance impact", "value": 35.00 }
]
},
{
"type": "timeline",
"events": [
{ "date": "2026-08-25", "label": "Card transaction", "status": "posted" },
{ "date": "2026-08-26", "label": "Fee assessed", "status": "completed" }
]
}
]
}
Key patterns:
- Typed components (
summary,timeline) instead of free-form HTML. - Each element has just enough data for a native SwiftUI view.
- The server can send full screens or diffs (e.g., a new timeline event).
Uzori follows a similar approach with a constrained, server-approved schema:
- Catalog of allowed components (lists, forms, timelines, charts).
- No arbitrary remote code, just data that maps to SwiftUI views.
- AI chooses layout; the server validates it.
Step 2: Choose Transport — SSE vs WebSocket for SwiftUI
You have two main options to stream server-driven UI updates into iOS:
When to Use Server‑Sent Events (SSE)
SSE is one‑way push over HTTP:
- Server → client only; the client subscribes to a stream.
- Built‑in auto‑reconnect and
Last-Event-IDsemantics. - The WHATWG spec notes battery savings on portable devices versus connection-heavy alternatives.
Use SSE when:
- The client mostly listens (e.g., AI explanations, timeline updates).
- You don’t need low-level control over backpressure.
- You want a simpler mental model: “subscribe to updates for screen X.”
On iOS 15+, you can use URLSession.AsyncBytes for SSE:
func connectSSE() async throws {
let url = URL(string: "https://api.yourbank.com/ui/fee-stream")!
let (bytes, _) = try await URLSession.shared.bytes(from: url)
for try await line in bytes.lines {
guard line.hasPrefix("data:") else { continue }
let jsonString = String(line.dropFirst(5)).trimmingCharacters(in: .whitespaces)
handleIncomingUIEvent(jsonString)
}
}
When to Use WebSockets
WebSockets are bidirectional:
- Client and server can both send messages.
- Great for streaming UI plus user intents, optimistic updates, or tool calls.
Use WebSockets when:
- You want conversational interaction with an AI concierge.
- The client needs to send user events (e.g., “dispute this fee” or “drill into terms”) and get UI updates back.
- You need more control over session semantics.
On iOS 13+, you can use URLSessionWebSocketTask:
class UIWebSocketClient {
private var task: URLSessionWebSocketTask?
func connect() {
let url = URL(string: "wss://api.yourbank.com/ui/ws")!
task = URLSession.shared.webSocketTask(with: url)
task?.resume()
receiveLoop()
}
private func receiveLoop() {
task?.receive { [weak self] result in
switch result {
case .success(let message):
if case .string(let text) = message {
self?.handleIncomingUIEvent(text)
}
case .failure(let error):
// handle reconnection, backoff, logging
print("WebSocket error: \(error)")
}
self?.receiveLoop()
}
}
}
In both cases, transport is only the pipe. The real work is how you apply updates to state and SwiftUI.
Step 3: Model Streaming State in SwiftUI (Observation / ObservableObject)
For streaming UI, state management is not optional. Apple’s newer Observation system (iOS 17+) ensures views only update when they read a property. On earlier versions, ObservableObject with @Published works well.
Create a store that holds the current server-driven UI:
@Observable
class StreamingUIStore { // iOS 17+ Observation
var currentScreen: ScreenModel? = nil
var isLoading: Bool = false
var errorMessage: String? = nil
}
// For iOS 16 and below, use:
// class StreamingUIStore: ObservableObject {
// @Published var currentScreen: ScreenModel? = nil
// @Published var isLoading: Bool = false
// @Published var errorMessage: String? = nil
// }
Your root view reads from this store:
struct StreamingUIScreen: View {
@Environment(StreamingUIStore.self) var store
var body: some View {
Group {
if let screen = store.currentScreen {
ScreenRenderer(screen: screen)
} else if store.isLoading {
ProgressView("Loading explanation…")
} else if let error = store.errorMessage {
Text(error)
} else {
Text("Ask: Why was I charged this fee?")
}
}
.animation(.default, value: store.currentScreen?.id)
}
}
Patterns that keep SwiftUI responsive under continuous updates:
- Keep
ScreenModellean — avoid computed properties that do heavy work. - Use
@MainActorwhen mutating state from async streams. - Split complex screens into smaller
Views that depend on only the data they need.
Apple’s Instruments flags long body calculations over 500 μs and 1000 μs, so keep incremental renders cheap.
Step 4: Implement Diffing Strategies for Server‑Driven UI
Streaming a full screen for every tiny change doesn’t scale. You want to handle partial UI updates:
- The fee amount is updated after a recalculation.
- The timeline gets a new event when the dispute is opened.
- A new CTA appears when the account becomes eligible for a fee waiver.
Define a Diff Format
Example diff event:
{
"screenId": "fee_explanation",
"patches": [
{
"op": "replace",
"path": "/sections/0/items/0/value",
"value": 25.00
},
{
"op": "add",
"path": "/sections/1/events/2",
"value": {
"date": "2026-08-31",
"label": "Dispute opened",
"status": "pending"
}
}
]
}
On the client, apply patches to your ScreenModel:
@MainActor
func applyPatches(_ patches: [Patch], to screen: inout ScreenModel) {
for patch in patches {
switch patch.op {
case .replace:
// navigate by path segments and set the new value
replaceValue(at: patch.path, with: patch.value, in: &screen)
case .add:
addValue(at: patch.path, value: patch.value, in: &screen)
case .remove:
removeValue(at: patch.path, in: &screen)
}
}
}
You can roll your own minimal JSON Patch implementation or use a library. The goal is to keep diff application cheap and predictable.
Align Diffing with SwiftUI Identity
SwiftUI uses identity (id(_:)) to decide which views to update. Combine this with diffing:
- Give each
SectionandTimelineEventa stableid. - Use
ForEachkeyed byidso only changed rows re-render.
struct TimelineView: View {
let events: [TimelineEvent]
var body: some View {
List {
ForEach(events) { event in
TimelineRow(event: event)
.id(event.id) // stable identity
}
}
}
}
This pairing — JSON diffs + SwiftUI identity — keeps your UI responsive even under frequent server updates.
Step 5: Keep SwiftUI Responsive Under Continuous Streaming
Continuous server-driven UI updates can easily overwhelm the client if you’re not careful. SwiftUI performance is sensitive to update frequency and view body cost.
Best practices:
- Throttle updates:
- If the server emits multiple patches per second, batch them on the client.
- Apply them on a short timer (e.g., every 100–200 ms) to avoid re-render storms.
- Use lightweight models:
- Keep
ScreenModelstrictly data; avoid heavy logic. - Push calculations (e.g., interest, pro‑rated fees) to the server or a background layer.
- Keep
- Minimize hierarchy churn:
- Prefer patching existing sections over replacing the whole
sectionsarray. - Avoid changing IDs unless the underlying entity truly changed.
- Prefer patching existing sections over replacing the whole
Apple’s guidance is to reduce update frequency, not just optimize code. Use Instruments to find long-running updates and adjust your streaming cadence.
If you integrate Uzori’s iOS SDK, much of this is handled for you:
- Uzori streams validated SwiftUI screen descriptors.
- The SDK’s runtime applies diffs and keeps the view hierarchy stable.
- You get a single integration point, and the AI agent orchestrates flows using your backend.
Step 6: Add Optimistic UI and User Intents
Answering “Why was I charged this fee?” is just the first step. Users will immediately ask: “Can I get this refunded?” or “What changed in my account?”
Streaming UI works best when combined with optimistic updates:
- User taps “Request refund”.
- You immediately update the timeline with an event: “Refund requested” (optimistic).
- The server processes the mutation and either confirms or rolls back.
Example flow over WebSocket:
- User action → client sends
{"intent": "request_refund", "feeId": "..."}. - Client optimistically updates
ScreenModelwith a pending timeline event. - Server replies with diff: confirm refund or mark as declined.
SwiftUI implementation pattern:
func requestRefund(feeId: String) {
// 1. Optimistic UI
store.addPendingEvent(
label: "Refund requested",
date: Date()
)
// 2. Send intent over WebSocket
webSocketClient.send(intent: .requestRefund(feeId: feeId))
}
This pattern aligns with modern server-driven UI systems seen at Netflix and Meta:
- Netflix chose mutations for in-session page updates.
- Meta cut 84% of Messenger core code and made the app 2x faster to start by leaning on native plus dynamic templates.
Your fee explanation screen becomes part of a dynamic, session-aware UI, not a static page.
Step 7: Putting It All Together with Uzori
If you want to avoid building everything from scratch, Uzori gives you:
- A SwiftUI iOS SDK that exposes a single streaming screen.
- An AI engine that:
- Reads user questions like “Why was I charged this fee?”
- Calls your backend APIs (often via OpenAPI contracts).
- Generates native UI flows (numbers, timelines, CTAs) as SwiftUI screen descriptors.
- A server-side validator that ensures every screen is:
- Within a safe schema (no arbitrary code)
- Consistent with your design system and data contracts.
You keep:
- Native rendering, performance, and accessibility.
- Control over your component library and architecture.
- The ability to A/B test flows and evolve your server-driven UI model.
Uzori sits exactly at the intersection of generative UI and server-driven UI:
- Generative: AI composes fee explanations and dispute flows.
- Server-driven: your backend validates, versions, and streams screens over SSE or WebSockets.
For deeper thinking about transaction disputes and multi-step answers, see the related piece “Where did my money go?” — a transaction dispute is a four-step status answer, which complements this tutorial at the UX and data modeling level.
FAQ: Streaming Server‑Driven UI into SwiftUI
1. Should I use WebSockets or SSE for streaming SwiftUI UI updates?
Use SSE when you primarily need one-way push and simpler semantics. Use WebSockets when you require bidirectional communication, such as sending user intents, tool calls, or optimistic updates.
On iOS, URLSession.AsyncBytes works well for SSE (iOS 15+), while URLSessionWebSocketTask supports WebSockets from iOS 13.
2. How do I keep SwiftUI responsive under frequent updates?
Keep updates batched and lightweight:
- Throttle patches to a reasonable cadence (e.g., 5–10 frames per second at most).
- Use stable
idvalues andEquatableViewto reduce unnecessary re-renders. - Keep
ScreenModelmostly data and push heavy computations off the main thread.
Apple’s Instruments flags view body work above 500–1000 μs, so use it to find and fix hotspots.
3. How do I handle partial UI updates (diffs) from the server?
Define a simple patch format (JSON Patch-like) and apply it to your in-memory ScreenModel. Combine this with SwiftUI identities (id(_:)) so only changed components re-render.
Most fee-explanation flows only need small patches (e.g., adding a timeline event or updating one number), so diffing gives you smoother performance than full-screen replacements.
4. Can I use this approach with AI-generated UIs?
Yes. You can have an AI agent generate structured UI descriptors instead of text, as long as the output conforms to a pre-approved schema. Uzori’s engine does exactly this: it turns AI reasoning into SwiftUI screens, validates them on your server, and streams them into the app as server-driven UI.
5. How is this different from a regular chat interface for banking support?
A regular chat interface answers “Why was I charged this fee?” with paragraphs. A streaming server-driven UI answers with interactive, native UI:
- Four key numbers presented clearly.
- A timeline showing the lifecycle of the fee.
- Actions tied directly to your backend (dispute, refund, learn more).
This leads to faster comprehension, fewer support loops, and a UX that feels like your app — not someone else’s chatbot.
By combining WebSocket or SSE transport, solid state management, and diff-based updates, you can turn complex financial questions into clear, responsive SwiftUI flows. Whether you roll your own protocol or adopt Uzori’s SDK, the pattern is the same: let the server and AI stream the interface, and let SwiftUI do what it does best — render native, delightful experiences.