Field note · Android · iOS · Fintech
Real-time trading on Android: lessons from Schwab Mobile.
A trade ticket looks simple. A symbol field, a quantity, a buy/sell toggle, a Submit button. Everything that makes it actually work — live quotes, multiple order types, market-hours logic, encrypted transport, biometric auth, and the assumption that the user is on a flaky 4G connection in a parking lot — happens behind that surface. Here's the shape of what we shipped at Schwab.
The latency budget you don't get to negotiate
A trader expects the displayed bid/ask to update at least once a second on an active symbol, and they expect the order confirmation to land in under 1.5 seconds after they tap Submit. Both are non-negotiable: traders will leave a brokerage over either one. That gives you a hard budget — pick any one of (network round-trip, JSON parse, view recomposition, animation frame) and assume it has 100 ms.
Two consequences shape the whole stack:
- You can't render the network. The UI binds to a local model. The network feeds the model. Any time the UI awaits the network directly, you'll miss frames during a fast market.
- You can't allocate per tick. A streaming quote feed for ten symbols at 4 ticks/sec is 40 events/sec. Per-tick object allocation thrashes the heap. Quote rows are pooled and mutated in place.
The streaming layer
Real-time quotes don't come over REST. The wire is a long-lived persistent connection — historically WebSockets, occasionally a server-sent stream — and the server pushes deltas. The client subscribes to a watchlist and the server confirms with a snapshot, then sends partial updates per tick.
Two patterns that matter on mobile:
- Subscribe to what's visible. The watchlist screen shows 8 rows; the stream subscribes to those 8 symbols. Scroll changes the subscription. Background tabs unsubscribe. This isn't an optimization — it's the difference between a phone that lasts the trading day and one that needs charging at lunch.
- Coalesce updates. Don't post every tick to the main thread. Buffer ticks for the last 100 ms, apply them in a single recomposition pass. Charts and watchlists thank you.
// Coalesced quote feed — emits at most one update per symbol per frame window.
class QuoteFeed(private val ws: QuoteSocket) {
val quotes: SharedFlow<Map<Symbol, Quote>> = ws.ticks
.scan(emptyMap<Symbol, Quote>()) { acc, tick ->
acc + (tick.symbol to acc[tick.symbol]?.apply(tick) ?: Quote(tick))
}
.sample(100.milliseconds)
.flowOn(Dispatchers.Default)
.shareIn(scope, SharingStarted.WhileSubscribed(), replay = 1)
suspend fun subscribe(symbols: Set<Symbol>) = ws.subscribe(symbols)
}
The order ticket is a state machine
"Buy 100 AAPL at market" hides a small state machine. Validations are layered: client-side (positive quantity, valid symbol, market-hours awareness for stop orders), server-side (account funds, day-trade-pattern check, regulatory pre-trade), and broker-side (acceptance, partial fill, full fill, reject).
The mistake to avoid: trying to model "submitting" as a single boolean. The actual states a ticket flows through are roughly:
- Drafting — user can edit fields freely.
- Validating — client check passes, awaiting server pre-trade.
- Confirming — server accepted; user sees the disclosure / confirm screen.
- Submitted — order acknowledged, awaiting broker.
- Working / partial / filled / rejected — broker terminal states.
Every screen that interacts with the ticket binds to that state, not to ad-hoc booleans. The reward is no double submissions, no impossible UI states, and a much easier QA story.
Biometric auth — and what it doesn't do
Face / fingerprint unlock is table stakes in fintech. It's also frequently misimplemented: developers treat the biometric prompt as authentication, when it's actually authorization to release a stored secret. The authentication still happens between your stored token and the server.
The pattern that holds up to a security review:
- Login generates a refresh token; refresh token gets wrapped with a key from
Keystorebound tosetUserAuthenticationRequired(true). - Biometric prompt unlocks the key. Failure means the wrapped token stays sealed.
- Session timeout after N minutes of inactivity always falls back to PIN/password — biometric alone shouldn't extend a session indefinitely.
- On a new device or after enrolling a new fingerprint, the wrapped token is invalidated. The user logs in again. Don't try to silently re-wrap; that's a regression of your security posture.
Network — the layer that bites you in production
Three small things saved us repeatedly:
- Idempotency keys on order submission. The user double-tapped Submit because they didn't see the spinner. Without an idempotency key, you book two orders. With it, the second is a no-op.
- Aggressive client-side timeouts on order paths. 5 seconds, then surface "we don't know the status of your order" and link to Order Status. Never sit on a spinner for 30 seconds during a fast market.
- Always reconcile on resume. When the app comes back to foreground, fetch live order status before showing any cached state. Stale order data in fintech is worse than blank.
Bottom line
A real-time trading app is fundamentally a UI bound to a streaming backend, with a state machine for orders and a security model that assumes the device is hostile. None of the individual pieces are exotic — but the combination is unforgiving, and the patterns above are the ones I'd reach for first if I were building it again tomorrow.