CoreLocation Patterns Reference
Extended patterns for CoreLocation on iOS 17+ with modern Swift concurrency.
Import CoreLocation in every file that uses these APIs.
import CoreLocationContents
- CLLocationUpdate.liveUpdates() (iOS 17+)
- CLServiceSession (iOS 18+)
- CLMonitor for Geofencing (iOS 17+)
- CLBackgroundActivitySession
- Significant Location Change Monitoring
- Visit Monitoring
- Region Monitoring Migration (CLCircularRegion to CLMonitor)
- Location Accuracy Management
- Testing Location in Simulator
- Privacy and Info.plist Keys
- Common Pitfalls
- References
CLLocationUpdate.liveUpdates() (iOS 17+)
Basic Usage
func startReceivingLocation() async {
for try await update in CLLocationUpdate.liveUpdates() {
guard let location = update.location else { continue }
print("Lat: \(location.coordinate.latitude), Lon: \(location.coordinate.longitude)")
print("Accuracy: \(location.horizontalAccuracy)m")
}
}Full Implementation with Filtering
Filter out stale, inaccurate, or duplicate updates to avoid unnecessary UI refreshes and reduce battery impact.
@MainActor
@Observable
final class LocationService {
var currentLocation: CLLocation?
var isTracking = false
private var trackingTask: Task<Void, Never>?
private var lastReportedLocation: CLLocation?
/// Minimum distance in meters between reported locations.
private let distanceFilter: CLLocationDistance = 10
/// Maximum acceptable horizontal accuracy in meters.
private let accuracyThreshold: CLLocationAccuracy = 100
/// Maximum acceptable age for a delivered location.
private let maximumLocationAge: TimeInterval = 15
func startTracking() {
guard !isTracking else { return }
isTracking = true
trackingTask = Task {
do {
let updates = CLLocationUpdate.liveUpdates(.default)
for try await update in updates {
if Task.isCancelled { break }
// Skip updates without a location
guard let location = update.location else { continue }
// Skip inaccurate readings
guard location.horizontalAccuracy >= 0,
location.horizontalAccuracy < accuracyThreshold else {
continue
}
// Skip stale readings
guard abs(location.timestamp.timeIntervalSinceNow) < maximumLocationAge else {
continue
}
// Skip impossible movement when speed/course are available
if location.speed >= 0, location.speed > 80 {
continue
}
if location.course >= 0, location.courseAccuracy < 0 {
continue
}
// Skip if the user has not moved enough
if let last = lastReportedLocation,
location.distance(from: last) < distanceFilter {
continue
}
lastReportedLocation = location
currentLocation = location
}
} catch is CancellationError {
// Expected when tracking stops.
} catch {
currentLocation = nil
}
isTracking = false
}
}
func stopTracking() {
trackingTask?.cancel()
trackingTask = nil
isTracking = false
}
}LiveConfiguration Options
CLLocationUpdate.liveUpdates(_:) accepts a LiveConfiguration parameter:
// Default: balanced power and accuracy
CLLocationUpdate.liveUpdates(.default)
// Best for navigation: highest accuracy, most frequent updates
CLLocationUpdate.liveUpdates(.automotiveNavigation)
// Fitness tracking
CLLocationUpdate.liveUpdates(.fitness)
// Other-navigation (non-automotive)
CLLocationUpdate.liveUpdates(.otherNavigation)
// Airborne: for drone or aviation apps
CLLocationUpdate.liveUpdates(.airborne)Handling Diagnostics and Degraded Behavior (iOS 18+)
Keep every liveUpdates() stream in an owned Task so the feature can cancel
it when the map, route, or monitoring workflow stops. Treat diagnostics and bad
location samples as state changes: update UI, fall back to cached/manual input,
or suspend background work instead of continuing an invisible loop.
for try await update in CLLocationUpdate.liveUpdates() {
// Check authorization status (iOS 18+)
if update.authorizationDenied {
// User denied location; prompt to open Settings
break
}
if update.authorizationDeniedGlobally {
// Location Services disabled system-wide
break
}
if update.insufficientlyInUse {
// App does not meet in-use requirements
continue
}
if update.locationUnavailable {
// Temporarily unable to determine location; keep iterating
continue
}
if update.stationary {
// Device stopped moving; updates will pause
continue
}
guard let location = update.location else { continue }
// Use location
}Note: authorizationDenied, authorizationDeniedGlobally,
insufficientlyInUse, locationUnavailable, and stationary are only
available on iOS 18+. On iOS 17, check update.location == nil to detect
unavailable location.
Map diagnostics to user-visible behavior instead of silently waiting:
authorizationDenied: stop location work and show a Settings recovery path.authorizationDeniedGlobally: explain that system Location Services are off.insufficientlyInUse: suspend live updates until the feature is active again.locationUnavailable: keep the feature usable with cached, typed, or map-region input while waiting for recovery.- Reduced or approximate accuracy: widen search/geofence assumptions or request full accuracy only from a user-triggered feature that needs it.
Before publishing a location, filter for valid horizontal accuracy, acceptable
timestamp age, and plausible movement. Use speed and course only when their
values are valid; negative values mean the measurement is unavailable.
CLServiceSession (iOS 18+)
Setup and Lifecycle
CLServiceSession declares your authorization requirements for a feature.
Hold a strong reference for the session's entire duration.
@MainActor
@Observable
final class LocationFeature {
private var serviceSession: CLServiceSession?
private var locationTask: Task<Void, Never>?
func activate() {
// Declare that this feature needs when-in-use authorization
serviceSession = CLServiceSession(authorization: .whenInUse)
locationTask = Task {
do {
for try await update in CLLocationUpdate.liveUpdates() {
guard let location = update.location else { continue }
// process location
}
} catch is CancellationError {
// Expected when the feature stops.
} catch {
// Show a degraded state or retry from user action.
}
}
}
func deactivate() {
locationTask?.cancel()
locationTask = nil
// Release the session to signal you no longer need location
serviceSession = nil
}
}Full Accuracy Request
Request full accuracy when the user has granted approximate-only permission:
// Requires Info.plist:
// NSLocationTemporaryUsageDescriptionDictionary
// NearbySearchPurpose: "Show nearby stores within walking distance."
let session = CLServiceSession(
authorization: .whenInUse,
fullAccuracyPurposeKey: "NearbySearchPurpose"
)Always Authorization
Only use .always when you need the system to relaunch your app in the
background for significant location changes after termination.
let session = CLServiceSession(authorization: .always)Requires NSLocationAlwaysAndWhenInUseUsageDescription in Info.plist.
Implicit vs. Explicit Sessions
On iOS 18+, CLLocationUpdate.liveUpdates() and CLMonitor create an
implicit CLServiceSession behind the scenes if you do not create one. You
need an explicit session when:
- You require
.alwaysauthorization - You need full accuracy via
fullAccuracyPurposeKey - You want to enforce explicit session management (add
NSLocationRequireExplicitServiceSessionto Info.plist)
CLMonitor for Geofencing (iOS 17+)
Basic Setup
Use CLMonitor for modern condition monitoring. It is an actor, so its
async APIs require await.
@available(iOS 17, *)
actor GeofenceMonitor {
private var monitor: CLMonitor?
private var monitoringTask: Task<Void, any Error>?
func startMonitoring(regions: [GeofenceRegion]) async {
let monitor = await CLMonitor("myAppGeofences")
self.monitor = monitor
// Add circular geographic conditions
for region in regions {
let condition = CLMonitor.CircularGeographicCondition(
center: region.center,
radius: region.radius
)
await monitor.add(condition, identifier: region.id)
}
// Listen for events
monitoringTask = Task {
for try await event in await monitor.events {
switch event.state {
case .satisfied:
// Device entered the region
handleEntry(identifier: event.identifier)
case .unsatisfied:
// Device exited the region
handleExit(identifier: event.identifier)
case .unknown:
break
default:
break
}
}
}
}
func stopMonitoring() async {
monitoringTask?.cancel()
monitoringTask = nil
if let monitor {
for identifier in await monitor.identifiers {
await monitor.remove(identifier)
}
}
monitor = nil
}
private func handleEntry(identifier: String) {
print("Entered region: \(identifier)")
}
private func handleExit(identifier: String) {
print("Exited region: \(identifier)")
}
}
struct GeofenceRegion: Identifiable {
let id: String
let center: CLLocationCoordinate2D
let radius: CLLocationDistance
}Critical CLMonitor Rules
Maximum 20 conditions per app. Adding more causes excess conditions to report
unmonitoredstate. This limit is per-app, not per-monitor.Do not recreate CLMonitor instances rapidly. Creating a monitor with the same name while one is still alive crashes the app. Reuse the instance and call
add/removeto change conditions.Subscribe to
eventsexactly once per CLMonitor. Cancelling and re-subscribing causes the new subscription to immediately cancel. Keep a single long-lived subscription.Use diffing for condition updates. Instead of removing all conditions and re-adding them, calculate which to add and which to remove.
Target iOS 18+ for best results. Pair
CLMonitorwithCLServiceSessionfor reliable authorization management.
Adding Conditions with Initial State
Specify an assumed initial state to avoid spurious events on first add:
await monitor.add(condition, identifier: "office", assuming: .unsatisfied)Use .unsatisfied when you believe the device is outside the region. Use
.satisfied when you believe the device is inside.
Updating Conditions Dynamically
func updateRegions(_ newRegions: [GeofenceRegion]) async {
guard let monitor else { return }
let existingIDs = Set(await monitor.identifiers)
let newIDs = Set(newRegions.map(\.id))
// Remove stale conditions
for id in existingIDs.subtracting(newIDs) {
await monitor.remove(id)
}
// Add new conditions
for region in newRegions where !existingIDs.contains(region.id) {
let condition = CLMonitor.CircularGeographicCondition(
center: region.center,
radius: region.radius
)
await monitor.add(condition, identifier: region.id, assuming: .unsatisfied)
}
}Checking Last Known State
if let record = await monitor.record(for: "office") {
let lastState = record.lastEvent.state
let lastDate = record.lastEvent.date
print("Region 'office' was \(lastState) at \(lastDate)")
}CLBackgroundActivitySession
Allow a when-in-use authorized app to receive location updates in the
background. Requires the Location updates background mode capability.
@available(iOS 17, *)
actor BackgroundLocationTracker {
private var backgroundSession: CLBackgroundActivitySession?
private var serviceSession: CLServiceSession?
private var trackingTask: Task<Void, Never>?
func startBackgroundTracking() {
// Declare authorization intent (iOS 18+)
serviceSession = CLServiceSession(authorization: .whenInUse)
// Start background activity session -- shows blue location indicator
backgroundSession = CLBackgroundActivitySession()
trackingTask = Task {
do {
for try await update in CLLocationUpdate.liveUpdates(.fitness) {
guard let location = update.location else { continue }
// Record location for fitness tracking, navigation, etc.
await recordLocation(location)
}
} catch is CancellationError {
// Expected when background tracking stops.
} catch {
// Persist an error state or retry from user action.
}
}
}
func stopBackgroundTracking() {
trackingTask?.cancel()
trackingTask = nil
backgroundSession?.invalidate()
backgroundSession = nil
serviceSession = nil
}
private func recordLocation(_ location: CLLocation) async {
// Persist to database, update Live Activity, etc.
}
}Background Requirements Summary
To receive location in the background you need ALL of these:
Background Modes > Location updatescapability enabled.NSLocationWhenInUseUsageDescriptionin Info.plist..whenInUseor.alwaysauthorization granted.- Either a
CLBackgroundActivitySessionheld or a Live Activity running. - An active
CLLocationUpdateorCLMonitorsubscription.
.always authorization is NOT required for background location. The
difference: with .always, the system can relaunch your terminated app for
significant location changes. With .whenInUse + background session, the
app must be running (foreground or suspended).
Significant Location Change Monitoring
Use when you only need coarse location updates at ~500-meter intervals. Extremely battery efficient because it piggybacks on cellular tower changes.
// Legacy CLLocationManager approach (still valid, no modern replacement)
let manager = CLLocationManager()
manager.startMonitoringSignificantLocationChanges()
// The delegate receives updates when the device moves ~500m+ from the
// last reported location. Updates arrive 1-5 minutes apart.There is no CLLocationUpdate equivalent for significant location changes.
Use CLLocationManager for this specific use case.
Visit Monitoring
Detect when the user arrives at or departs from a place. Useful for journaling, check-in, and context-aware features.
let manager = CLLocationManager()
manager.startMonitoringVisits()
// Delegate callback:
func locationManager(_ manager: CLLocationManager, didVisit visit: CLVisit) {
let coordinate = visit.coordinate
let arrivalDate = visit.arrivalDate
let departureDate = visit.departureDate
// departureDate == .distantFuture means the user is still at the location
}Region Monitoring Migration (CLCircularRegion to CLMonitor)
Older Delegate Approach
let region = CLCircularRegion(center: coordinate, radius: 200, identifier: "office")
region.notifyOnEntry = true
region.notifyOnExit = true
manager.startMonitoring(for: region)Modern Approach (iOS 17+)
// MODERN
let monitor = await CLMonitor("appMonitor")
let condition = CLMonitor.CircularGeographicCondition(center: coordinate, radius: 200)
await monitor.add(condition, identifier: "office")
for try await event in await monitor.events {
if event.identifier == "office" {
switch event.state {
case .satisfied: handleEntry()
case .unsatisfied: handleExit()
default: break
}
}
}Key differences:
| Aspect | CLCircularRegion | CLMonitor |
|---|---|---|
| API style | Delegate callbacks | Async sequence |
| Max regions | 20 per app | 20 per app |
| Entry/exit | Separate booleans | State enum (satisfied/unsatisfied) |
| Concurrency | @objc delegate | Actor-based |
| Min iOS | iOS 7 | iOS 17 |
Location Accuracy Management
Accuracy Levels for CLLocationManager
manager.desiredAccuracy = kCLLocationAccuracyBest // GPS, ~5m, highest power
manager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters // ~10m
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters // WiFi, ~100m
manager.desiredAccuracy = kCLLocationAccuracyKilometer // Cell tower, ~1km
manager.desiredAccuracy = kCLLocationAccuracyThreeKilometers // ~3km, lowest power
manager.desiredAccuracy = kCLLocationAccuracyReduced // ~5km, privacy-safeActivity Type (Influences Power Management)
manager.activityType = .other // Default
manager.activityType = .automotiveNavigation // Highway speeds, high accuracy
manager.activityType = .fitness // Walking/running
manager.activityType = .otherNavigation // Boats, trains
manager.activityType = .airborne // Drones, aircraft (iOS 12+)CLLocationUpdate has no filtering
CLLocationUpdate.liveUpdates() does not support desiredAccuracy or
distanceFilter. Filter the stream yourself:
for try await update in CLLocationUpdate.liveUpdates() {
guard let location = update.location,
location.horizontalAccuracy < 50,
location.horizontalAccuracy >= 0 else { continue }
// Use filtered location
}Testing Location in Simulator
Set a fixed simulated location
In Xcode: Debug > Simulate Location > choose a city or custom coordinate.
GPX File for a Moving Route
Create a .gpx file and add it to your Xcode project:
<?xml version="1.0"?>
<gpx version="1.1" creator="Xcode">
<wpt lat="37.3349" lon="-122.0090">
<time>2025-01-01T00:00:00Z</time>
<name>Apple Park</name>
</wpt>
<wpt lat="37.3318" lon="-122.0312">
<time>2025-01-01T00:01:00Z</time>
<name>Infinite Loop</name>
</wpt>
<wpt lat="37.3230" lon="-122.0322">
<time>2025-01-01T00:02:00Z</time>
<name>De Anza College</name>
</wpt>
</gpx>Set this file in the scheme: Edit Scheme > Run > Options > Default Location.
The simulator interpolates between waypoints using timestamps. Playback loops automatically when it reaches the last waypoint.
Programmatic Simulation in Tests
Use CLLocationManager with XCTest by injecting a location protocol:
protocol LocationProviding: Sendable {
func updates() -> AsyncStream<CLLocation>
}
// Production
struct LiveLocationProvider: LocationProviding {
func updates() -> AsyncStream<CLLocation> {
AsyncStream { continuation in
Task {
for try await update in CLLocationUpdate.liveUpdates() {
if let location = update.location {
continuation.yield(location)
}
}
continuation.finish()
}
}
}
}
// Test mock
struct MockLocationProvider: LocationProviding {
let locations: [CLLocation]
func updates() -> AsyncStream<CLLocation> {
AsyncStream { continuation in
for location in locations {
continuation.yield(location)
}
continuation.finish()
}
}
}Privacy and Info.plist Keys
Required Keys
| Key | When to add |
|---|---|
NSLocationWhenInUseUsageDescription |
Always, for any location use |
NSLocationAlwaysAndWhenInUseUsageDescription |
Only if requesting .always |
Optional Keys
| Key | Purpose |
|---|---|
NSLocationTemporaryUsageDescriptionDictionary |
Per-feature full-accuracy descriptions |
NSLocationRequireExplicitServiceSession |
Force explicit CLServiceSession usage (iOS 18+) |
NSLocationDefaultAccuracyReduced |
Default to approximate location |
UIBackgroundModes (includes location) |
Background location updates |
Usage Description Best Practices
Good: "Shows nearby coffee shops within walking distance so you can find your next stop quickly."
Bad: "This app uses your location."
App Review rejects vague usage descriptions. Be specific about what the user gains from sharing their location.
Common Pitfalls
CLMonitor crash on rapid recreation
// CRASH -- creating a monitor with a name already in use
let monitorA = await CLMonitor("myMonitor")
// ... immediately discard monitorA ...
let monitorB = await CLMonitor("myMonitor") // NSInternalInconsistencyExceptionFix: reuse the existing monitor instance. Only create a new one after the old one has been fully torn down (conditions removed, reference released, NOT in the same run loop).
Accuracy-limited updates need explicit handling
Approximate location may update infrequently. On iOS 18+, check diagnostic
properties such as accuracyLimited and locationUnavailable; otherwise,
treat update.location == nil as a state change and provide a degraded
experience instead of spinning or timing out.
Forgetting to hold CLBackgroundActivitySession
// WRONG -- session is immediately deallocated
func startBackground() {
let _ = CLBackgroundActivitySession()
// ^ No strong reference; session ends immediately
}
// CORRECT -- hold as a stored property
private var bgSession: CLBackgroundActivitySession?
func startBackground() {
bgSession = CLBackgroundActivitySession()
}Not checking horizontalAccuracy
// WRONG -- using location with negative accuracy (invalid)
guard let location = update.location else { continue }
updateMap(location) // May have accuracy of -1 (invalid)
// CORRECT
guard let location = update.location,
location.horizontalAccuracy >= 0 else { continue }
updateMap(location)A horizontalAccuracy of -1 means the coordinate is invalid.
References
- Apple docs: CLLocationUpdate
- Apple docs: CLServiceSession
- Apple docs: CLMonitor
- Apple docs: CLBackgroundActivitySession
- Apple docs: Requesting authorization
- Apple docs: Handling background location