---
title: Working Around the 20-Geofence Limit on iOS
description: iOS monitors at most 20 regions per app. Learn how to register only the nearest geofences, add a refresh region and swap them as the user moves in Capacitor.
date:
  created: 2026-10-22
  updated: 2026-10-22
authors:
  - robingenz
categories:
  - Capacitor
  - Guides
  - SDKs
links:
  - Capacitor Geofences: sdks/capacitor/geofences.md
faq: true
---

# Working Around the 20-Geofence Limit on iOS

The iOS geofence limit is 20 regions per app, and Apple does not raise it for anyone. A store locator with 300 branches, a field service app with hundreds of customer sites, or a city guide with every point of interest hits that ceiling on the first sync, and the 21st region never fires. The workaround is the one Apple suggests itself: register only the regions nearest to the user, keep one slot for a large refresh region, and swap the set whenever the user leaves it. This post builds that pattern with the [Capacitor Geofences plugin](../../sdks/capacitor/geofences.md), covers the Android side with its 100-geofence limit, and explains when the swap can and cannot run. The plugin is part of [Capawesome Insiders](../../insiders/index.md).

<!-- more -->

<div class="capawesome-z29o10a">
  <a href="https://capawesome.io/" target="_blank">
    <img alt="Thousands of teams ship faster with Capawesome Cloud Native Builds and Live Updates" src="https://capawesome.io/assets/banners/cloud-teams-ship-faster-with-capacitor.png" />
  </a>
</div>

**Key takeaways:**

- Core Location lets one app monitor at most 20 regions of any type at a time, shared by every location manager in the app; Android allows 100 geofences per app and device user.
- `addGeofences(...)` checks the limit before it registers anything and rejects with `GEOFENCE_LIMIT_EXCEEDED`, so existing regions keep running.
- Register the 19 nearest places plus one large refresh region that only reports exits, and recompute the set when the user leaves it.
- Swap by diffing the registered ids, and remove before you add, because the plugin counts every geofence in the call against the regions already monitored on iOS.
- The swap runs in JavaScript, so it is reliable on screen and on launch; a refresh exit while the app is terminated lands in the queue and is handled on the next start.

## The two limits

Apple caps region monitoring at 20 regions per app, and the cap covers more than your geofences. The [`startMonitoring(for:)` reference](https://developer.apple.com/documentation/corelocation/cllocationmanager/startmonitoring(for:)){:target="_blank"} states that "an app can register up to 20 regions at a time", and Apple's condition monitoring article adds that Core Location "prevents any single app from monitoring more than 20 conditions of any type simultaneously". The regions are shared by all location manager objects in the app, so a beacon region registered by another plugin and a geofence registered by this one draw from the same 20 slots. When a 21st region is requested, the location manager reports a `regionMonitoringFailure` error to the delegate instead of monitoring it.

Android is more generous but capped as well. Google's [geofencing guide](https://developer.android.com/develop/sensors-and-location/location/geofencing){:target="_blank"} allows 100 geofences per app on single-user devices, and 100 per app per device user on multi-user devices. Past that, the geofencing client fails the request with `GEOFENCE_TOO_MANY_GEOFENCES`.

The Capacitor Geofences plugin checks both limits before it touches the operating system. [`addGeofences(...)`](../../sdks/capacitor/geofences.md#addgeofences) rejects with the error code `GEOFENCE_LIMIT_EXCEEDED` when the regions already monitored plus the regions in the call exceed 20 on iOS or 100 on Android, and nothing in the call is registered. On iOS, the count comes from the location manager's `monitoredRegions` set, so regions from other code in your app are included. On Android, the plugin counts the geofences in its own store, and Google Play services enforce the limit independently for anything registered elsewhere. A call that gets past the pre-check and still fails inside the operating system is rejected with `ADD_FAILED`, and on iOS the plugin stops monitoring the regions of that call again so that the previously registered ones stay intact.

## Nearest-N registration

The workaround is to treat the 20 slots as a window around the user, not as the whole list. Apple's archived [region-monitoring guide](https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/LocationAwarenessPG/RegionMonitoring/RegionMonitoring.html){:target="_blank"} says it directly: "consider registering only those regions in the user's immediate vicinity. As the user's location changes, you can remove regions that are now farther away and add regions coming up on the user's path." A user with 300 branches in the app is never near more than a handful of them.

The pattern has three parts, and the rest of this post implements each one:

- **A place list**: the full set of places lives in your app, in a database or an API response, with an id, coordinates and the radius each place should get. The operating system only ever sees a subset.
- **A refresh region**: one of the slots holds a large region centered on the user's current position that only reports exits. Its exit means the user has moved far enough that the registered subset may no longer be the nearest one.
- **A swap routine**: a function that fetches the current position, picks the nearest places, and brings the registered set in line with them. It runs on launch, whenever the app returns to the foreground, and on every refresh exit.

On iOS, that leaves 19 slots for places. On Android, the same code runs with 99, and the refresh region is not strictly required, but one code path for both platforms is easier to reason about than a platform switch.

## Pick the nearest places

Selecting the nearest places is a sort by distance from the current position. The haversine formula is accurate enough for a radius decision, because a geofence of 200 meters does not care about a few meters of error at city scale:

```typescript
interface Place {
  id: string;
  latitude: number;
  longitude: number;
  radius: number;
}

const EARTH_RADIUS_IN_METERS = 6371000;

const distanceInMeters = (
  from: { latitude: number; longitude: number },
  to: { latitude: number; longitude: number },
): number => {
  const toRadians = (degrees: number) => (degrees * Math.PI) / 180;
  const deltaLatitude = toRadians(to.latitude - from.latitude);
  const deltaLongitude = toRadians(to.longitude - from.longitude);
  const a =
    Math.sin(deltaLatitude / 2) ** 2 +
    Math.cos(toRadians(from.latitude)) *
      Math.cos(toRadians(to.latitude)) *
      Math.sin(deltaLongitude / 2) ** 2;
  return 2 * EARTH_RADIUS_IN_METERS * Math.asin(Math.sqrt(a));
};

const sortByDistance = (
  places: Place[],
  position: { latitude: number; longitude: number },
): Place[] =>
  [...places].sort(
    (a, b) => distanceInMeters(position, a) - distanceInMeters(position, b),
  );
```

The number of place slots is the platform limit minus one for the refresh region. `Capacitor.getPlatform()` tells the two apart at runtime:

```typescript
import { Capacitor } from '@capacitor/core';

const PLACE_SLOTS = Capacitor.getPlatform() === 'ios' ? 19 : 99;

const selectNearest = (
  places: Place[],
  position: { latitude: number; longitude: number },
) => {
  const sorted = sortByDistance(places, position);
  const excluded = sorted.slice(PLACE_SLOTS);
  const clearanceInMeters = Math.min(
    Number.POSITIVE_INFINITY,
    ...excluded.map((place) => distanceInMeters(position, place) - place.radius),
  );
  return { selected: sorted.slice(0, PLACE_SLOTS), clearanceInMeters };
};
```

`clearanceInMeters` is the distance from the current position to the nearest boundary of any excluded place, which is its center distance minus its radius. The nearest center is not always the nearest boundary: an excluded place 1,100 meters away with a 600-meter radius begins at 500 meters, closer than one 1,000 meters away with a 100-meter radius. The refresh region is sized from the clearance in the next step, which is why the function returns it alongside the selection.

## Add a refresh region

The refresh region should fire before the user reaches a place that is not registered. Its center is the current position and its radius is the clearance, so the exit arrives while the user is still outside every unregistered geofence. A floor of 500 meters keeps the region from collapsing when the places are dense, because iOS reports a region exit only after the device has moved a minimum distance past the boundary, and a region of a few dozen meters would swap on every short walk. The floor has a cost: when the clearance is smaller than the floor, the user can enter an excluded place before the refresh exit arrives, and that transition is missed. In dense areas, the foreground and position-driven refreshes described below are what keep the set current, not the refresh region:

```typescript
import type { Geofence } from '@capawesome-team/capacitor-geofences';

const REFRESH_ID = 'refresh';
const MINIMUM_REFRESH_RADIUS_IN_METERS = 500;

const createRefreshRegion = (
  position: { latitude: number; longitude: number },
  clearanceInMeters: number,
): Geofence => ({
  id: REFRESH_ID,
  latitude: position.latitude,
  longitude: position.longitude,
  radius: Math.max(clearanceInMeters, MINIMUM_REFRESH_RADIUS_IN_METERS),
  notifyOnEnter: false,
  notifyOnExit: true,
});
```

When every place fits into the slots, there is no excluded place, the clearance is infinite and the radius becomes very large. That is intended. iOS cannot monitor a region larger than the location manager's [`maximumRegionMonitoringDistance`](https://developer.apple.com/documentation/corelocation/cllocationmanager/maximumregionmonitoringdistance){:target="_blank"}, and Apple's documented behavior is an error, not a smaller region. The plugin clamps the radius to that distance on iOS before it registers the region, so an oversized refresh region becomes the largest region the device supports instead of a rejected call. Android has no fixed maximum radius.

Two properties keep the refresh region quiet. `notifyOnEnter: false` suppresses the enter transition that Android would otherwise report immediately after registration, since the user is inside the region by construction. And the region gets no `notification`, so the user never sees a system notification for a bookkeeping event.

## Swap on exit

The swap brings the registered set in line with the new selection without removing the regions that stay. A full `removeAllGeofences()` followed by `addGeofences(...)` would work, but it has a side effect on Android: the plugin registers geofences with an initial trigger of `INITIAL_TRIGGER_ENTER`, so a place the user is currently inside reports a second enter transition on every re-add. Diffing the ids avoids that, and [`getGeofences()`](../../sdks/capacitor/geofences.md#getgeofences) returns the currently monitored set to diff against:

```typescript
import { Geofences } from '@capawesome-team/capacitor-geofences';
import { Geolocation } from '@capacitor/geolocation';

let pendingRefresh: Promise<void> | undefined;

const refreshGeofences = (places: Place[]): Promise<void> => {
  if (!pendingRefresh) {
    pendingRefresh = runRefresh(places).finally(() => {
      pendingRefresh = undefined;
    });
  }
  return pendingRefresh;
};

const runRefresh = async (places: Place[]) => {
  const { coords } = await Geolocation.getCurrentPosition();
  const position = { latitude: coords.latitude, longitude: coords.longitude };
  const { selected, clearanceInMeters } = selectNearest(places, position);
  const wanted = [...selected, createRefreshRegion(position, clearanceInMeters)];

  const { geofences } = await Geofences.getGeofences();
  const registeredIds = new Set(geofences.map((geofence) => geofence.id));
  const wantedIds = new Set(wanted.map((geofence) => geofence.id));

  const idsToRemove = [...registeredIds].filter(
    (id) => !wantedIds.has(id) || id === REFRESH_ID,
  );
  const geofencesToAdd = wanted.filter(
    (geofence) => !registeredIds.has(geofence.id) || geofence.id === REFRESH_ID,
  );

  if (idsToRemove.length > 0) {
    await Geofences.removeGeofences({ ids: idsToRemove });
  }
  await Geofences.addGeofences({ geofences: geofencesToAdd });
};
```

The refresh region is removed and re-added on every swap, because its center and radius change each time, and because the pre-check described below counts a re-added id as a new region. The `pendingRefresh` guard serializes the triggers: a refresh exit, a return to the foreground and a position update can all arrive within a second of each other, and two swaps that read the same registered set would both try to fill the slots, with the second one rejected by the limit pre-check. While a swap runs, every further call waits for it instead of starting its own. The example reads the current position with `@capacitor/geolocation`, which is enough when the swap runs in the foreground; the next section covers an app that already tracks positions.

The order of the two calls matters on iOS. The plugin's pre-check adds every geofence in the call to the number of regions already monitored, whether or not its id is monitored already, so adding 19 new places while 19 old ones are still registered is rejected with `GEOFENCE_LIMIT_EXCEEDED` even though the removal would have made room. Removing first keeps the count under 20 at every step. The same order is harmless on Android.

The place geofences themselves are plain entries from the place list, so `notifyOnEnter`, `notifyOnExit` and a `notification` per place work as in any other setup. [How to Add Geofencing to an Ionic App](./how-to-add-geofencing-to-an-ionic-app.md) covers that part, from the permission flow to the local notification.

## When the swap runs

The swap is JavaScript, and that decides where it can run. Three triggers cover the foreground reliably, and the fourth case comes with a caveat.

The first trigger is the refresh exit while the app is running. The [`geofenceTransition`](../../sdks/capacitor/geofences.md#addlistenergeofencetransition-) event is a live feed delivered while the web view is alive, so a listener that filters for the refresh id can start the swap right away:

```typescript
import { Geofences, TransitionType } from '@capawesome-team/capacitor-geofences';

const listenForRefreshExits = async (places: Place[]) => {
  await Geofences.addListener('geofenceTransition', (event) => {
    const { geofenceId, type } = event.transition;
    if (geofenceId === REFRESH_ID && type === TransitionType.Exit) {
      refreshGeofences(places).catch((error) => console.error('Geofence swap failed', error));
    }
  });
};
```

The second and third triggers are app start and every return to the foreground. Run the swap after the permissions are granted on launch, and again on `visibilitychange` when the document becomes visible. Those two runs are what catch the movement the app did not see, and they cost one position fix each.

The fourth case is a refresh exit while the app is in the background or terminated, and here the swap does not run. iOS relaunches a terminated app for a region event, but Apple's region-monitoring guide gives it "around 10 seconds" to handle the event, which does not reliably cover a cold web view start plus the position fix and two plugin calls. The plugin handles transitions natively for that reason: it appends the exit to the [queue](../../sdks/capacitor/geofences.md#queue) when the queue is enabled, shows the region's notification, and uploads the transition when a `url` is configured, all without starting the web view. The refresh exit is therefore recorded, not lost, and the swap on the next launch acts on it. The consequence for the user is a gap: between the refresh exit and the next app start, the registered places are the ones around the position where the app last ran, so a user who drives to another city with the app terminated has no geofences near them until the app opens. A larger minimum refresh radius does not close that gap, it only makes the exit arrive later.

An app that already tracks positions can close most of it. The [Capacitor Background Geolocation plugin](../../sdks/capacitor/background-geolocation.md) delivers a `positionChange` event during an active watch session while the web view is alive, and a watch session keeps the app running in the background. Calling the swap from that event whenever the distance to the last swap position exceeds the current refresh radius replaces the position fix and keeps the registered set current for as long as the session runs. Without a watch session, the foreground triggers are all you have.

Reboots differ per platform, and the pattern handles both without extra code. On Android, the plugin re-registers its geofences after a reboot and after an app update, including the refresh region. On iOS, the operating system persists the monitored regions itself, and the `monitoredRegions` reference states that they are repopulated after a relaunch; the plugin makes no claim about re-registration on iOS beyond that.

## Radius sizing

The radius of each place decides whether the system can resolve a crossing, and the figures for the two platforms come from different places. For iOS, Apple's archived guide states that notifications are reported only after the device crosses the boundary, moves away from it by a minimum distance and stays there for at least 20 seconds, and that "for testing purposes, you can assume that the minimum distance is approximately 200 meters". Apple's sample in the condition monitoring article uses a radius of 200 meters as well, so a place radius of at least 200 meters follows Apple's numbers without being an Apple recommendation. [Why Are Your Geofences Not Triggering on iOS?](./why-are-your-geofences-not-triggering-on-ios.md) covers the threshold rule and the other causes of a silent region.

For Android, Google's geofencing guide recommends a minimum radius between 100 and 150 meters, because Wi-Fi location is accurate to about 20 to 50 meters and larger radii reduce how often the device checks for a crossing. In rural areas without Wi-Fi, where accuracy drops to hundreds of meters, Google suggests larger geofences.

The refresh region plays by different rules. It is as large as the gap to the next unregistered place allows, often kilometers, and it only needs its exit to arrive at some point before the user reaches that place. Places that overlap each other are fine on both platforms; each region reports its own transitions, so two branches 150 meters apart can both have a 200-meter radius.

## FAQ

### How many geofences can an iOS app monitor at once?

20. Apple's `startMonitoring(for:)` reference states that an app can register up to 20 regions at a time, and the limit applies to regions of any type across all location managers in the app. The limit is a property of Core Location, not of the [Capacitor Geofences plugin](../../sdks/capacitor/geofences.md), and there is no entitlement or setting that raises it.

### What happens when I add the 21st geofence?

The plugin rejects the call with the error code `GEOFENCE_LIMIT_EXCEEDED` before it registers anything, so the 20 regions that are already monitored keep working and the ones in the rejected call are not registered. Check the error code in your catch block and trigger a swap instead of retrying the same call.

### Do regions from other plugins count against the limit?

Yes, on iOS. The 20 regions are shared by every location manager in the app, so a beacon region or a region registered by another plugin takes one of the slots, and the plugin's pre-check sees it in `monitoredRegions`. On Android, the plugin counts only its own geofences, and Google Play services enforce the 100-geofence limit for the app as a whole.

### Does Android need the nearest-N pattern too?

Only above 100 places. Android allows 100 geofences per app and device user, so an app with fewer places can register all of them. Above that, the same code works with 99 place slots and one refresh region, and the plugin re-registers the set after a reboot or an app update on Android.

### Does the swap run while the app is terminated?

No. A refresh exit while the app is terminated is stored in the queue by the plugin and handled by the swap on the next app start. Between the two, the registered places are the ones around the position where the app last ran. An active watch session of the Background Geolocation plugin keeps the app running in the background, and the swap can be triggered from its `positionChange` event during that session.

### Is the Capacitor Geofences plugin free?

No. The plugin is part of the [Capawesome Insiders](../../insiders/index.md) subscription and is installed from the Capawesome npm registry with the license key that comes with it.

## Try Capawesome Cloud

The nearest-N pattern is pure JavaScript, but the plugin behind it is native code with a manifest permission on Android and two `Info.plist` keys on iOS, which a web update cannot 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

If your app has more than 20 places on iOS or more than 100 on Android, treat the slots as a window around the user from the start rather than retrofitting it after the first `GEOFENCE_LIMIT_EXCEEDED` in production. Keep the full place list in your app, register the nearest places plus one exit-only refresh region, diff the ids on every swap, and remove before you add. Run the swap on launch, on every return to the foreground and on each refresh exit, and accept that a terminated app keeps the last set until it opens again, unless a Background Geolocation watch session feeds the swap. [How to Add Geofencing to an Ionic App](./how-to-add-geofencing-to-an-ionic-app.md) covers the permission flow and the notification for each place, which this post left out.

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"}.
