Kyle Young

← All Posts

How to Make an iOS App Monitor Location When Terminated/Killed

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:

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.

Significant location changes fire roughly every 500 meters and require a cell tower transition. They're not precise, but they're persistent — the OS will relaunch your app to deliver them even from a killed state.

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

The full source is on GitHub if you want to drop it directly into a project:

View on GitHub — ios-persistent-geofencing