---
title: How to Add Geofencing to an Ionic App
description: Add geofencing to an Ionic Angular app with the Capacitor Geofences plugin, from two-step location permissions to native notifications and a durable queue.
date:
  created: 2026-10-09
  updated: 2026-10-09
authors:
  - robingenz
categories:
  - Capacitor
  - Guides
  - Ionic Framework
  - SDKs
links:
  - Capacitor Geofences: sdks/capacitor/geofences.md
faq: true
---

# How to Add Geofencing to an Ionic App

To add geofencing to an Ionic app, you hand circular regions to the operating system through a native plugin, ask for background location in two separate steps, and react when the device enters or leaves a region. This Ionic geofencing tutorial builds a store reminder page in Ionic Angular with standalone components and signals, on top of the [Capacitor Geofences plugin](../../sdks/capacitor/geofences.md). You get one Angular service that covers permissions, registration, transitions, a native notification per geofence and a queue for transitions your app missed. The plugin is part of [Capawesome Insiders](../../insiders/index.md).

<!-- more -->

<div class="capawesome-z29o10a">
  <a href="https://capawesome.io/" target="_blank">
    <img alt="Try Capawesome Cloud free for 14 days — iOS builds without a Mac" src="https://capawesome.io/assets/banners/cloud-free-trial-capacitor-apps.png" />
  </a>
</div>

**Key takeaways:**

- The Capacitor Geofences plugin requires Capacitor 8 and replaces `cordova-plugin-geofence`, whose last npm release, 0.7.0, dates from 2017.
- Background location needs its own `requestPermissions(...)` call after the foreground permission, and `addGeofences(...)` rejects with `PERMISSION_DENIED` while only foreground location is granted.
- iOS monitors at most 20 regions per app and Android at most 100 geofences per app, both limits set by the operating system.
- A geofence's `notification` is shown by the operating system even while the app is not running, whereas the `geofenceTransition` event fires only while the app runs.
- The transition queue stores nothing until `setConfig({ maxSize })` is called; once enabled, it records every transition whether or not the app is running.

## What you will build

The app in this Ionic geofencing tutorial is a single Ionic page that lists a few stores, each with a toggle. Switching a toggle on registers a 200 meter geofence around that store, and when the user later walks into the region, the phone shows a reminder to open the shopping list, even if the app was swiped away hours earlier. Switching it off removes the geofence again.

All plugin logic lives in one `GeofenceService`, provided in root and holding its state in signals, and the page is a standalone component built from `ion-list`, `ion-item` and `ion-toggle`. The plugin calls themselves are plain TypeScript, so the same code works in an Ionic React or Vue app with a different wrapper around it.

Most geofencing tutorials for Ionic still build on Cordova. [`cordova-plugin-geofence`](https://www.npmjs.com/package/cordova-plugin-geofence){:target="_blank"} published its latest version, 0.7.0, in 2017, and its GitHub repository saw its last push in 2022. The `@ionic-native/geofence` wrapper stopped at 5.36.0 in 2021, and Awesome Cordova Plugins, the successor of Ionic Native, ships no geofence wrapper at all. The Capacitor Geofences plugin targets Capacitor 8 and uses the region monitoring of Android and iOS directly. [Announcing the Capacitor Geofences Plugin](./announcing-the-capacitor-geofences-plugin.md) explains that design and the full API, so this guide stays with the Ionic integration.

## Platform setup

The Capacitor Geofences plugin needs one manifest entry on Android and two usage strings on iOS before the first permission request. To install the Capacitor Geofences plugin, please refer to the [Installation](../../sdks/capacitor/geofences.md/#installation) section in the plugin documentation. The plugin needs no keys in `capacitor.config.ts`.

On Android, the plugin already declares `ACCESS_FINE_LOCATION`, `POST_NOTIFICATIONS` and `RECEIVE_BOOT_COMPLETED`. The background location permission is the one you add yourself, in `android/app/src/main/AndroidManifest.xml`:

```xml
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
```

The plugin leaves that line to you because Google Play reviews it. [Google Play's background location policy](https://support.google.com/googleplay/android-developer/answer/9799150){:target="_blank"} allows the permission only when it is required for the core functionality of the app, asks you to fill in the Permissions Declaration Form in the Play Console, and requires a prominent in-app disclosure before the request that explains the app uses location when it is closed or not in use. For the store reminder page, that disclosure is a short screen shown before the first toggle triggers the permission flow.

On iOS, add both usage descriptions to `ios/App/App/Info.plist`:

```xml
<key>NSLocationWhenInUseUsageDescription</key>
<string>The app needs access to your location to remind you when you are near a store.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>The app needs access to your location to remind you when you are near a store, even while it is closed.</string>
```

Apple's [authorization guide](https://developer.apple.com/documentation/corelocation/requesting-authorization-to-use-location-services){:target="_blank"} requires `NSLocationWhenInUseUsageDescription` for both access levels and `NSLocationAlwaysAndWhenInUseUsageDescription` for Always, and states that "authorization requests fail immediately if the required keys aren't present". With a key missing, the plugin's `requestPermissions(...)` and `addGeofences(...)` reject with an explicit error message.

## Request permissions

Geofencing in an Ionic app needs background location: the Always authorization on iOS and the background location permission on Android. Both platforms expect it in a second step, so [`requestPermissions(...)`](../../sdks/capacitor/geofences.md#requestpermissions) runs twice, first with `location` and then with `backgroundLocation`. The service starts with that method:

```typescript
import { Injectable } from '@angular/core';
import { Geofences } from '@capawesome-team/capacitor-geofences';

@Injectable({ providedIn: 'root' })
export class GeofenceService {
  async requestPermissions(): Promise<boolean> {
    let status = await Geofences.requestPermissions({
      permissions: ['location'],
    });
    if (status.location !== 'granted') {
      return false;
    }
    status = await Geofences.requestPermissions({
      permissions: ['backgroundLocation'],
    });
    if (status.backgroundLocation !== 'granted') {
      return false;
    }
    await Geofences.requestPermissions({ permissions: ['notifications'] });
    return true;
  }
}
```

The second call behaves differently per platform. On Android 10 and later, the foreground and background permissions cannot be requested together, and from Android 11 the [background permission dialog](https://developer.android.com/develop/sensors-and-location/location/permissions/background){:target="_blank"} no longer offers the option at all, so the plugin opens the app's location settings, where the user has to pick "Allow all the time". On iOS, the second call shows the system prompt that upgrades While Using the App to Always, and Apple's authorization guide states that you "can make the request only once". The third call requests the notification permission, which Android 13 and later require before any notification, the reminder included, can appear.

Ask in context. The page calls this method when the user switches on the first toggle, right after your disclosure screen, rather than on app start. If the user grants only foreground location, [`addGeofences(...)`](../../sdks/capacitor/geofences.md#addgeofences) rejects with the `PERMISSION_DENIED` error code. Once [`checkPermissions()`](../../sdks/capacitor/geofences.md#checkpermissions) reports `denied`, no prompt appears again. A button that calls [`openSettings()`](../../sdks/capacitor/geofences.md#opensettings) is then the way back.

## Register a geofence

`addGeofences(...)` hands one or more circular regions to the operating system, each with an `id`, a center in `latitude` and `longitude`, and a `radius` in meters. The service gets a small `Store` model and an `add(...)` method that registers the store and records its ID in a signal. The following blocks show only the members they add to `GeofenceService`:

```typescript
import { Injectable, signal } from '@angular/core';
import { Geofences } from '@capawesome-team/capacitor-geofences';

export interface Store {
  id: string;
  name: string;
  latitude: number;
  longitude: number;
}

@Injectable({ providedIn: 'root' })
export class GeofenceService {
  readonly activeIds = signal<string[]>([]);

  async add(store: Store): Promise<void> {
    if (!(await this.requestPermissions())) {
      return;
    }
    await Geofences.addGeofences({
      geofences: [
        {
          id: store.id,
          latitude: store.latitude,
          longitude: store.longitude,
          radius: 200,
          notifyOnEnter: true,
          notifyOnExit: false,
          notification: this.reminderFor(store),
        },
      ],
    });
    this.activeIds.update((ids) => (ids.includes(store.id) ? ids : [...ids, store.id]));
  }
}
```

Passing the store's own `id` keeps the geofence and your data linked; without one, the plugin generates a UUID and returns it in `ids`. `notifyOnEnter` and `notifyOnExit` both default to `true`, and the reminder only makes sense on arrival, so the exit is switched off. On Android, `androidNotifyOnDwell` with `androidLoiteringDelay` in milliseconds adds a dwell transition after the user stayed inside for that long. iOS has no dwell transition. Geofences never expire either, so remove them yourself when they are no longer needed.

The 200 meter radius follows Apple's numbers without being an Apple recommendation. Apple's [region monitoring sample](https://developer.apple.com/documentation/corelocation/monitoring-the-user-s-proximity-to-geographic-regions){:target="_blank"} uses a radius of 200 meters. Apple's [archived region monitoring guide](https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/LocationAwarenessPG/RegionMonitoring/RegionMonitoring.html){:target="_blank"} tells you that "for testing purposes, you can assume that the minimum distance is approximately 200 meters". Android's [geofencing guide](https://developer.android.com/develop/sensors-and-location/location/geofencing){:target="_blank"} suggests a minimum radius between 100 and 150 meters. At the other end, iOS reports a monitoring failure for a region larger than [`maximumRegionMonitoringDistance`](https://developer.apple.com/documentation/corelocation/cllocationmanager/maximumregionmonitoringdistance){:target="_blank"}, and the plugin clamps the radius to that maximum before registering.

The number of regions is capped by the operating system. Android allows 100 geofences per app, per device user on multi-user devices, according to the same Android guide, and Apple's [`startMonitoring(for:)` reference](https://developer.apple.com/documentation/corelocation/cllocationmanager/startmonitoring%28for:%29){:target="_blank"} states that "an app can register up to 20 regions at a time". Beyond either limit, `addGeofences(...)` rejects with `GEOFENCE_LIMIT_EXCEEDED`, so a chain with hundreds of stores registers the closest ones and swaps them as the user moves.

One more difference shows up in the first test. Android fires an enter transition right away when the device is already inside a new geofence, while iOS waits for a boundary crossing. [Why Are Your Geofences Not Triggering on iOS?](./why-are-your-geofences-not-triggering-on-ios.md) explains the iOS threshold rules behind that.

## Handle transitions

The [`geofenceTransition`](../../sdks/capacitor/geofences.md#addlistenergeofencetransition-) event delivers each enter, exit or dwell transition to JavaScript, with the details nested under `event.transition`. The service keeps the most recent one in a signal that the page can render:

```typescript
import { Injectable, signal } from '@angular/core';
import { Geofences, GeofenceTransition } from '@capawesome-team/capacitor-geofences';

@Injectable({ providedIn: 'root' })
export class GeofenceService {
  readonly lastTransition = signal<GeofenceTransition | null>(null);

  async startListening(): Promise<void> {
    await Geofences.addListener('geofenceTransition', (event) => {
      this.lastTransition.set(event.transition);
    });
  }
}
```

Each transition carries the `geofenceId`, the `type` as `'ENTER'`, `'EXIT'` or `'DWELL'` (compare against the `TransitionType` enum), and a `timestamp` in milliseconds since epoch. `latitude` and `longitude` hold the triggering position on Android and are always `null` on iOS, because Core Location does not provide it. Look the store up by `geofenceId` instead, which works on both platforms since the ID is the store's own.

The event is a live feed. It reaches your listener only while the app is running and the listener is registered, and transitions detected while the app was in the background or terminated are not replayed. That makes the listener the right place for updating the open page, and the wrong place for anything the user must not miss. The next two sections cover those cases.

## Notify the user

The per-geofence `notification` option is how geofencing in an Ionic app reaches a user whose app is closed. You pass a `title` and a `text`, and the operating system shows that local notification when a transition for the geofence is detected, including while the app is in the background or not running at all. The `add(...)` method above takes it from a small helper:

```typescript
import { Injectable } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class GeofenceService {
  private reminderFor(store: Store) {
    return {
      title: `You are near ${store.name}`,
      text: 'Open your shopping list before you go in.',
    };
  }
}
```

The text is fixed when you register the geofence, and the README states that the plugin shows it when a transition for that geofence is detected, which includes an exit. That is the second reason `notifyOnExit` is `false` in `add(...)`: with exit enabled, the user would read "You are near" while walking away.

This guide does not add `@capacitor/local-notifications` on top. The `geofenceTransition` event only fires while the app runs, so a notification scheduled from the listener could never cover the closed app, and the README does not say whether the plugin's notification also appears while the app is in the foreground, so a second, app-scheduled notification could duplicate it. The plugin's notification alone covers every app state. The README also documents no event for a tap on that notification.

## Queue missed transitions

The transition queue is the durable record of geofence transitions, and it is off until you call [`setConfig(...)`](../../sdks/capacitor/geofences.md#setconfig) with `maxSize`. Once enabled, the plugin appends every transition to an on-device queue whether or not your app is running, which includes the transitions that happened while no listener was attached. The queue survives app restarts, force-quits and device reboots, holds 1,000 transitions by default, and drops the oldest ones once it is full. Transitions detected before the `setConfig(...)` call are not stored.

The README's getting started sample drains the queue without enabling it first, so that sample alone reads an empty queue. The service reads the queue in pages with [`getQueuedTransitions(...)`](../../sdks/capacitor/geofences.md#getqueuedtransitions). It acknowledges each page with [`deleteQueuedTransitions(...)`](../../sdks/capacitor/geofences.md#deletequeuedtransitions):

```typescript
import { Injectable } from '@angular/core';
import { Geofences } from '@capawesome-team/capacitor-geofences';

@Injectable({ providedIn: 'root' })
export class GeofenceService {
  async drainQueue(): Promise<void> {
    let hasMore = true;
    while (hasMore) {
      const result = await Geofences.getQueuedTransitions({ limit: 100 });
      if (!result.transitions.length) {
        break;
      }
      await this.persist(result.transitions);
      await Geofences.deleteQueuedTransitions({
        upToId: result.transitions[result.transitions.length - 1].id,
      });
      hasMore = result.hasMore;
    }
  }
}
```

`persist(...)` is your own method that writes the transitions to local storage or sends them to your API. Reading and deleting are separate steps, so a crash in between leaves the transitions in the queue. Every queued transition has a numeric `id` that increases monotonically, and `upToId` deletes everything up to and including it. A transition that reached the listener live is in the queue as well, so treat the queue as the source of truth for anything you store.

A `start()` method enables the queue, registers the listener and drains the queue on launch and whenever the app becomes visible again, like the README does:

```typescript
import { Injectable } from '@angular/core';
import { Geofences } from '@capawesome-team/capacitor-geofences';

@Injectable({ providedIn: 'root' })
export class GeofenceService {
  async start(): Promise<void> {
    await Geofences.setConfig({ maxSize: 1000 });
    await this.startListening();
    await this.drainQueue();
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'visible') {
        this.drainQueue();
      }
    });
  }
}
```

`setConfig(...)` replaces the whole stored configuration. If you later add an upload `url` so that the plugin sends transitions to your server natively, pass `maxSize` and `url` in the same call; the [announcement post](./announcing-the-capacitor-geofences-plugin.md#upload-transitions-to-your-server) covers that upload and its server contract.

## Remove geofences

The Capacitor Geofences plugin keeps geofences registered across app launches, so the page restores its toggle state from the operating system rather than from memory. [`getGeofences()`](../../sdks/capacitor/geofences.md#getgeofences) returns every geofence currently monitored:

```typescript
import { Injectable, signal } from '@angular/core';
import { Geofences } from '@capawesome-team/capacitor-geofences';

@Injectable({ providedIn: 'root' })
export class GeofenceService {
  readonly activeIds = signal<string[]>([]);

  async restore(): Promise<void> {
    const { geofences } = await Geofences.getGeofences();
    this.activeIds.set(geofences.flatMap((geofence) => geofence.id ?? []));
  }
}
```

On Android, the plugin re-registers geofences automatically after a device reboot or an app update, and on iOS the operating system persists the monitored regions. Switching a toggle off calls [`removeGeofences(...)`](../../sdks/capacitor/geofences.md#removegeofences) with the store's ID:

```typescript
import { Injectable, signal } from '@angular/core';
import { Geofences } from '@capawesome-team/capacitor-geofences';

@Injectable({ providedIn: 'root' })
export class GeofenceService {
  readonly activeIds = signal<string[]>([]);

  async remove(id: string): Promise<void> {
    await Geofences.removeGeofences({ ids: [id] });
    this.activeIds.update((ids) => ids.filter((activeId) => activeId !== id));
  }
}
```

When the user signs out, [`removeAllGeofences()`](../../sdks/capacitor/geofences.md#removeallgeofences) clears every region in one call. With the service complete, the page renders one toggle per store, reads `activeIds` for the toggle state and shows the last live transition:

```typescript
import { Component, OnInit, inject } from '@angular/core';
import {
  IonContent,
  IonHeader,
  IonItem,
  IonList,
  IonNote,
  IonTitle,
  IonToggle,
  IonToolbar,
} from '@ionic/angular/standalone';
import { GeofenceService, Store } from './geofence.service';

@Component({
  selector: 'app-stores',
  imports: [IonContent, IonHeader, IonItem, IonList, IonNote, IonTitle, IonToggle, IonToolbar],
  template: `
    <ion-header>
      <ion-toolbar>
        <ion-title>Store reminders</ion-title>
      </ion-toolbar>
    </ion-header>
    <ion-content>
      <ion-list>
        @for (store of stores; track store.id) {
          <ion-item>
            <ion-toggle
              [checked]="geofenceService.activeIds().includes(store.id)"
              (ionChange)="toggle(store, $event.detail.checked)"
            >
              {{ store.name }}
            </ion-toggle>
          </ion-item>
        }
      </ion-list>
      @if (geofenceService.lastTransition(); as transition) {
        <ion-note class="ion-padding">{{ transition.type }} at {{ transition.geofenceId }}</ion-note>
      }
    </ion-content>
  `,
})
export class StoresPage implements OnInit {
  readonly geofenceService = inject(GeofenceService);
  readonly stores: Store[] = [
    { id: 'store-berlin', name: 'Berlin Mitte', latitude: 52.52, longitude: 13.405 },
    { id: 'store-cupertino', name: 'Cupertino', latitude: 37.33182, longitude: -122.03118 },
  ];

  async ngOnInit(): Promise<void> {
    await this.geofenceService.start();
    await this.geofenceService.restore();
  }

  async toggle(store: Store, checked: boolean): Promise<void> {
    if (checked) {
      await this.geofenceService.add(store);
    } else {
      await this.geofenceService.remove(store.id);
    }
  }
}
```

The component is standalone and imports each Ionic component from `@ionic/angular/standalone`, so no NgModule is involved. In a real app, `stores` comes from your API, and you call `start()` once at app startup rather than per page if several pages use the service.

## FAQ

### How do I add geofencing to an Ionic app?

Install the [Capacitor Geofences plugin](../../sdks/capacitor/geofences.md), add `ACCESS_BACKGROUND_LOCATION` on Android and both location usage strings on iOS, request `location` and then `backgroundLocation` in two calls, and register regions with `addGeofences(...)`. Attach a `notification` to each geofence for the closed app, listen to `geofenceTransition` while the app runs, and enable the queue with `setConfig({ maxSize })` to read missed transitions.

### Do geofences fire when the Ionic app is closed?

Yes, with background location granted. The operating system detects the transition, shows the geofence's notification and, if the queue is enabled, stores the transition for your app to read on the next launch. The `geofenceTransition` event does not fire for it, since that event only reaches a running app.

### Does cordova-plugin-geofence still work with Ionic?

`cordova-plugin-geofence` has had no npm release since 0.7.0 in 2017, and its Ionic Native wrapper `@ionic-native/geofence` stopped at 5.36.0 in 2021. Awesome Cordova Plugins, the successor of Ionic Native, has no geofence wrapper at all. The Capacitor Geofences plugin is the Capacitor-native replacement.

### How many geofences can an Ionic app register?

20 on iOS and 100 on Android, per app. Both are operating system limits, and `addGeofences(...)` rejects with `GEOFENCE_LIMIT_EXCEEDED` beyond them. Register the regions nearest to the user and replace them as the user moves.

### Does the plugin work with Ionic React or Vue?

Yes. The plugin is framework-agnostic and works in any Capacitor app, including Ionic with Angular, React or Vue. Only the service and page wrappers in this guide are Angular-specific; the plugin calls stay the same.

### Is the Capacitor Geofences plugin free?

No. It is part of the [Capawesome Insiders](../../insiders/index.md) subscription and installs from the Capawesome npm registry with your license key. It has no web implementation, so every method rejects with an unimplemented error in the browser.

## Related posts

- [Announcing the Capacitor Geofences Plugin](./announcing-the-capacitor-geofences-plugin.md)
- [Why Are Your Geofences Not Triggering on iOS?](./why-are-your-geofences-not-triggering-on-ios.md)
- [An Alternative to Transistorsoft Background Geolocation](./alternative-to-transistorsoft-background-geolocation.md)

## Try Capawesome Cloud

Geofencing changes the native side of your Ionic app: a new plugin, a manifest permission and two `Info.plist` keys, none of which a web update can ship. Capawesome Cloud builds and signs the Android and iOS binaries for you, without a Mac for iOS, and [How to Sign & Build Capacitor Apps in the Cloud](./how-to-sign-and-build-your-capacitor-app-in-the-cloud.md) walks through the setup.

[Try Capawesome Cloud Free](https://capawesome.io){ .md-button .md-button--primary }

## Conclusion

Before shipping, test the store reminder with real movement: install a build on a phone, register a store a few hundred meters away, close the app and walk across the boundary. The iOS Simulator and the Android Emulator can confirm that permissions are granted and that `getGeofences()` returns your regions, but delivery timing only shows on a device. If the reminder appears on Android and not on an iPhone, [Why Are Your Geofences Not Triggering on iOS?](./why-are-your-geofences-not-triggering-on-ios.md) goes through the causes one by one.

If you have any questions, join us on the [Capawesome Discord server](https://discord.gg/VCXxSVjefW){:target="_blank"}. To stay updated on the latest news, subscribe to the [Capawesome newsletter](https://capawesome.io/newsletter/){:target="_blank"}.
