DEV Community

Robin for Capawesome

Posted on Originally published at capawesome.io

How to Add Geofencing to an Ionic App

In this tutorial, we build a store reminder page in Ionic Angular: switch on a toggle for a store, and when you later walk within 200 meters of it, your phone reminds you to open your shopping list, even if the app was closed hours ago. You will learn how to request background location in two steps, register and remove geofences, show a native notification per geofence and read the transitions your app missed.

Most Ionic geofencing tutorials still use cordova-plugin-geofence, whose last release (0.7.0) dates from 2017. We use a Capacitor plugin built on the native region monitoring of Android and iOS instead.

Prerequisites

  • An Ionic Angular app on Capacitor 8 with standalone components.
  • The Capacitor Geofences plugin. Full transparency: we build this plugin at Capawesome. It is part of Capawesome Insiders, a paid subscription. For setup, see the Installation section of the docs.
  • The ACCESS_BACKGROUND_LOCATION permission on Android and the two location usage strings on iOS (see below).

All plugin logic lives in one GeofenceService with signals. The plugin calls are plain TypeScript, so they work in Ionic React or Vue too.

Platform setup

The plugin needs no capacitor.config.ts keys. On Android, it already declares ACCESS_FINE_LOCATION, POST_NOTIFICATIONS and RECEIVE_BOOT_COMPLETED, and you add background location yourself in android/app/src/main/AndroidManifest.xml:


Enter fullscreen mode Exit fullscreen mode

That line is left to you because Google Play reviews it: the background location policy requires it to serve core functionality, a Permissions Declaration Form in the Play Console, and a prominent in-app disclosure before the request. For our page, that's a short screen before the first toggle triggers the permission flow.

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

NSLocationWhenInUseUsageDescription
The app needs access to your location to remind you when you are near a store.
NSLocationAlwaysAndWhenInUseUsageDescription
The app needs access to your location to remind you when you are near a store, even while it is closed.
Enter fullscreen mode Exit fullscreen mode

Per Apple's authorization guide, "authorization requests fail immediately if the required keys aren't present", and the plugin rejects with an explicit error in that case.

Request permissions

Geofencing needs background location (Always on iOS). Both platforms expect it as a second step, so we call requestPermissions(...) first with location and then with backgroundLocation:

async requestPermissions(): Promise {
  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;
}
Enter fullscreen mode Exit fullscreen mode

From Android 11, the background permission dialog no longer offers the option, so the plugin opens the location settings where the user picks "Allow all the time". On iOS, it shows the prompt that upgrades to Always, and Apple notes you "can make the request only once". The third call covers notifications, required on Android 13+.

Ask in context, when the user switches on the first toggle. With only foreground location, addGeofences(...) rejects with PERMISSION_DENIED. Once checkPermissions() reports denied, a button calling openSettings() is the way back.

Register a geofence

addGeofences(...) hands circular regions to the operating system, each with an id, a center and a radius in meters. Here is the service with a Store model, add(...) and a notification helper:

import { Injectable, signal } from '@angular/core';
import { Geofences, GeofenceTransition } 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([]);
  readonly lastTransition = signal(null);

  async add(store: Store): Promise {
    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]));
  }

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

  // requestPermissions() from the previous section
}
Enter fullscreen mode Exit fullscreen mode

Using the store's own id links the geofence to your data. Enter and exit both default to true; we only want arrivals. Android adds dwell via androidNotifyOnDwell and androidLoiteringDelay, iOS has none. Geofences never expire, so you remove them yourself.

Why 200 meters? Apple's region monitoring sample uses it, and Apple's archived guide assumes "approximately 200 meters" as the minimum distance for testing purposes. Android's geofencing guide suggests at least 100 to 150 meters. At the top end, iOS reports a monitoring failure for regions above maximumRegionMonitoringDistance, so the plugin clamps the radius to that maximum.

The OS caps the count at 20 regions per app on iOS and 100 on Android. Beyond that, addGeofences(...) rejects with GEOFENCE_LIMIT_EXCEEDED, so register the nearest stores and swap them as the user moves. Also, Android fires an enter transition right away if the device is already inside, while iOS waits for a boundary crossing. Why Are Your Geofences Not Triggering on iOS? explains why.

Handle transitions

The geofenceTransition event delivers each transition under event.transition, with a geofenceId, a type ('ENTER', 'EXIT' or 'DWELL') and a timestamp. latitude and longitude are always null on iOS, so look stores up by geofenceId.

The event only fires while the app is running with the listener attached, and background transitions are not replayed. Use it to update the open page, not for anything the user must not miss. The listener is registered in start() below.

Notify the user

The per-geofence notification (our reminderFor(...)) is how you reach a user whose app is closed. The operating system shows it on a transition even when the app is in the background or not running. The text is fixed at registration and shows on exits too, which is another reason notifyOnExit is false.

Skip @capacitor/local-notifications here: a notification scheduled from the listener can't cover the closed app and might duplicate the plugin's.

Queue missed transitions

The transition queue is off until you call setConfig(...) with maxSize. Then the plugin records every transition on the device whether or not your app runs. The queue survives restarts and reboots, holds 1,000 entries by default and drops the oldest when full.

The rest of the service enables the queue, listens, drains with getQueuedTransitions(...) and deleteQueuedTransitions(...), and restores and removes geofences:

async start(): Promise {
  await Geofences.setConfig({ maxSize: 1000 });
  await Geofences.addListener('geofenceTransition', (event) => {
    this.lastTransition.set(event.transition);
  });
  await this.drainQueue();
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'visible') {
      this.drainQueue();
    }
  });
}

async drainQueue(): Promise {
  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;
  }
}

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

async remove(id: string): Promise {
  await Geofences.removeGeofences({ ids: [id] });
  this.activeIds.update((ids) => ids.filter((activeId) => activeId !== id));
}
Enter fullscreen mode Exit fullscreen mode

persist(...) is your own storage or API call. Since reading and deleting are separate, a crash in between keeps the transitions queued, and upToId deletes everything up to that id. Live transitions are queued too, so the queue is your source of truth.

setConfig(...) replaces the whole config, so if you add a native upload url later, pass it with maxSize (details in the announcement post).

Remove geofences

Geofences persist across launches, so restore() reads the toggle state from getGeofences(). On Android, the plugin re-registers them after a reboot or app update; on iOS, the OS persists the regions. A toggle off calls removeGeofences(...), and removeAllGeofences() clears everything on sign-out.

The standalone page renders one toggle per store and the last live transition:

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: `


        Store reminders




        @for (store of stores; track store.id) {


              {{ store.name }}


        }

      @if (geofenceService.lastTransition(); as transition) {
        {{ transition.type }} at {{ transition.geofenceId }}
      }

  `,
})
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 {
    await this.geofenceService.start();
    await this.geofenceService.restore();
  }

  async toggle(store: Store, checked: boolean): Promise {
    if (checked) {
      await this.geofenceService.add(store);
    } else {
      await this.geofenceService.remove(store.id);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

In a real app, stores comes from your API, and start() runs once at app startup.

Conclusion

Test with real movement before shipping: install a build on a phone, register a store a few hundred meters away, close the app and walk across the boundary. Simulators confirm permissions and that getGeofences() returns your regions, but delivery timing only shows on a device. If the reminder works on Android but not on iPhone, the iOS troubleshooting post above lists the causes.

The full post on the Capawesome blog includes an FAQ on closed apps, region limits and React or Vue, and if you have questions, ask in the comments.

Top comments (0)