VButko

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 App Icon

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 PassthroughSubject because we only care about fresh locations. If you need to cache the last known position, use CurrentValueSubject instead.

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:

  1. Use Combine for maximum flexibility and iOS 13+ support.
  2. 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 App Icon

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!