AsyncSequence for Real-Time APIs: From Legacy Polling to Swift 6 Elegance
Ever feel like you’re duct-taping Timer, DispatchQueue.main.asyncAfter, and @Published into some Frankensteinian polling beast just to get real-time-ish updates? Yeah — me too.
But guess what? Swift 6 dropped the mic with AsyncStream. You get structured concurrency, clean cancellation, and a streaming API that actually feels modern. So why are we still dragging around old code like it's 2017?
In this post, we’re going full send on rewriting a legacy polling API — simulating weather updates every few seconds — and showing off three different styles:
- ✅ Old-school
Timer - 🔗 Combine pipelines
- 🌊 Swift 6’s glorious
AsyncStream
All that wrapped in a SwiftUI SeatAvailabilityView that turns green for clear skies and red for storms. Bonus round? Cancellation from multiple angles, mocked WebSocket behavior, and real SwiftTesting test cases to lock it all down.
Let’s fix some code that’s been haunting your repo for years.
🧰 Wanna mess with the full code? Grab it here → GitHub
🫶 Quick thing before we go full stream-ahead: If this post helps you even a little, tapping that 👏 button (you can hit it up to 50 times!) helps it reach more iOS devs. It’s a small gesture that makes a big difference. Thanks!
☁️ The Setup: Mock Weather API
We’re simulating a weather API that updates every few seconds. For simplicity, the response will be either .clear or .stormy, chosen randomly. You’ll use this in every approach.
💡 Try running this with the SeatAvailabilityView and watch the UI flip between green and red in real-time. Screenshots or screen recordings make great additions to your README.
enum WeatherCondition: String, CaseIterable {
case clear, stormy
}
struct WeatherResponse {
let condition: WeatherCondition
}
actor MockWeatherAPI {
func fetchWeather() async -> WeatherResponse {
try? await Task.sleep(for: .seconds(1))
return WeatherResponse(condition: WeatherCondition.allCases.randomElement()!)
}
}
⏱️ Attempt 1: Timer-Based Polling
final class TimerViewModel: ObservableObject {
@Published var weather: WeatherCondition = .clear
private var timer: Timer?
init(api: MockWeatherAPI) {
timer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
Task {
let response = await api.fetchWeather()
await MainActor.run { self?.weather = response.condition }
}
}
}
deinit {
timer?.invalidate()
}
}
It’s functional. It works. And it’s so brittle it might crack under a NavigationLink.
- Cancelling? Only if you remember to
invalidate(). - Background-safe? Nope.
- Testable? Not easily.
🔗 Attempt 2: Combine-Based Polling
final class CombineViewModel: ObservableObject {
@Published var weather: WeatherCondition = .clear
private var cancellables = Set<AnyCancellable>()
init(api: MockWeatherAPI) {
Timer.publish(every: 3, on: .main, in: .common)
.autoconnect()
.flatMap { _ in
Future { promise in
Task {
let response = await api.fetchWeather()
promise(.success(response.condition))
}
}
}
.receive(on: RunLoop.main)
.assign(to: &$weather)
}
}
Cleaner cancelation and better testability, but still awkward mixing Combine and async/await.
🌊 Attempt 3: AsyncStream, Swift 6 Edition
final class StreamViewModel: ObservableObject {
@Published var weather: WeatherCondition = .clear
private var task: Task<Void, Never>?
init(api: MockWeatherAPI) {
task = Task {
for await update in Self.pollingStream(api: api) {
await MainActor.run { self.weather = update }
}
}
}
deinit {
task?.cancel()
}
static func pollingStream(api: MockWeatherAPI) -> AsyncStream<WeatherCondition> {
AsyncStream { continuation in
Task {
while !Task.isCancelled {
let update = await api.fetchWeather()
continuation.yield(update.condition)
try? await Task.sleep(for: .seconds(3))
}
continuation.finish()
}
}
}
}
✨ Why’s this *
*for await*loop so chill? It plays nice with Swift 6’s structured concurrency and handles cancelation like a pro. No thread spaghetti here.*
- ✅ Simple cancelation
- ✅ Full async context
- ✅ Testable with async sequences
- ✅ Composable for timeouts, debounce, etc
🟩 SeatAvailabilityView
struct SeatAvailabilityView: View {
let condition: WeatherCondition
var body: some View {
Circle()
.fill(condition == .clear ? .green : .red)
.frame(width: 100, height: 100)
.overlay(Text(condition.rawValue.capitalized))
}
}
✋ Cancelation Variants
We’ll explore three ways to cancel:
- View disappears (via
.taskcancellation) - Manual
.cancel()call indeinit - Custom timeout using
AsyncThrowingStream
🧪 SwiftTesting: Let’s Validate
import Testing
@testable import YourModule
struct WeatherPollingTests: Testable {
func test_streamEmitsValues() async throws {
let api = MockWeatherAPI()
let stream = StreamViewModel.pollingStream(api: api)
var iterator = stream.makeAsyncIterator()
let first = try await iterator.next()
#expect(first != nil)
}
func test_streamCancellation() async throws {
let api = MockWeatherAPI()
let task = Task {
for await _ in StreamViewModel.pollingStream(api: api) { }
}
task.cancel()
#expect(task.isCancelled)
}
}
🧪 Bonus: Streams are a CI/CD dream — no flaky timers, no sleeps, just clean mocks and fast tests.
🔌 Bonus Round: WebSockets? Sure, Bro
Sure, sockets are great. But when you’re stuck with polling, a clean stream setup still gets you 90% there — without the overhead or mood swings.
Here’s how you might mock one:
struct MockWebSocket: AsyncSequence {
typealias Element = WeatherCondition
struct AsyncIterator: AsyncIteratorProtocol {
mutating func next() async -> WeatherCondition? {
try? await Task.sleep(for: .seconds(2))
return WeatherCondition.allCases.randomElement()
}
}
func makeAsyncIterator() -> AsyncIterator { AsyncIterator() }
}
Swap this into your view model and you’re socket-ready. Want to switch to a real socket later? Your UI won’t even blink.
✅ Key Riffs
Timeris fragile, Combine adds complexity,AsyncStreambrings calm.- Cancelation matters. Build for it.
- Streams make mocking and testing way easier — and cleaner for CI too.
- Swift 6 gave
AsyncSequencethe muscle it always needed.
If you liked this post, hit that 👏 button, share it with your team, and maybe take one of those old polling loops in your codebase out back and retire it.
Until next time — keep streaming.
🎯 Bonus: More Real-World iOS Survival Stories
If you’re hungry for more tips, tricks, and a few battle-tested stories from the trenches of native mobile development, swing by my collection of articles: https://medium.com/@wesleymatlock. These posts are packed with real-world solutions, some laughs, and the kind of knowledge that’s saved me from a few late-night debugging sessions.
Let’s keep building apps that rock — and if you’ve got questions or stories of your own, drop me a line. I’d love to hear from you.