Streaming financial data on iOS without melting the battery
Atlas: Finance shows live prices for equities, bonds, commodities, forex, and crypto. A lot of numbers that need to move. Users expect the experience to feel alive -- tickers updating, charts crawling forward, portfolio values reflecting reality. But "live" and "won't kill your battery by lunch" are competing goals, and the naive implementation of one destroys the other.
Why WebSockets
First decision was transport. I went with WebSockets over polling for a simple reason: financial data is push-driven. A stock price changes when it changes, not on a schedule you set. With polling, you're either checking too often (wasting resources when nothing moved) or too infrequently (showing stale data). A persistent WebSocket lets the server push ticks as they happen, and the client only does work when there's actually something new.
Connection management isn't complicated. URLSessionWebSocketTask handles the socket lifecycle. I wrapped it in an actor that manages reconnection with exponential backoff and handles the usual edge cases -- network transitions, server disconnects, app lifecycle changes.
The 40% battery problem
First working version was brutal on the battery. Every incoming tick updated the corresponding price label immediately. If you were watching a list of 50 tickers during market hours, that's potentially hundreds of updates per second, each triggering a SwiftUI view redraw. Profiled it on an iPhone 14 and measured roughly 40% battery drain in one hour. Instruments showed the GPU pegged from constant layout passes.
Obvious in retrospect. If AAPL goes from 241.32 to 241.33 to 241.31 to 241.33 within half a second, nobody perceives those intermediate states. They just see a number. But SwiftUI was faithfully re-rendering on every single change.
Batching and diffing
Fix was a diffing layer between the WebSocket and the UI. Incoming ticks accumulate in a buffer for 500ms. At the end of each window, I diff the buffer against the currently displayed state and only emit values that actually changed from what's on screen. If a price bounced around and landed back where it started, nothing gets pushed. If 50 tickers came in but only 12 have a visually different price, only 12 views redraw.
Battery drain dropped from about 40% per hour to around 8%. The app feels just as live because 500ms is below the threshold where humans notice latency in a price feed.
Adaptive frequency
Not all screens need the same update speed. When a user is looking at a detail view for a specific ticker, that ticker gets the full 500ms batched stream. But tickers scrolled off screen in a list don't need updates at all until they reappear. I use a visibility tracker tied to SwiftUI's onAppear and onDisappear to maintain a set of currently visible symbols, and only those participate in the diffing pass. Everything else gets throttled to a 5-second interval so the data isn't wildly stale when the user scrolls back.
When the app moves to the background, the WebSocket disconnects entirely. No reason to keep it open.
Chart rendering
For sparklines and small inline charts, Swift Charts works well. Declarative, integrates naturally with SwiftUI, handles the typical 50-200 data points a sparkline needs.
The full-screen interactive chart is a different story. Users can pinch to zoom out to a year of minute-level data, easily 10,000+ points. Swift Charts choked on this. Frame drops, hitches during pinch gestures, visible lag on panning. I replaced it with a custom renderer backed by Metal. Chart geometry gets uploaded to the GPU as a line strip, and interactions like panning and zooming are just matrix transforms on the vertex buffer. More code to maintain but the rendering is essentially free -- the GPU doesn't care about 10,000 line segments.
Background refresh
When the app isn't open, I use BGAppRefreshTask to periodically fetch a snapshot of the user's watched symbols. The system decides when to actually run these, so the data might be anywhere from 15 minutes to a few hours old. That's fine. The point isn't real time accuracy when the app is closed. It's making sure that when the user opens the app, there's something reasonable on screen for the first second before the WebSocket connects and live data takes over. Nobody wants to stare at yesterday's closing prices while a spinner goes.
-- Daniel