Core Location Modern API: Async Updates and Geofencing


If you have been writing iOS apps for more than a year, you have probably implemented CLLocationManagerDelegate at least a dozen times — setting up a delegate, handling didUpdateLocations, remembering to call stopUpdatingLocation, and wrestling with the authorization dance across three different callback methods. iOS 17 replaced this entire pattern with a single async sequence.

This guide covers CLLocationUpdate.liveUpdates(), the new CLMonitor for geofencing, the authorization model, background location configuration, and the significant-change service. We will not cover beacon ranging or indoor positioning — those APIs remain delegate-based.

Contents

The Problem

The delegate-based CLLocationManager API has been unchanged since iOS 2. It works, but it creates structural problems in modern Swift code:

// ❌ The delegate pattern: scattered callbacks, manual lifecycle
final class PixarStudioFinder: NSObject, CLLocationManagerDelegate {
    private let locationManager = CLLocationManager()
    private var continuation: CheckedContinuation<CLLocation, any Error>?

    override init() {
        super.init()
        locationManager.delegate = self
        locationManager.desiredAccuracy = kCLLocationAccuracyBest
    }

    func findNearestStudio() async throws -> CLLocation {
        return try await withCheckedThrowingContinuation { continuation in
            self.continuation = continuation
            locationManager.requestWhenInUseAuthorization()
            locationManager.startUpdatingLocation()
        }
        // Problem: what if two callers invoke this simultaneously?
        // Problem: who calls stopUpdatingLocation?
        // Problem: the continuation is stored as mutable state
    }

    func locationManager(
        _ manager: CLLocationManager,
        didUpdateLocations locations: [CLLocation]
    ) {
        guard let location = locations.last else { return }
        continuation?.resume(returning: location)
        continuation = nil
        manager.stopUpdatingLocation()
    }

    func locationManager(
        _ manager: CLLocationManager,
        didFailWithError error: any Error
    ) {
        continuation?.resume(throwing: error)
        continuation = nil
    }
}

This wrapper is fragile. The continuation property is mutable shared state that is not Sendable. Multiple callers risk resuming the same continuation twice, which is a runtime crash. Stopping updates requires manual bookkeeping. And the authorization flow is split across a separate delegate callback that fires asynchronously — you cannot express “request authorization, then start updates” as a linear sequence of awaits.

CLLocationUpdate.liveUpdates() solves all of this.

CLLocationUpdate: Async Location Streams

CLLocationUpdate.liveUpdates() returns an AsyncSequence of CLLocationUpdate values. Each element contains a CLLocation along with metadata about the update:

import CoreLocation

@available(iOS 17.0, *)
final class PixarStudioLocator {
    /// Streams the user's location as they walk through a Pixar studio tour.
    func trackUserLocation() async {
        let updates = CLLocationUpdate.liveUpdates()

        for await update in updates {
            guard let location = update.location else { continue }

            print("Lat: \(location.coordinate.latitude)")
            print("Lon: \(location.coordinate.longitude)")
            print("Accuracy: \(location.horizontalAccuracy)m")

            if update.isStationary {
                print("User stopped — maybe admiring the Luxo Jr. lamp")
            }
        }
    }
}

The loop suspends when no update is available and resumes when a new location arrives. When the enclosing Task is cancelled, the sequence terminates and location updates stop automatically — no stopUpdatingLocation() call needed.

Accuracy Configuration

liveUpdates() accepts an optional LiveConfiguration parameter that replaces desiredAccuracy and distanceFilter:

@available(iOS 17.0, *)
extension PixarStudioLocator {
    /// High-accuracy tracking for the Pixar campus walking tour.
    func trackHighAccuracy() async {
        let updates = CLLocationUpdate.liveUpdates(.default)

        for await update in updates {
            guard let location = update.location else { continue }
            await processLocation(location)
        }
    }

    /// Power-efficient tracking for a road trip between studios.
    func trackRoadTrip() async {
        let updates = CLLocationUpdate.liveUpdates(.automotiveNavigation)

        for await update in updates {
            guard let location = update.location else { continue }
            await updateRoadTripProgress(location)
        }
    }
}

The LiveConfiguration presets map to common use cases:

ConfigurationUse CaseAccuracyPower
.defaultGeneral purposeBest availableModerate
.automotiveNavigationTurn-by-turn navigationHigh (GPS + sensor fusion)High
.otherNavigationNon-automotive navigationHighHigh
.fitnessWorkout trackingModerateLow-moderate
.airborneDrone / aviationHigh altitudeModerate

Apple Docs: CLLocationUpdate — Core Location

One-Shot Location

For cases where you need a single location — like centering a map on launch — you do not need to loop. Take the first update with acceptable accuracy and return:

@available(iOS 17.0, *)
extension PixarStudioLocator {
    /// Gets a single high-accuracy location, then stops.
    func currentLocation() async -> CLLocation? {
        let updates = CLLocationUpdate.liveUpdates()

        // Take the first update with acceptable accuracy
        for await update in updates {
            if let location = update.location,
               location.horizontalAccuracy < 50 {
                return location
            }
        }

        return nil
    }
}

The for await loop exits on the first return, which cancels the async sequence and stops location hardware. This pattern is cleaner than the old startUpdatingLocation() / stopUpdatingLocation() pair.

Authorization the Modern Way

Core Location’s authorization model has not fundamentally changed, but the modern API integrates it more cleanly. CLLocationUpdate.liveUpdates() automatically triggers the authorization prompt if the app’s status is .notDetermined — you do not need to call requestWhenInUseAuthorization() separately.

However, for explicit control, use CLLocationManager to check and request authorization before starting updates:

@available(iOS 17.0, *)
final class LocationAuthorizationManager {
    private let manager = CLLocationManager()

    /// The current authorization status.
    var authorizationStatus: CLAuthorizationStatus {
        manager.authorizationStatus
    }

    /// Requests when-in-use authorization if not yet determined.
    func requestWhenInUseAuthorization() {
        guard manager.authorizationStatus == .notDetermined else { return }
        manager.requestWhenInUseAuthorization()
    }

    /// Requests always authorization for background geofencing.
    func requestAlwaysAuthorization() {
        guard manager.authorizationStatus == .authorizedWhenInUse else {
            // Must request when-in-use first per Apple's model
            manager.requestWhenInUseAuthorization()
            return
        }
        manager.requestAlwaysAuthorization()
    }
}

Warning: iOS enforces a progressive authorization model. You must receive .authorizedWhenInUse before requesting .authorizedAlways. Calling requestAlwaysAuthorization() from .notDetermined will only grant when-in-use permission. See Apple’s Choosing the Location Authorization Level guide.

Info.plist Keys

Your app must include the appropriate usage description keys:

<!-- Required for when-in-use authorization -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>PixarTour needs your location to find nearby Pixar landmarks.</string>

<!-- Required for always authorization (background + geofencing) -->
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>PixarTour uses your location in the background to notify you near landmarks.</string>

CLMonitor: Modern Geofencing

CLMonitor is the iOS 17 replacement for CLLocationManager.startMonitoring(for:). Instead of delegate callbacks for region entry and exit, CLMonitor provides an async sequence of monitoring events:

@available(iOS 17.0, *)
final class PixarLandmarkMonitor {
    private var monitor: CLMonitor?

    /// Starts monitoring Pixar studio locations for geofence events.
    func startMonitoring() async {
        let monitor = await CLMonitor("pixar-landmarks")
        self.monitor = monitor

        // Add geofence conditions
        await monitor.add(
            CircularGeographicCondition(
                center: CLLocationCoordinate2D(
                    latitude: 37.8323, longitude: -122.2864
                ),
                radius: 200 // meters
            ),
            identifier: "pixar-hq"
        )

        await monitor.add(
            CircularGeographicCondition(
                center: CLLocationCoordinate2D(
                    latitude: 34.1561, longitude: -118.3254
                ),
                radius: 200
            ),
            identifier: "disney-studios"
        )

        // Consume geofence events as an async sequence
        for await event in await monitor.events {
            switch event.state {
            case .satisfied:
                print("Entered \(event.identifier) — welcome!")
                await sendArrivalNotification(for: event.identifier)
            case .unsatisfied:
                print("Left \(event.identifier) — come back soon!")
            case .unknown:
                break
            @unknown default:
                break
            }
        }
    }
}

CLMonitor vs. the Old Region Monitoring API

CLMonitor improves on the legacy API in several ways:

  • Async sequence — No delegate. Events arrive in a for await loop with automatic cancellation.
  • Named monitors — The string identifier passed to CLMonitor("name") persists across app launches. The system restores your monitored conditions automatically.
  • Condition typesCLMonitor supports CircularGeographicCondition (geofencing) and BeaconIdentityCondition (iBeacons) through a unified API.
  • No 20-region limit per monitor — The legacy API limited you to 20 CLCircularRegion objects. CLMonitor relaxes this restriction, though Apple recommends keeping the number reasonable for battery life.
@available(iOS 17.0, *)
extension PixarLandmarkMonitor {
    /// Checks the current state of a specific geofence.
    func checkState(
        for identifier: String
    ) async -> CLMonitor.Event.State? {
        guard let monitor else { return nil }
        let record = await monitor.record(for: identifier)
        return record?.lastEvent.state
    }

    /// Removes a geofence condition.
    func stopMonitoring(identifier: String) async {
        await monitor?.remove(identifier)
    }
}

Note: CLMonitor requires Always authorization for geofencing to work when the app is not in the foreground. With only WhenInUse authorization, geofence events fire only while the app is actively in use.

Background Location

Background location involves two separate capabilities: background location updates (continuous GPS tracking) and background monitoring (geofence wake-ups).

Enabling Background Location Updates

For continuous tracking while the app is backgrounded — a fitness tracker following Woody on a trail run, for instance — enable the Location updates background mode:

  1. In Xcode, go to your target’s Signing & Capabilities tab.
  2. Add Background Modes.
  3. Check Location updates.

Then use liveUpdates() with the appropriate configuration:

@available(iOS 17.0, *)
final class PixarFitnessTracker {
    /// Tracks location in the background for a workout session.
    func startBackgroundTracking() async {
        let updates = CLLocationUpdate.liveUpdates(.fitness)

        for await update in updates {
            guard let location = update.location else { continue }
            await recordWorkoutSample(location)

            // The isStationary property helps conserve power
            if update.isStationary {
                // User stopped — reduce processing, keep stream alive
                continue
            }
        }
    }
}

Warning: Background location is one of the most scrutinized capabilities during App Review. Your app must have a clear, user-visible reason for tracking location in the background. The blue status bar indicator appears while background updates are active. Apple will reject apps that use background location for analytics or advertising.

Background Geofencing with CLMonitor

CLMonitor geofencing works in the background without the continuous location background mode — it uses the system’s low-power geofence coprocessor. When the device crosses a geofence boundary, the system wakes your app briefly to deliver the event.

This is the preferred approach for “notify me when I arrive” features because it uses significantly less power than continuous GPS tracking.

Significant-Change Location Service

The significant-change service is a power-efficient alternative to continuous updates. It wakes your app when the device moves approximately 500 meters or changes cell tower connections:

@available(iOS 17.0, *)
final class PixarTripLogger {
    /// Logs significant location changes during a Pixar road trip.
    /// Uses minimal power — ideal for all-day tracking.
    func logSignificantChanges() async {
        let updates = CLLocationUpdate.liveUpdates()

        for await update in updates {
            guard let location = update.location else { continue }

            // Only process if the user has moved significantly
            // The system handles the filtering — we receive updates
            // only at significant location changes
            await logTripWaypoint(location)
        }
    }
}

Tip: For the significant-change service specifically, use the legacy CLLocationManager.startMonitoringSignificantLocationChanges() API if you need wake-from-terminated behavior. The async liveUpdates() sequence requires your app to be running (foreground or suspended with background mode). The legacy significant-change API can launch your app from a terminated state.

Advanced Usage

Combining Location with MapKit

The most common pattern is feeding location updates into a MapKit for SwiftUI map. Here is a SwiftUI view that tracks the user’s live location and displays it with a trail polyline:

import CoreLocation
import MapKit
import SwiftUI

@available(iOS 17.0, *)
@Observable
final class ToyStoryTourViewModel {
    var userLocation: CLLocation?
    var cameraPosition: MapCameraPosition = .userLocation(fallback: .automatic)
    var visitedCoordinates: [CLLocationCoordinate2D] = []

    private var trackingTask: Task<Void, Never>?

    func startTracking() {
        trackingTask = Task {
            let updates = CLLocationUpdate.liveUpdates()

            for await update in updates {
                guard let location = update.location else { continue }
                userLocation = location
                visitedCoordinates.append(location.coordinate)
            }
        }
    }

    func stopTracking() {
        trackingTask?.cancel()
        trackingTask = nil
    }
}

@available(iOS 17.0, *)
struct ToyStoryTourMap: View {
    @State private var viewModel = ToyStoryTourViewModel()

    var body: some View {
        Map(position: $viewModel.cameraPosition) {
            UserAnnotation()

            // Draw the path the user has walked
            if viewModel.visitedCoordinates.count > 1 {
                MapPolyline(coordinates: viewModel.visitedCoordinates)
                    .stroke(.blue, lineWidth: 3)
            }

            // Pixar campus landmarks
            Marker(
                "Luxo Jr. Lamp",
                systemImage: "light.max",
                coordinate: CLLocationCoordinate2D(
                    latitude: 37.8325, longitude: -122.2860
                )
            )
            .tint(.yellow)

            Marker(
                "Steve Jobs Building",
                systemImage: "building.2",
                coordinate: CLLocationCoordinate2D(
                    latitude: 37.8320, longitude: -122.2868
                )
            )
            .tint(.gray)
        }
        .mapControls {
            MapUserLocationButton()
            MapCompass()
        }
        .task { viewModel.startTracking() }
        .onDisappear { viewModel.stopTracking() }
    }
}

Filtering Updates by Accuracy

Raw location updates can be noisy, especially indoors. Filter out low-accuracy readings to avoid jumpy UI:

@available(iOS 17.0, *)
extension PixarStudioLocator {
    /// Yields only locations with accuracy better than the threshold.
    func accurateLocations(
        threshold: CLLocationAccuracy = 20.0
    ) -> AsyncFilterSequence<CLLocationUpdate.Updates> {
        CLLocationUpdate.liveUpdates()
            .filter { update in
                guard let location = update.location else { return false }
                return location.horizontalAccuracy <= threshold
                    && location.horizontalAccuracy >= 0
            }
    }
}

Handling Authorization Changes at Runtime

Users can change location permissions in Settings at any time. The liveUpdates() stream provides properties to detect these changes:

@available(iOS 17.0, *)
final class LocationPermissionObserver {
    private let manager = CLLocationManager()

    /// Observes authorization status changes via the update stream.
    func observeAuthorizationChanges() async {
        for await update in CLLocationUpdate.liveUpdates() {
            // The stream respects authorization changes:
            // - If the user revokes permission, the stream ends
            // - Accuracy reduction is reflected in update.location
            if update.authorizationDenied {
                await MainActor.run {
                    showLocationPermissionAlert()
                }
                break
            }

            if update.accuracyLimited {
                await MainActor.run {
                    showApproximateLocationBanner()
                }
            }
        }
    }
}

Performance Considerations

Location services are one of the largest battery drains on iOS. The modern API provides better defaults, but you still need to make conscious choices:

  • Stop when done. The async sequence pattern handles this automatically — cancel the Task and updates stop. But if you store the task in a view model, remember to cancel it in deinit or onDisappear.
  • Choose the right configuration. .fitness uses accelerometer-assisted dead reckoning between GPS fixes, which is more power-efficient than .default for workout apps. .automotiveNavigation uses the most power but provides the best accuracy at speed.
  • Use isStationary checks. When CLLocationUpdate.isStationary is true, the user has not moved. Skip heavy processing (geocoding, route recalculation) until movement resumes.
  • Geofencing over continuous tracking. If your feature is “notify when near a location,” use CLMonitor geofencing, not continuous liveUpdates(). Geofencing uses the M-series coprocessor and consumes almost no battery.
  • Approximate vs. precise location. iOS 14+ lets users grant approximate location only. Check location.horizontalAccuracy — values above 1000 meters indicate approximate mode. Design your UI to degrade gracefully when precision is reduced.

Apple Docs: CLLocationManager — Core Location

When to Use (and When Not To)

ScenarioRecommendation
Continuous location tracking in a running/cycling appCLLocationUpdate.liveUpdates(.fitness) — purpose-built for workouts
”Notify me when I arrive at X”CLMonitor with CircularGeographicCondition — low power, works in background
One-shot “where am I?” on app launchliveUpdates() with early return after first accurate fix
City-level location for weather or contentCLLocationManager.requestLocation() or single liveUpdates() read
Background location for delivery/rideshare appsliveUpdates(.automotiveNavigation) with background location capability
Beacon-based indoor positioningLegacy CLLocationManager delegate API — ranging remains delegate-based
Geofencing with app launch from terminated stateLegacy startMonitoring(for:) — it can launch terminated apps

Summary

  • CLLocationUpdate.liveUpdates() replaces the CLLocationManagerDelegate pattern with a clean AsyncSequence — location updates arrive in a for await loop, and cancelling the Task stops the hardware automatically.
  • Configuration presets (.default, .fitness, .automotiveNavigation) replace desiredAccuracy and distanceFilter with intent-based settings that the system can optimize.
  • CLMonitor modernizes geofencing with named, persistent monitors and an async sequence of entry/exit events — no delegate, no 20-region limit, and automatic restoration across launches.
  • Background location requires the Location updates background mode and appropriate authorization. Geofencing via CLMonitor works in the background without continuous tracking, using the low-power coprocessor.
  • The isStationary and accuracyLimited properties on CLLocationUpdate let you make power-conscious decisions: skip processing when the user is not moving, and degrade gracefully when only approximate location is available.

For displaying location data on a map, see MapKit for SwiftUI: Maps, Markers, Routing, and Custom Styles. To build a complete location-based app from scratch, check out Build a Maps App with MapKit.