← All articles

April 5, 2026  ·  17 min read

Local Notifications and Geofencing in iOS

A practical deep-dive into local notifications in iOS — the three trigger types, permission handling, custom actions, and how to combine UNLocationNotificationTrigger with Core Location to build a geofence-based notification system entirely on-device.

Local Notifications and Geofencing in iOS

Most iOS developers encounter push notifications first — the server-side setup, APNs configuration, device tokens. It’s the more visible part of the notifications system. But there’s an entire category of notifications that require none of that infrastructure: no server, no APNs, no internet connection. Just your app, iOS, and a trigger condition.

Local notifications are scheduled entirely on-device. From the user’s perspective, they’re identical to a push notification — same banner, same lock screen card, same sound. But the entire lifecycle happens locally. The user can be on a plane in airplane mode and your notification fires exactly when it should.

What makes local notifications genuinely interesting is their trigger system. You’re not just scheduling something for a fixed time — you can trigger on a countdown, on a calendar date, or on the user physically crossing a geographic boundary. That last one, combined with Core Location, is where things get powerful.

This article builds up from the basics of local notifications through to a practical geofencing implementation — the kind you’d actually ship in a production app.


The Foundation — Permissions

Before scheduling anything, you need user authorization. Local notifications go through the exact same UNUserNotificationCenter pipeline as remote push notifications and are subject to identical permission requirements. This surprises developers more often than it should — there’s an intuitive assumption that “local” means no permission needed. It doesn’t.

1
2
3
4
5
6
UNUserNotificationCenter.current()
    .requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
        if granted {
            print("Ready to schedule local notifications")
        }
    }

If the user denies permission, notifications are silently dropped when you schedule them — no error, no callback, nothing. And once denied, the system dialog never appears again. The only path back is directing the user to Settings → Your App → Notifications manually. This is why the timing and context of your permission prompt matters — don’t ask on cold launch with no context. Ask at the moment the user has just done something that makes the value of notifications obvious.

UNUserNotificationCenter is a singleton, accessed via .current(). There’s no public initializer — it manages system-level state (authorization status, pending requests, delivered notifications) that must be centralized. Everything in the local notifications system flows through it.

You can check the current authorization status before attempting to schedule anything:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
UNUserNotificationCenter.current().getNotificationSettings { settings in
    switch settings.authorizationStatus {
    case .authorized:    // full permission granted
    case .provisional:   // silent delivery to Notification Center only
    case .denied:        // user said no — redirect to Settings
    case .notDetermined: // never asked — safe to request
    case .ephemeral:     // App Clips only
    @unknown default: break
    }
}

The Anatomy of a Local Notification

Every local notification is built from two pieces: a UNMutableNotificationContent that describes what the notification looks like, and a trigger that describes when it fires. Wrap both in a UNNotificationRequest and hand it to UNUserNotificationCenter.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
let content = UNMutableNotificationContent()
content.title = "Time to drink water 💧"
content.body = "You haven't had water in 30 minutes."
content.sound = .default
content.badge = 1
content.userInfo = ["type": "hydration_reminder"]  // custom payload, accessible on tap

let trigger = UNTimeIntervalNotificationTrigger(
    timeInterval: 30 * 60,
    repeats: false
)

let request = UNNotificationRequest(
    identifier: "hydration_reminder",  // reusing the same ID replaces the previous request
    content: content,
    trigger: trigger
)

UNUserNotificationCenter.current().add(request) { error in
    if let error { print("Scheduling failed: \(error)") }
}

The identifier is more important than it looks. If you schedule two requests with the same identifier, the second replaces the first. This is useful — it means you can update a pending notification (say, rescheduling a reminder) without explicitly cancelling the old one first. It’s also a footgun if you accidentally reuse an identifier across unrelated notifications.


The Three Trigger Types

Local notifications support three distinct trigger mechanisms. Each maps to a different class of user need.

UNTimeIntervalNotificationTrigger — Countdown-Based

Fire after a fixed number of seconds. The simplest trigger and the most useful during development (set timeInterval: 5 to test the full notification flow quickly).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// One-shot: fire after 30 minutes
let trigger = UNTimeIntervalNotificationTrigger(
    timeInterval: 30 * 60,
    repeats: false
)

// Repeating: fire every hour
let repeatingTrigger = UNTimeIntervalNotificationTrigger(
    timeInterval: 60 * 60,
    repeats: true     // timeInterval must be ≥ 60 seconds when repeats: true
)

The 60-second minimum for repeating triggers is enforced by iOS and throws an error if violated. It’s easy to hit this during development when you set a short interval for testing and forget to switch repeats back to false.

UNCalendarNotificationTrigger — Date and Time Based

Fire at a specific date, time, or recurring schedule. Built on DateComponents, which gives you fine-grained control — schedule for 9:30 AM every Monday, or a one-time event on a specific date.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// Fire every day at 9:30 AM
var dailyComponents = DateComponents()
dailyComponents.hour = 9
dailyComponents.minute = 30

let dailyTrigger = UNCalendarNotificationTrigger(
    dateMatching: dailyComponents,
    repeats: true
)

// Fire once on a specific date
var oneTimeComponents = Calendar.current.dateComponents(
    [.year, .month, .day, .hour, .minute],
    from: someDate
)

let oneTimeTrigger = UNCalendarNotificationTrigger(
    dateMatching: oneTimeComponents,
    repeats: false
)

The calendar trigger respects the device’s timezone and locale. If the user travels across timezones, iOS adjusts accordingly — a notification scheduled for 9:30 AM fires at 9:30 AM local time, wherever the user is.

UNLocationNotificationTrigger — Geography-Based

Fire when the user enters or exits a geographic region. This is the most powerful trigger and the one with the most constraints — which we’ll cover in depth shortly.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
let region = CLCircularRegion(
    center: CLLocationCoordinate2D(latitude: 12.9775, longitude: 77.5713),
    radius: 300,  // meters
    identifier: "majestic_metro"
)
region.notifyOnEntry = true
region.notifyOnExit = false

let trigger = UNLocationNotificationTrigger(
    region: region,
    repeats: true  // fire every time the user enters, not just the first time
)

Managing the Notification Lifecycle

Scheduling is only half the story. Production apps need to inspect, update, and cancel notifications — pending ones that haven’t fired yet, and delivered ones sitting in Notification Center.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class NotificationManager {
    private let center = UNUserNotificationCenter.current()

    // Pending — scheduled but not yet shown
    func listPending() async -> [UNNotificationRequest] {
        await center.pendingNotificationRequests()
    }

    func cancel(identifiers: [String]) {
        center.removePendingNotificationRequests(withIdentifiers: identifiers)
    }

    func cancelAll() {
        center.removeAllPendingNotificationRequests()
    }

    // Delivered — shown and sitting in Notification Center
    func listDelivered() async -> [UNNotification] {
        await center.deliveredNotifications()
    }

    func removeDelivered(identifiers: [String]) {
        center.removeDeliveredNotifications(withIdentifiers: identifiers)
    }

    func clearAllDelivered() {
        center.removeAllDeliveredNotifications()
    }
}

A practical pattern: when the user completes an action that a reminder was scheduled for (marks a task done, logs a workout), cancel the corresponding pending notification immediately. Nothing erodes trust faster than a notification that fires for something the user already did.


Foreground Delivery

By default, iOS suppresses local notifications when the app is active in the foreground. The notification fires and is delivered to the app, but the user sees no banner. This is often the right behaviour — if the user is already in the app, showing them a banner for something they can see on screen is redundant.

But sometimes you want the banner anyway. Implement UNUserNotificationCenterDelegate and opt in explicitly:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
extension AppDelegate: UNUserNotificationCenterDelegate {

    // Called when a notification arrives while the app is in the foreground
    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                 willPresent notification: UNNotification,
                                 withCompletionHandler completionHandler:
                                     @escaping (UNNotificationPresentationOptions) -> Void) {
        // Opt in to show the banner even while active
        completionHandler([.banner, .sound, .badge])

        // Or suppress it and handle silently
        // completionHandler([])
    }
}

Set the delegate in didFinishLaunchingWithOptions — before any notifications can arrive:

1
UNUserNotificationCenter.current().delegate = self

Adding Custom Actions

Local notifications support the same custom action system as remote push notifications. When the user long-presses a notification, action buttons appear — and the user can respond without opening the app.

The architecture is always the same: define UNNotificationAction instances, group them into a UNNotificationCategory, register the category, and set content.categoryIdentifier on the notification content to wire them together.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Define actions
let snoozeAction = UNNotificationAction(
    identifier: "SNOOZE",
    title: "Snooze 10 min",
    options: []              // background — app not opened
)

let dismissAction = UNNotificationAction(
    identifier: "DISMISS",
    title: "Dismiss",
    options: [.destructive]  // red title
)

// Group into a category
let reminderCategory = UNNotificationCategory(
    identifier: "REMINDER",
    actions: [snoozeAction, dismissAction],
    intentIdentifiers: [],
    options: []
)

// Register — must happen every launch
UNUserNotificationCenter.current().setNotificationCategories([reminderCategory])

Then when scheduling the notification:

1
2
3
4
5
let content = UNMutableNotificationContent()
content.title = "Daily Standup in 5 minutes"
content.body = "Your team is waiting."
content.sound = .default
content.categoryIdentifier = "REMINDER"  // ← wires in the action buttons

One distinction worth knowing: for remote notifications, the category comes from the "category" key in the APNs JSON payload. For local notifications, it’s set directly on content.categoryIdentifier in code. Same underlying system, different entry point.

Handle the tapped action in the delegate:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func userNotificationCenter(_ center: UNUserNotificationCenter,
                             didReceive response: UNNotificationResponse,
                             withCompletionHandler completionHandler: @escaping () -> Void) {

    switch response.actionIdentifier {

    case "SNOOZE":
        // Reschedule for 10 minutes from now
        scheduleReminder(after: 10 * 60)

    case "DISMISS":
        // Nothing to do — notification is already gone
        break

    case UNNotificationDefaultActionIdentifier:
        // User tapped the notification body — open relevant screen
        navigateToReminderDetail()

    default:
        break
    }

    completionHandler()  // always call this
}

UNNotificationDefaultActionIdentifier is a system constant representing the user tapping the notification body itself — not a custom button. Always handle it explicitly if you have deep linking to do.


Geofencing with Core Location and Local Notifications

Here’s where everything gets genuinely interesting. The UNLocationNotificationTrigger approach we touched on earlier works well for simple entry/exit scenarios — but there are times when you want to do more than just show a notification when a region boundary is crossed. Maybe you need to make an API call, update a local database, or show a notification whose content depends on real-time data fetched on entry. For those cases, you wire Core Location’s region monitoring directly to your own notification scheduling logic.

The use case: imagine a cab services app that wants to show a contextual notification whenever the user enters a known transit hub — Majestic Metro in Bengaluru, for example. “Cabs are available near Majestic. Tap to book.” The user doesn’t need to have the app open. The notification should fire reliably, even if the app hasn’t been touched in hours.

The Two Approaches

Before jumping into code, it’s worth being explicit about the two approaches and when to use each:

UNLocationNotificationTrigger — Let UNUserNotificationCenter manage the geofence internally. You define the region and the notification content together. iOS handles the monitoring. Use this when all you need is to show a fixed notification on entry.

CLLocationManager + local notification — You monitor regions yourself via CLLocationManager, and when didEnterRegion fires, you schedule a local notification (or do anything else you need). Use this when entry needs to trigger additional work beyond displaying a notification.

For the cab services use case, either works. We’ll implement the CLLocationManager approach since it’s more flexible and shows the full picture.

Permissions — Both Are Required

This use case needs two permissions working in concert:

1
2
3
4
// Info.plist — all three are required
// NSLocationAlwaysAndWhenInUseUsageDescription
// NSLocationWhenInUseUsageDescription  
// NSLocationAlwaysUsageDescription

You need Always location authorization — not “When In Use.” The “When In Use” authorization only works while the app is in the foreground, which defeats the entire purpose of a geofence-triggered notification.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class PermissionCoordinator {

    private let locationManager = CLLocationManager()

    func requestAll() {
        // Location — must be Always, not just When In Use
        locationManager.requestAlwaysAuthorization()

        // Notifications — same permission as any local notification
        UNUserNotificationCenter.current()
            .requestAuthorization(options: [.alert, .sound]) { granted, _ in
                print("Notification permission: \(granted)")
            }
    }
}

Ask for location authorization before notification authorization — if you ask for both at once, iOS staggers the dialogs, and the user may be confused about why an app needs both. Explaining the geofencing value proposition first makes the location prompt land better.

Modelling the Zones

Keep your geofenced locations clean and typed. A struct per zone gives you a single source of truth that flows through the rest of the system:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct GeofencedZone {
    let identifier: String
    let coordinate: CLLocationCoordinate2D
    let radius: CLLocationDistance   // meters
    let notificationTitle: String
    let notificationBody: String
}

let transitHubs: [GeofencedZone] = [
    GeofencedZone(
        identifier: "majestic_metro_bengaluru",
        coordinate: CLLocationCoordinate2D(latitude: 12.9775, longitude: 77.5713),
        radius: 300,
        notificationTitle: "Cabs available near Majestic Metro 🚕",
        notificationBody: "Multiple cabs nearby. Tap to book."
    ),
    GeofencedZone(
        identifier: "koramangala_hub",
        coordinate: CLLocationCoordinate2D(latitude: 12.9352, longitude: 77.6245),
        radius: 250,
        notificationTitle: "Cabs available near Koramangala 🚕",
        notificationBody: "Tap to see available rides."
    )
]

The Geofence Manager

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class GeofenceManager: NSObject {

    private let locationManager = CLLocationManager()
    private let zones: [GeofencedZone]

    // Cooldown tracking — prevents duplicate notifications on boundary flicker
    private var lastNotificationTime: [String: Date] = [:]
    private let cooldownInterval: TimeInterval = 30 * 60  // 30 minutes

    init(zones: [GeofencedZone]) {
        self.zones = zones
        super.init()
        locationManager.delegate = self
        locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
    }

    func startMonitoring() {
        for zone in zones {
            let region = CLCircularRegion(
                center: zone.coordinate,
                radius: min(zone.radius, locationManager.maximumRegionMonitoringDistance),
                identifier: zone.identifier
            )
            region.notifyOnEntry = true
            region.notifyOnExit = false

            locationManager.startMonitoring(for: region)
        }
    }

    func stopMonitoring() {
        locationManager.monitoredRegions.forEach {
            locationManager.stopMonitoring(for: $0)
        }
    }
}

Clamping to maximumRegionMonitoringDistance is important — iOS caps the maximum radius (typically several hundred meters to a few kilometers depending on the device), and exceeding it silently clips the region to the maximum.

Responding to Region Entry

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
extension GeofenceManager: CLLocationManagerDelegate {

    func locationManager(_ manager: CLLocationManager,
                         didEnterRegion region: CLRegion) {

        guard let zone = zones.first(where: { $0.identifier == region.identifier }) else {
            return
        }

        guard shouldNotify(for: zone.identifier) else {
            print("Cooldown active for \(zone.identifier) — skipping notification")
            return
        }

        scheduleNotification(for: zone)
    }

    func locationManager(_ manager: CLLocationManager,
                         didExitRegion region: CLRegion) {
        // Cancel the notification if the user exits before tapping
        // (optional — depends on your UX intent)
        UNUserNotificationCenter.current()
            .removePendingNotificationRequests(withIdentifiers: [region.identifier])
    }

    func locationManager(_ manager: CLLocationManager,
                         monitoringDidFailFor region: CLRegion?,
                         withError error: Error) {
        print("Monitoring failed for \(region?.identifier ?? "unknown"): \(error)")
    }

    // MARK: - Private

    private func shouldNotify(for zoneId: String) -> Bool {
        if let last = lastNotificationTime[zoneId],
           Date().timeIntervalSince(last) < cooldownInterval {
            return false
        }
        lastNotificationTime[zoneId] = Date()
        return true
    }

    private func scheduleNotification(for zone: GeofencedZone) {
        let content = UNMutableNotificationContent()
        content.title = zone.notificationTitle
        content.body = zone.notificationBody
        content.sound = .default
        content.userInfo = ["zone_id": zone.identifier]

        // Fire almost immediately — the geofence crossing is the trigger
        let trigger = UNTimeIntervalNotificationTrigger(
            timeInterval: 1,
            repeats: false
        )

        // Include timestamp in identifier so each entry creates a fresh notification
        let identifier = "\(zone.identifier)_\(Int(Date().timeIntervalSince1970))"

        let request = UNNotificationRequest(
            identifier: identifier,
            content: content,
            trigger: trigger
        )

        UNUserNotificationCenter.current().add(request) { error in
            if let error { print("Notification scheduling failed: \(error)") }
        }
    }
}

The cooldown guard deserves attention. iOS can and does fire didEnterRegion multiple times for the same physical boundary crossing — especially when the user lingers near the edge of a geofence. Without a cooldown, a user walking slowly past the geofence boundary might get five notifications in quick succession. Thirty minutes is a reasonable default; adjust based on your use case.

Handling the Notification Tap

When the user taps the notification, you get the zone_id back from userInfo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func userNotificationCenter(_ center: UNUserNotificationCenter,
                             didReceive response: UNNotificationResponse,
                             withCompletionHandler completionHandler: @escaping () -> Void) {

    let userInfo = response.notification.request.content.userInfo

    if let zoneId = userInfo["zone_id"] as? String {
        // Navigate to the cab booking screen for this zone
        NavigationRouter.shared.openCabBooking(near: zoneId)
    }

    completionHandler()
}

The Constraints You Need to Know

Geofencing with Core Location has a set of system-level constraints that aren’t immediately obvious from the API surface. Ship without understanding these and you’ll end up with a support queue full of “notifications aren’t working” reports.

The 20-region limit is system-wide. iOS enforces a hard limit of 20 simultaneously monitored regions across all apps on the device — not just your app. If you try to register more than 20 regions, the extras are silently ignored. There is no error. The strategy for apps with many locations is to monitor only the regions closest to the user’s current location, and swap them dynamically as the user moves.

Geofencing uses cell tower and WiFi triangulation, not GPS. This is intentional — it’s what makes geofencing battery-efficient. The tradeoff is accuracy: expect the entry callback to fire 50–300 meters after the actual boundary crossing, not exactly at the boundary. Don’t set radii smaller than 100–150 meters or you’ll get missed triggers and phantom entries as the location estimate fluctuates.

The app must have been launched at least once since the last reboot. Core Location region monitoring persists across app termination — registered regions survive even if the app is killed. But after a device reboot, the app must be launched at least once before region monitoring resumes. This is a fundamental constraint of the platform and not something you can work around.

didEnterRegion can fire for regions you’re already inside. When monitoring starts, if the user is already within a registered region, iOS may immediately fire didEnterRegion. This is documented behaviour, but it catches people off guard. The cooldown guard above handles this gracefully.


Putting It All Together

The full system wires together cleanly at app startup. In AppDelegate or your app’s entry point via @UIApplicationDelegateAdaptor:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func application(_ application: UIApplication,
                 didFinishLaunchingWithOptions launchOptions: ...) -> Bool {

    // 1. Set notification delegate
    UNUserNotificationCenter.current().delegate = self

    // 2. Request permissions
    PermissionCoordinator().requestAll()

    // 3. Start geofence monitoring
    let geofenceManager = GeofenceManager(zones: transitHubs)
    geofenceManager.startMonitoring()

    return true
}

From this point, the system runs entirely in the background. The user opens your app once, grants location and notification permissions, and from then on — whether the app is in the foreground, background, or terminated — iOS wakes the app when a region boundary is crossed, your delegate fires, and a notification gets scheduled. The user experiences it as the app “just knowing” they’re near a transit hub.


Wrapping Up

Local notifications are underused relative to their capability. Developers default to remote push notifications for everything — even for things that have nothing to do with a server, that should work offline, that are inherently device-local events. A workout reminder, a calendar alert, a location-aware prompt — none of these need a backend.

The geofencing use case illustrates the ceiling of what’s possible: an entirely on-device system that delivers contextually relevant, location-aware notifications with no network dependency. The constraints are real — the 20-region limit, the location accuracy tradeoff, the battery vs. precision balance — but they’re manageable with the right architecture.

The mental model to hold on to: UNUserNotificationCenter is the single coordination point for everything notification-related on the device. Local or remote, simple or rich, time-based or location-based — it all flows through there. Master that API and the rest is configuration.