Skip to content

Geofencing: How a Radius Trigger Actually Gets Computed

9 min read · updated August 11, 2026

A circular geofence is one inequality: is the distance from this fix to the centre less than the radius. Getting that inequality right takes twenty lines. Getting it to stop firing forty times an hour takes the rest of this page.

The test is one inequality

Given a fix at (lat, lon), a fence centre at (clat, clon) and a radius r in metres, the fence contains the fix when distance(fix, centre) ≤ r. Everything difficult is in the word “distance”. You cannot use Pythagoras on degrees: a degree of latitude is about 111,320 m everywhere, but a degree of longitude is 111,320 × cos(latitude) metres, so at 51°N it is roughly 69,300 m. Treating the two as equal is the single most common bug in this code, and it has its own page with the arithmetic worked out.

Two design decisions come before any code. The first is where the test runs. Evaluating on the device means the trigger fires without network and without draining battery on continuous uploads, but you inherit the operating system’s limits on how many fences it will watch. Evaluating on the server means unlimited fences and full history, but the device has to report position continuously and the event arrives only as fast as the reporting interval. Most fleet systems do both: coarse fences on the device to wake the app, fine ones on the server against the resulting stream.

The second is what the fence actually represents. A circle is a convenience, not a fact about a depot; the real boundary is a polygon around a yard, and a circle that covers it also covers the road outside. If false enters from passing traffic are expensive, do the cheap circle test first and a point-in-polygon test second, using the circle purely as the index lookup. The remainder of this page assumes the circle, because everything that follows applies unchanged to the polygon case.

A haversine you can paste

Haversine gives great-circle distance on a sphere. It is numerically well behaved at small separations, which the older spherical-law-of-cosines formula is not, and that matters here because geofence radii are small.

const R = 6371008.8; // IUGG mean Earth radius, metres

function haversine(lat1, lon1, lat2, lon2) {
  const rad = Math.PI / 180;
  const dLat = (lat2 - lat1) * rad;
  const dLon = (lon2 - lon1) * rad;
  const p1 = lat1 * rad;
  const p2 = lat2 * rad;

  const a =
    Math.sin(dLat / 2) ** 2 +
    Math.cos(p1) * Math.cos(p2) * Math.sin(dLon / 2) ** 2;

  return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
}

The Math.min(1, ...) is not decoration. For two identical points, floating-point error can push the square root a few ulps above 1 and asin returns NaN, which then compares false against every radius and silently disables the fence for that device. Because it assumes a sphere, haversine differs from a true WGS84 ellipsoidal geodesic by up to about 0.5% — a few centimetres on a 100 m fence, so irrelevant here, and very relevant on a continental leg.

The bounding-box prefilter

Evaluating every fence against every fix is fine at ten fences and absurd at ten thousand. Wrap each fence in a latitude/longitude box and reject on integer-cheap comparisons before you touch a trigonometric function:

function bbox(clat, clon, r) {
  const dLat = r / 111320;
  const dLon = r / (111320 * Math.cos(clat * Math.PI / 180));
  return { minLat: clat - dLat, maxLat: clat + dLat,
           minLon: clon - dLon, maxLon: clon + dLon };
}

The box is a superset of the circle, so a candidate that fails it cannot be inside; a candidate that passes still needs the haversine check. Note the cos in the longitude term breaks down near the poles and the box wraps badly across the ±180° antimeridian. If your fences can be anywhere, put them in a real index instead — an R-tree or a geohash prefix — and keep the box trick for the single-digit-fence case.

A worked trigger

A depot fence sits at 51.5074 N, −0.1278 E with r = 150 m. A vehicle reports 51.5081 N, −0.1271 E with a reported horizontal accuracy of 12 m.

dLat = 0.0007 deg -> 0.0007 * 111320          =  77.9 m
dLon = 0.0007 deg -> 0.0007 * 111320 * cos(51.5) =  48.5 m
distance ~= sqrt(77.9^2 + 48.5^2) = sqrt(6068 + 2352) = 91.8 m
haversine over the same pair                     = 91.8 m
91.8 <= 150  ->  inside

The flat approximation and haversine agree to well under a metre at this scale, which is the useful sanity check: if your two methods disagree by tens of metres over 100 m, one of them has a units or coordinate-order bug rather than a curvature problem.

Why the correct version still fires all night

A parked vehicle 150 m from the centre, with a fix that wanders by ten or twenty metres, crosses the boundary repeatedly. A single threshold turns that jitter into an event stream. Three fixes, applied in order:

  1. Gate on reported accuracy. Every platform hands you an accuracy radius with the fix. If that radius is larger than about half the fence radius, the fix cannot distinguish inside from outside; drop it rather than letting it vote. A 30 m fence evaluated against 60 m fixes is a random number generator.
  2. Use two radii, not one. Enter when distance ≤ r; leave only when distance > r + b, with a buffer b of roughly the typical accuracy — 50 m is a reasonable starting point in a city. The state machine has memory, so a fix sitting in the band between the two radii changes nothing.
  3. Require dwell. Hold the candidate state for a fixed period — thirty seconds for a vehicle arrival, several minutes for a “visited this shop” signal — and only emit the event if it survives. This also removes the pass-through case, where a vehicle drives across a fence without stopping.
function step(state, fix, fence) {
  if (fix.accuracy > fence.r / 2) return state;   // 1
  const d = haversine(fix.lat, fix.lon, fence.clat, fence.clon);
  const inside = state.inside ? d <= fence.r + fence.buffer : d <= fence.r;
  if (inside === state.inside) return { ...state, since: state.since };
  if (fix.t - state.since < fence.dwellMs) return state;   // 3
  return { inside, since: fix.t, event: inside ? "enter" : "exit" };
}

Platform limits you will hit

If the fence is evaluated on the device rather than on your server, the operating system caps how many you may register. Google’s Android geofencing documentation documents a limit of 100 geofences per app per device user. Apple’s Core Location region monitoring has long been capped at 20 monitored regions per app. Neither is negotiable, so any application with more fences than that has to register only the nearest N to the device’s current position and re-register as it moves — which is itself a nearest-neighbour query, and is why the two techniques on this page tend to arrive together.

Two behaviours of on-device geofencing surprise people who assumed the operating system polls a position and compares it. It does not, because that would flatten the battery: the platform uses cell and Wi-Fi signals and only escalates to satellite positioning when it thinks a boundary is near. The consequence is latency — an enter event can arrive minutes after the crossing — and the second consequence is that events do not survive everything. A device reboot, a location services toggle, or the app being force-stopped drops registered fences, so re-registering on app start and after a boot broadcast is not optional. Neither is handling an enter and an exit arriving out of order, which batching makes possible.

Those two caps are platform behaviour and have moved before. Treat them as the documented limits at the time of writing and check the current Android and Core Location references before designing around them.