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.

⚡ 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:
AsyncSequenceneeds anElementtype (what values you're producing)makeAsyncIterator()returns the thing that actually generates valuesAsyncIteratorProtocolrequiresnext() asyncwhich returns the next value or nil when done
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:
AsyncStreamhandles the hard parts of AsyncSequencecontinuation.yield()sends values into the streamcontinuation.finish()ends it- Delegate callbacks feed into the stream
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:
- Data arrives over time (streams, notifications, timers)
- You want readable async iteration
- Callbacks or Combine feel like overkill
Skip them when:
- You just need one async value (use plain
await) - Complex transformations/operators needed (Combine’s better)
- Performance-critical tight loops (measure first though)
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 runsOne 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! 🚀
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
Why I'm Rebuilding My Blog From Scratch (and Leaving Medium)
After years of publishing on someone else's platform, I'm moving my writing to a home I actually own. Here's the reasoning, and what I'm building instead.
ReadData Is the Model: The Most Ignored Part of AI
A beginner-friendly guide to why data quality beats model hype.
Read