All articles
iOS5 min read

Swift For-Await Loops: Handle Real-Time Data Without the Chaos

Network streams, notifications, sensor data — for-await loops make async sequences feel like normal Swift code. Finally.

K
Karan Pal
Author
Image Generated by AI
Image Generated by AI

⚡ What AsyncSequence Actually Is

Here’s the thing — AsyncSequence is just a sequence where you have to wait for each element. That’s it.

Regular sequences? You iterate through stuff that’s already there. Arrays, dictionaries, ranges. All the values exist upfront, there’s no waiting required here.

for number in [1, 2, 3] {
    print(number)
}

AsyncSequence looks similar, but values arrive over time. Could be network responses trickling in slowly. Sensor readings sent to your device over a period. Notifications firing back to back but with delay. Database changes streaming to your device byte by byte.

for await update in locationStream {
    print(update.coordinate)
}

Look at the difference. Just threw await in there. One keyword. That's the entire syntax change.

But here’s what makes it click — think about how we used to handle this stuff. Combine with its sink and store and pipeline operators. Or URLSession delegates with like 6 different callback methods. Or NotificationCenter observers you forget to remove and leak memory.

Now this feels like normal Swift again.

The async part means “this might take time, don’t block.” The sequence part means “multiple values, not just one.” Put them together and you get a for loop that waits for data as it arrives. No callbacks, no delegates, no Combine gymnastics.

Actually, let me rephrase that — Combine still has its place for complex transformations. But for just iterating through async data? For-await wins on readability every time.

🔄 Your First For-Await Loop

Now let’s build something you can actually run. A timer that counts down from 5.

import Foundation

struct Countdown: AsyncSequence {
    typealias Element = Int
    let start: Int

    struct AsyncIterator: AsyncIteratorProtocol {
        var current: Int

        mutating func next() async -> Int? {
            guard current > 0 else { return nil }
            try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 sec
            defer { current -= 1 }
            return current
        }
    }

    func makeAsyncIterator() -> AsyncIterator {
        AsyncIterator(current: start)
    }
}

// use it
Task {
    for await count in Countdown(start: 5) {
        print(count)
    }
    print("Done!")
}

Run this and you’ll see 5, 4, 3, 2, 1 printing on your second with one second delay after each print.

Breaking it down:

The for await loop just keeps calling next() until it gets nil. Behind the scenes, same as regular for loops—just async.

Now here’s the cool part. That loop looks synchronous. No completion handlers. No callbacks nested 3 levels deep. Just linear code that waits for each value.

// this is clear
for await count in Countdown(start: 5) {
    updateUI(with: count)
}

// vs the callback version (don't do this)
var remaining = 5
func tick() {
    guard remaining > 0 else {
        print("Done!")
        return
    }
    print(remaining)
    remaining -= 1
    DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
        tick() // recursive nightmare
    }
}

Which would you rather maintain? Yeah.

**How to Convert Callback Hell to Modern async/await in Swift** _A practical guide to wrapping callbacks, delegates, and old APIs in modern Swift concurrency_swift-pal.comhttps://swift-pal.com/how-to-convert-callback-hell-to-modern-async-await-in-swift-8d43d0660bef

🛠️ Real-World Use Cases

Okay, countdown timers are cute. But what about actual app scenarios?

Network Streaming

Fetching paginated data without callback hell:

struct PageFetcher: AsyncSequence {
    typealias Element = [String]
    let baseURL: String

    struct AsyncIterator: AsyncIteratorProtocol {
        var page = 1
        var hasMore = true
        let baseURL: String

        mutating func next() async -> [String]? {
            guard hasMore else { return nil }

            // fake network call
            try? await Task.sleep(nanoseconds: 500_000_000)

            let items = ["Item \(page)-1", "Item \(page)-2", "Item \(page)-3"]
            page += 1

            if page > 3 { hasMore = false } // stop after 3 pages

            return items
        }
    }

    func makeAsyncIterator() -> AsyncIterator {
        AsyncIterator(baseURL: baseURL)
    }
}

Task {
    for await page in PageFetcher(baseURL: "https://api.example.com") {
        print("Got page: \(page)")
    }
}

Here, each iteration fetches the next page, no manual page tracking in your view controller. No “load more” button bugs.

NotificationCenter as AsyncSequence

iOS 16.0+ gives us this for free:

let notifs = NotificationCenter.default.notifications(named: UIApplication.didBecomeActiveNotification)

Task {
    for await notification in notifs {
        print("App became active")
        // refresh data, whatever
    }
}

No more addObserver, no more removeObserver, no more retain cycles because you forgot to remove it. Loop ends when the Task gets cancelled.

Timer that actually makes sense

Task {
    for await _ in Timer.publish(every: 1.0, on: .main, in: .common).values {
        print("Tick")
    }
}

Combine’s Timer.publish returns a publisher, but calling .values on it gives you an AsyncSequence. Best of both worlds.

URLSession bytes streaming

let url = URL(string: "https://example.com/largefile.zip")!
let (bytes, response) = try await URLSession.shared.bytes(from: url)

var totalBytes = 0
for try await byte in bytes {
    totalBytes += 1
    // update progress bar
}
print("Downloaded \(totalBytes) bytes")

Progress tracking without delegates. Just count as bytes arrive.

Actually — pro tip: you probably want to batch those bytes instead of processing one at a time. But the concept holds.

🚀 Building Custom AsyncSequences

Most of the time you don’t need to build these from scratch. URLSession, NotificationCenter, Timer — they’re already AsyncSequences or have adapters.

But sometimes you need custom behavior. Like wrapping a delegate-based API.

Here’s a location stream (concept — not complete production code):

import CoreLocation

class LocationStream: NSObject, AsyncSequence, CLLocationManagerDelegate {
    typealias Element = CLLocation

    private let manager = CLLocationManager()
    private var continuation: AsyncStream<CLLocation>.Continuation?

    private lazy var stream: AsyncStream<CLLocation> = {
        AsyncStream { continuation in
            self.continuation = continuation
            manager.delegate = self
            manager.requestWhenInUseAuthorization()
            manager.startUpdatingLocation()
        }
    }()

    func makeAsyncIterator() -> AsyncStream<CLLocation>.Iterator {
        stream.makeAsyncIterator()
    }

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        for location in locations {
            continuation?.yield(location)
        }
    }

    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        continuation?.finish()
    }
}

// use it
Task {
    let stream = LocationStream()
    for await location in stream {
        print("Lat: \(location.coordinate.latitude)")
        // update map or whatever
    }
}

Key parts:

Is this perfect? Nah. You’d want proper cancellation, authorization handling, all that. But it shows the pattern — wrap callbacks, yield values, finish when done.

💡 Wrap-Up

For-await loops = for loops that wait. That’s the mental model.

Use them when:

Skip them when:

Main gotcha? Task cancellation. If the Task running your for-await loop gets cancelled, the loop stops. That’s usually what you want, but watch for cleanup you might need.

let task = Task {
    for await item in stream {
        process(item)
    }
    print("This runs even if cancelled")
}

// later
task.cancel() // loop stops, cleanup code still runs

One more thing — these compose nicely. You can map, filter, prefix on AsyncSequences just like regular sequences. iOS 16+ added a bunch of operators.

for await even in stream.filter({ $0 % 2 == 0 }) {
    print(even)
}

📺 Want to see this in action? I’ve got a complete video walkthrough coming soon on Swift Pal: https://youtube.com/@swift-pal

That’s for-await loops. Async sequences without the chaos. Finally feels like Swift again.

🎉 Enjoyed this article? Your support means the world to me!

💬 Drop a comment below! I love hearing about your experiences and answering questions

🎬 Subscribe on Youtube and become early subscribers of my channel: https://www.youtube.com/@swift-pal

💼 Let’s connect on LinkedIn for more professional insights: https://www.linkedin.com/in/karan-pal

Happy coding! 🚀

● The newsletter

New articles, straight to your inbox.

No spam, no filler — just new writing on iOS, the web, and AI when it ships. Unsubscribe anytime.

Keep reading