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
- CLLocationUpdate: Async Location Streams
- Authorization the Modern Way
- CLMonitor: Modern Geofencing
- Background Location
- Significant-Change Location Service
- Advanced Usage
- Performance Considerations
- When to Use (and When Not To)
- Summary
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:
| Configuration | Use Case | Accuracy | Power |
|---|---|---|---|
.default | General purpose | Best available | Moderate |
.automotiveNavigation | Turn-by-turn navigation | High (GPS + sensor fusion) | High |
.otherNavigation | Non-automotive navigation | High | High |
.fitness | Workout tracking | Moderate | Low-moderate |
.airborne | Drone / aviation | High altitude | Moderate |
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
.authorizedWhenInUsebefore requesting.authorizedAlways. CallingrequestAlwaysAuthorization()from.notDeterminedwill 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 awaitloop with automatic cancellation. - Named monitors — The string identifier passed to
CLMonitor("name")persists across app launches. The system restores your monitored conditions automatically. - Condition types —
CLMonitorsupportsCircularGeographicCondition(geofencing) andBeaconIdentityCondition(iBeacons) through a unified API. - No 20-region limit per monitor — The legacy API limited you to 20
CLCircularRegionobjects.CLMonitorrelaxes 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:
CLMonitorrequiresAlwaysauthorization for geofencing to work when the app is not in the foreground. With onlyWhenInUseauthorization, 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:
- In Xcode, go to your target’s Signing & Capabilities tab.
- Add Background Modes.
- 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 asyncliveUpdates()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
Taskand updates stop. But if you store the task in a view model, remember to cancel it indeinitoronDisappear. - Choose the right configuration.
.fitnessuses accelerometer-assisted dead reckoning between GPS fixes, which is more power-efficient than.defaultfor workout apps..automotiveNavigationuses the most power but provides the best accuracy at speed. - Use
isStationarychecks. WhenCLLocationUpdate.isStationaryistrue, 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
CLMonitorgeofencing, not continuousliveUpdates(). 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)
| Scenario | Recommendation |
|---|---|
| Continuous location tracking in a running/cycling app | CLLocationUpdate.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 launch | liveUpdates() with early return after first accurate fix |
| City-level location for weather or content | CLLocationManager.requestLocation() or single liveUpdates() read |
| Background location for delivery/rideshare apps | liveUpdates(.automotiveNavigation) with background location capability |
| Beacon-based indoor positioning | Legacy CLLocationManager delegate API — ranging remains delegate-based |
| Geofencing with app launch from terminated state | Legacy startMonitoring(for:) — it can launch terminated apps |
Summary
CLLocationUpdate.liveUpdates()replaces theCLLocationManagerDelegatepattern with a cleanAsyncSequence— location updates arrive in afor awaitloop, and cancelling theTaskstops the hardware automatically.- Configuration presets (
.default,.fitness,.automotiveNavigation) replacedesiredAccuracyanddistanceFilterwith intent-based settings that the system can optimize. CLMonitormodernizes 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
CLMonitorworks in the background without continuous tracking, using the low-power coprocessor. - The
isStationaryandaccuracyLimitedproperties onCLLocationUpdatelet 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.