AsyncSequence for Real-Time APIs: From Legacy Polling to Swift 6 Elegance

AsyncSequence for Real-Time APIs: From Legacy Polling to Swift 6 Elegance

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 .task cancellation)
  • Manual .cancel() call in deinit
  • 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

  • Timer is fragile, Combine adds complexity, AsyncStream brings calm.
  • Cancelation matters. Build for it.
  • Streams make mocking and testing way easier — and cleaner for CI too.
  • Swift 6 gave AsyncSequence the 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.