How to Build Modern async/await Location Manager with Swift Concurrency
Working with a user's location is a common requirement for many apps. To do that, we use CLLocationManager, an object that has been available since iOS 2.0 but hasn't been updated to support the modern async/await API. If your app uses modern concurrency, using the older location manager can feel awkward and out of place.
In this article, we'll explore how to write a modern LocationManager that improves code readability and convenience. We'll cover two types of location requests: one-off requests and subscribing to a stream of updates.
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!
LocationManager Base Setup
To access the device's location, we need to use the CLLocationManager class. To work with it, let's create a custom LocationManager wrapper that will interact with the system's CLLocationManager and expose only the API we need.
Let's start with a basic setup where we configure the CLLocationManager delegate and add an API to request permissions:
import CoreLocation
@MainActor
final class LocationManager: NSObject {
private let locationProvider = CLLocationManager()
override init() {
super.init()
locationProvider.delegate = self
}
}
// MARK: - Public API
extension LocationManager {
@MainActor
func requestPermissionIfNeeded() {
guard locationProvider.authorizationStatus == .notDetermined else {
return
}
locationProvider.requestWhenInUseAuthorization()
}
}
// MARK: - CLLocationManagerDelegate
extension LocationManager: CLLocationManagerDelegate {}
Subscribing to Location Changes
Let's see how to subscribe to location changes over time—a typical scenario is a map that tracks the user's position as they move.
To integrate with async/await, we need to expose an AsyncSequence of CLLocation values. Because several parts of the app may want to observe these updates simultaneously, Combine is a perfect fit: we create a local subject that publishes each new location and expose its AsyncPublisher.
First, we add a PassthroughSubject to LocationManager and feed it with the positions we get from CLLocationManager. We also propagate errors reported by the didFailWithError delegate method:
final class LocationManager: NSObject {
enum LocationError: Error {
case locationProviderError(Error)
}
private let currentLocation = PassthroughSubject<CLLocation, LocationError>()
...
}
extension LocationManager: CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
for location in locations {
currentLocation.send(location)
}
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: any Error) {
currentLocation.send(
completion: .failure(.locationProviderError(error))
)
}
}
We use
PassthroughSubjectbecause we only care about fresh locations. If you need to cache the last known position, useCurrentValueSubjectinstead.
We turn the subject into an async stream through its values property and filter out any nil values with compactMap:
currentLocation
.compactMap { $0 }
.eraseToAnyPublisher()
.values
Next, we expose a public API that starts the updates and returns the stream:
extension LocationManager {
@MainActor
func currentLocationPublisher() async -> AsyncThrowingPublisher<AnyPublisher<CLLocation, LocationError>> {
requestPermissionIfNeeded()
locationProvider.startUpdatingLocation()
return currentLocation
.compactMap { $0 }
.eraseToAnyPublisher()
.values
}
@MainActor
func stopLocationUpdates() {
locationProvider.stopUpdatingLocation()
}
}
CLLocationManager begins delivering updates only after startUpdatingLocation() is called, but we must first ensure the user has granted permission. In this sample we simply request authorization when the status is .notDetermined. In production you should also handle the .denied case—for example, by throwing an error so the UI can present a button that opens the Settings app.
With that in place, observing the user's position becomes straightforward:
@Observable
final class MapViewModel {
private let locationManager = LocationManager()
var currentLocation: CLLocation?
func startLocationUpdates() async {
for await location in locationManager.currentLocationPublisher() {
currentLocation = location
}
}
}
Note: Remember to call
stopLocationUpdates()when no subscribers need the stream.
With this simple, modern API we can now consume location updates and keep the view model in sync.
One-time Location Requests
Now that we know how to receive a stream of location updates, let’s look at a different scenario: fetching the user’s location just once. A single request is perfect for features such as showing nearby stores, where continuous tracking isn’t necessary.
Our goal is to build an async/await API that returns one fresh CLLocation. CLLocationManager already gives us requestLocation(), which triggers a delegate call that updates currentLocation. All we need is to bridge this callback to an asynchronous function.
We’ll explore two approaches:
- Use
Combinefor maximum flexibility and iOS 13+ support. - Use
AsyncPublisher, available on iOS 15+, for a more concise solution.
Using Combine
Combine lets us turn a publisher into a single asynchronous value. The same pattern can be reused anywhere you need one output from a stream.
@MainActor
func detectLocation() async throws -> CLLocation? {
await requestPermissionIfNeeded()
var cancellable: AnyCancellable?
var didReceiveValue = false
return try await withCheckedThrowingContinuation { continuation in
cancellable =
currentLocation
.sink(
receiveCompletion: { completion in
guard !didReceiveValue else { return }
switch completion {
case let .failure(error):
continuation.resume(throwing: error)
case .finished:
continuation.resume(
throwing: LocationError.missingOutput
)
}
},
receiveValue: { location in
guard !didReceiveValue else { return }
didReceiveValue = true
cancellable?.cancel()
continuation.resume(returning: location)
}
)
// Request one-time location
locationProvider.requestLocation()
}
}
We subscribe to the subject’s updates and cancel the subscription after receiving the first value. If no value arrives, we throw an error.
Using AsyncPublisher
AsyncPublisher offers a shorter, modern alternative when you can target iOS 15 or later.
// Add `@preconcurrency` to silence data race error
@preconcurrency import CoreLocation
@MainActor
func detectLocation() async throws -> CLLocation? {
let locationStream = currentLocation.values.dropFirst() // skip cached value
locationProvider.requestLocation()
// Await the first element (or throw on completion)
for try await location in locationStream {
return location
}
throw LocationError.missingOutput
}
We request a location, await the first element, and return it, ignoring any further updates.
Combine Single-Output vs. AsyncPublisher
Both approaches achieve the same result in our location-manager use case. Which one you choose comes down to personal preference.
Although AsyncPublisher is the more modern option and requires less code, there are situations where you need finer control, and Combine with its operators provides much greater flexibility.
Request Location Permission with async/await
When we call locationProvider.requestWhenInUseAuthorization() to ask for permission, we don’t know when the user will respond or when it’s safe to start requesting their location. Wrapping this flow in an async function gives us explicit control over the sequence of events. We can reuse the same Combine or AsyncPublisher techniques we used for the one-time location request.
Let’s explore the Combine-based version. First, we need to monitor the authorization status and update it whenever it changes. CLLocationManager guarantees that it will emit the current authorization status right after you create an instance.
final class LocationManager: NSObject {
...
private let currentAuthorization = CurrentValueSubject<CLAuthorizationStatus?, Never>(nil)
}
extension LocationManager: CLLocationManagerDelegate {
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
currentAuthorization.send(manager.authorizationStatus)
}
}
With a live authorization stream in place, we can build an async function that requests permission and returns as soon as we receive a new value:
@discardableResult
@MainActor
func requestPermissionIfNeeded() async -> CLAuthorizationStatus {
guard locationProvider.authorizationStatus == .notDetermined else {
return locationProvider.authorizationStatus
}
var cancellable: AnyCancellable?
var didReceiveValue = false
return await withCheckedContinuation { continuation in
cancellable =
currentAuthorization
.dropFirst()
.sink { permission in
guard !didReceiveValue else { return }
didReceiveValue = true
cancellable?.cancel()
continuation.resume(returning: permission ?? .denied)
}
// Request permission
locationProvider.requestWhenInUseAuthorization()
}
}
This helper makes it easy to integrate permission handling into detectLocation and any other API that needs authorization before accessing the user’s location.
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
By wrapping CLLocationManager in a lightweight LocationManager object, we created a modern async/await API. It offers an AsyncSequence for continuous updates, a one-shot detectLocation() method, and an awaitable permission request. You saw two ways to implement it: Combine for fine-grained control and AsyncPublisher for a shorter solution. Choose whichever style fits your team; both produce cleaner, more readable code that works well with Swift concurrency.
I hope you found this article useful. If you have questions or feedback, reach out on Twitter.
Thanks for reading!