How to Build a Single-Output Event Signal in Swift with async/await
Sometimes in our applications we need to wait for some work to finish before triggering another action. Practical examples include waiting for the app to bootstrap before showing the app content, waiting for the database to sync with the cloud, waiting for database migration to complete, or waiting for feature flags to be fetched.
In this article I want to show how we can create an async/await API for this pattern. We'll build a clean API that's easy to work with.
The implementation will be thread-safe because we will use an actor.
Task Flow: Task Manager App
Task Flow offers innovative features like multi-project tasks and the 'My Task Flow' concept. On top of the project structure, you get an endlessly flexible Task Flow view that suits the way you think and work. Available on iPhone, iPad, Mac and Apple Vision.
Try it now!
SingleOutput Actor
We'll create an actor that waiters can subscribe to using try await wait().
public actor SingleOutput<Value: Sendable> {
private var result: Result<Value, Error>?
private var waiters: [CheckedContinuation<Value, Error>] = []
public init() {}
/// Wait until a value is available.
public func wait() async throws -> Value {
if let result {
return try result.get()
}
return try await withCheckedThrowingContinuation { cont in
waiters.append(cont)
}
}
}
Here, we return immediately if we already have the result. Otherwise, we keep the request in the waiters array.
Now we need a way to return the value to all waiters when we receive it. We'll add an API to finish and give our waiters the output:
public actor SingleOutput<Value: Sendable> {
//...
public func succeed(_ value: Value) {
finish(.success(value))
}
public func fail(_ error: Error) {
finish(.failure(error))
}
private func finish(_ result: Result<Value, Error>) {
guard self.result == nil else {
// ignore repeat finishes
return
}
self.result = result
let continuations = waiters
waiters.removeAll()
for continuation in continuations {
continuation.resume(with: result)
}
}
}
Our SingleOutput is complete.
Notes:
- Thread-safe access by using
actor - Multiple
succeed/failcalls are ignored (first one wins) - If the event happened before you called
wait(), the value is returned immediately - If it happens later,
wait()suspends and resumes at the right moment - Multiple subscribers can wait for the result
- If the caller cancels while waiting, Swift will throw
CancellationErrorwhen the cancellation resumes. You can store the waiter's ID and remove them in theonCancelhandler, but that's usually not needed for single-output flows.
How To Use SingleOutput
Now let's look at how to use it with practical use cases.
// Example: App bootstrap
final class AppBootstrap {
private let ready = SingleOutput<Void>()
func start() {
Task {
do {
try await withThrowingTaskGroup(of: Void.self) { group in
for service in services {
group.addTask { try await service.performInitialSetup() }
}
try await group.waitForAll()
}
await ready.succeed(())
} catch {
await ready.fail(error)
}
}
}
func waitUntilReady() async throws {
try await ready.wait()
}
}
// Use in SwiftUI view
struct RootView: View {
@State private var isReady = false
let bootstrap: AppBootstrap
var body: some View {
Group {
if isReady {
MainContent()
} else {
SplashView()
}
}
.task {
do {
try await bootstrap.waitUntilReady()
isReady = true
} catch {
// show error
}
}
}
}
// Example: Wait for the initial database sync
final class StorageProvider {
private let initialSync = SingleOutput<Void>()
func start() {
// sync cloud data
// update initialSync status
Task { [initialSync] in await initialSync.succeed(()) }
}
func waitUntilReady() async throws {
try await initialSync.wait()
}
}
// Consumer
try await storageProvider.waitUntilReady()
// proceed: data is synced
Task Flow: Task Manager App
Task Flow offers innovative features like multi-project tasks and the 'My Task Flow' concept. On top of the project structure, you get an endlessly flexible Task Flow view that suits the way you think and work. Available on iPhone, iPad, Mac and Apple Vision.
Try it now!
Conclusion
The SingleOutput actor provides a clean and thread-safe way to handle scenarios where you need to wait for one-time events in your iOS apps. Whether you're waiting for app bootstrap, database sync, or feature flags to load, this pattern gives you a simple async/await API that multiple subscribers can use safely.
The key benefits of this approach are thread safety through the actor model, immediate return for late subscribers, and protection against multiple finish calls. This makes it perfect for coordinating startup tasks and other common use cases.
I hope you enjoyed this article. If you have any questions, suggestions, or feedback, please let me know on Twitter.
Thanks for reading!