While working on an iOS app last year that utilized location events, I had one critical issue to solve for: continuously monitoring user location. The app needed to monitor user location regardless of whether the app was in the foreground, background, suspended, or terminated.
After searching the internet and reading developer documentation, I was eventually able to come to a solution for this, but I figured I could hopefully give you a clearer, more detailed explanation on how to solve this issue.
Understanding App States on iOS
First, it's important to understand what "killed" means on iOS:
- Foreground — app is on screen, full access to everything (this is the state the app is in when you are actively using it)
- Background — app is off screen but still running, limited time before suspension
- Suspended — app is in memory but no code is executing
- Terminated/Killed — app is not in memory at all
Most location APIs work in the foreground and background with the right entitlements. Almost none of them survive suspension or termination. startUpdatingLocation() falls into that category — once the app is suspended, location updates stop. Once it's terminated, the process is gone entirely.
The Exception: OS-Managed Location APIs
The exception is two specific APIs that iOS treats differently: region monitoring (geofences) and significant location change monitoring. Both of these are managed by the OS, not your process. If the OS detects an event — a geofence crossing or a significant location change — it will wake your app to deliver it, even if your app was terminated.
This two-layer approach is the key insight: use significant location changes as a coarse background heartbeat to keep your geofence set fresh, and use CLCircularRegion geofences for precise entry/exit detection. Both survive termination.
Project Setup
There are three things you need to configure in Xcode before any of this works. Skip any one of them and your app will silently fail to receive background events.
1. Enable the Background Modes capability
In Xcode, select your app target → Signing & Capabilities → click + Capability → add Background Modes → check Location updates.
This adds the location value to UIBackgroundModes in your Info.plist. Without it, iOS won't deliver location events to your app in the background regardless of what your code does.
2. Add the required Info.plist keys
You need both usage description strings — iOS requires NSLocationWhenInUseUsageDescription even when you're requesting Always access:
<key>NSLocationWhenInUseUsageDescription</key>
<string>We need your location to show nearby hotspots.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We need your location in the background to notify you when you're near a hotspot.</string>
The NSLocationAlwaysAndWhenInUseUsageDescription string is what the user sees when they're asked to upgrade to Always — make it specific about why the background access is needed.
3. Configure the CLLocationManager
Two properties need to be set before you start monitoring:
locationManager.allowsBackgroundLocationUpdates = true
locationManager.pausesLocationUpdatesAutomatically = false
allowsBackgroundLocationUpdates is required or background callbacks won't fire. pausesLocationUpdatesAutomatically = false prevents iOS from suspending updates when it thinks the device is stationary — important for significant location change monitoring to stay active.
Requesting Always Authorization
Call requestAlwaysAuthorization() to kick off the permission prompt. On iOS 13+, the system may show a "When In Use" dialog first and offer the Always upgrade as a separate step — this is normal iOS behavior. Your app still works with When In Use (you can seed an initial geofence set while foregrounded), but the persistent heartbeat requires Always.
LocationManager.shared.requestAlwaysAuthorization()
One edge case worth handling: iOS doesn't always fire didChangeAuthorization when the user changes permissions in Settings and returns to your app. Call refreshAuthorizationStatus() from your scene delegate's sceneDidBecomeActive to catch it:
func sceneDidBecomeActive(_ scene: UIScene) {
LocationManager.shared.refreshAuthorizationStatus()
}
The Solution
The implementation revolves around a LocationManager singleton that combines both APIs. Here's the high-level setup:
import CoreLocation
class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
static let shared = LocationManager()
private let clManager = CLLocationManager()
@Published var lastLocation: CLLocation?
@Published var authorizationStatus: CLAuthorizationStatus = .notDetermined
private override init() {
super.init()
clManager.delegate = self
clManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
}
}
When the user grants Always authorization, you start both monitoring layers:
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
authorizationStatus = manager.authorizationStatus
switch manager.authorizationStatus {
case .authorizedAlways:
// Start the persistent heartbeat — survives termination
clManager.startMonitoringSignificantLocationChanges()
// Seed initial geofences while we have a known location
if let location = clManager.location {
refreshGeofences(around: location)
}
case .authorizedWhenInUse:
// No background heartbeat, but we can still seed geofences while foregrounded
if let location = clManager.location {
refreshGeofences(around: location)
}
default:
break
}
}
Dynamic Geofence Refresh
iOS caps you at 20 monitored regions system-wide. The trick is treating those 20 slots as a sliding window — whenever you get a significant location update, you re-center the window on the user's current position.
private let maxMonitoredRegions = 20
private let geofenceRefreshInterval: TimeInterval = 60
private var lastRefreshTime: Date = .distantPast
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.last else { return }
lastLocation = location
// Throttle API calls — don't refresh more than once per minute
guard Date().timeIntervalSince(lastRefreshTime) > geofenceRefreshInterval else { return }
lastRefreshTime = Date()
refreshGeofences(around: location)
}
private func refreshGeofences(around location: CLLocation) {
dataSource?.fetchNearbyPoints(latitude: location.coordinate.latitude,
longitude: location.coordinate.longitude) { [weak self] points in
guard let self else { return }
// Sort by distance, keep only the nearest 20
let nearest = points
.sorted { $0.distance < $1.distance }
.prefix(self.maxMonitoredRegions)
let newIDs = Set(nearest.map(\.id))
let currentIDs = Set(self.clManager.monitoredRegions.compactMap { $0.identifier })
// Remove regions that are no longer nearby
for region in self.clManager.monitoredRegions where !newIDs.contains(region.identifier) {
self.clManager.stopMonitoring(for: region)
}
// Add new regions, skip ones already being monitored
for point in nearest where !currentIDs.contains(point.id) {
let region = CLCircularRegion(
center: point.coordinate,
radius: point.radius,
identifier: point.id
)
region.notifyOnEntry = true
region.notifyOnExit = true
self.clManager.startMonitoring(for: region)
}
}
}
Handling Entry and Exit Events
Geofence events can fire multiple times near a boundary. A 10-second debounce per region prevents duplicate notifications:
private var entryDebounceTimers: [String: Timer] = [:]
private var exitDebounceTimers: [String: Timer] = [:]
private let debounceInterval: TimeInterval = 10
func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) {
entryDebounceTimers[region.identifier]?.invalidate()
entryDebounceTimers[region.identifier] = Timer.scheduledTimer(withTimeInterval: debounceInterval, repeats: false) { [weak self] _ in
self?.delegate?.didEnterRegion(id: region.identifier)
}
}
func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) {
exitDebounceTimers[region.identifier]?.invalidate()
exitDebounceTimers[region.identifier] = Timer.scheduledTimer(withTimeInterval: debounceInterval, repeats: false) { [weak self] _ in
self?.delegate?.didExitRegion(id: region.identifier)
}
}
Entry and exit timers are intentionally kept separate — a recent entry shouldn't be able to suppress a valid exit event.
Integration
The manager is designed around two protocols so you can plug in your own data source and event handler without touching the core logic:
protocol GeofenceDataSource: AnyObject {
func fetchNearbyPoints(
latitude: Double,
longitude: Double,
completion: @escaping ([PointOfInterest]) -> Void
)
}
protocol GeofenceEventDelegate: AnyObject {
func didEnterRegion(id: String)
func didExitRegion(id: String)
}
struct PointOfInterest {
let id: String
let coordinate: CLLocationCoordinate2D
let radius: CLLocationDistance
let distance: CLLocationDistance
}
Wire it up in your app delegate or scene delegate:
LocationManager.shared.dataSource = myAPIClient
LocationManager.shared.delegate = myEventHandler
LocationManager.shared.requestAlwaysAuthorization()
One thing worth noting: iOS doesn't always fire didChangeAuthorization when the user changes permissions in Settings and returns to your app. Call refreshAuthorizationStatus() from sceneDidBecomeActive to catch those cases.
Takeaways
- startUpdatingLocation() does not survive termination. If you need location events from a killed app, you need geofences or significant location changes — both are managed by the OS, not your process.
- Combine both APIs. Significant location changes act as a persistent heartbeat that keep your geofence set centered on the user. Geofences handle precise entry/exit detection.
- Manage the 20-region cap dynamically. Treat it as a sliding window — refresh the set on each location update, keeping only the nearest points.
- Debounce geofence events. Region boundaries can cause rapid repeated events. A short debounce (10s) prevents duplicate notifications without meaningfully delaying real ones.
The full source is on GitHub if you want to drop it directly into a project:
View on GitHub — ios-persistent-geofencing