polyfence-react-native
v3.0.0
Published
Polyfence React Native bridge — privacy-first geofencing, same zones across mobile/IoT/server. Bridge over polyfence-core.
Readme
Polyfence React Native
The Polyfence geofence layer for React Native. Define zones once; same zones run on your mobile app, your IoT device, and your server (Polyfence platform). This package is the React Native bridge over polyfence-core (engine source: github.com/polyfence/polyfence-core). Privacy-first by default — positions never leave the device; only zone events.
The screenshots above are from the example app in this repo —
a working iOS + Android React Native app that fetches zones from the
Polyfence SaaS, tracks location, and renders enter / exit / dwell events.
Sign up at polyfence.io for a free API key, then
follow example/README.md to run it locally.
Who this is for
You're building a React Native app that needs geofencing — delivery, logistics, fitness, healthcare, asset tracking, agritech, fleet, or consumer. You want the math on-device, the zones defined once, and the same definitions reusable on your IoT firmware or server when you grow into those surfaces.
This package is the React Native bridge. The same engine runs on iOS/Android via polyfence-core, on embedded MCUs via polyfence-embedded, and server-side via the Polyfence API.
Why Polyfence?
- Polygon geofencing — Not just circles. Define zones with arbitrary polygon boundaries (complex city zones, campus outlines, delivery areas).
- Unlimited zones — No artificial limits. Monitor hundreds of zones simultaneously with zone clustering for performance.
- Privacy-first — All geofencing runs on-device. Zero location data ever leaves the device by default. No cloud dependency.
- SmartGPS — Intelligent GPS scheduling based on proximity, movement, activity, and battery state. 40-50% less battery drain than naive polling.
- Activity recognition — Automatically detect user activity (walking, driving, stationary) and optimize GPS intervals.
- Background tracking — True background operation with foreground service (Android) and location background mode (iOS).
- TypeScript-first — Full type definitions. Built for type safety.
- Dwell detection — Detect when users stay in zones for a configured duration.
Where your zones come from
Three storage choices — same plugin API in all cases:
| Approach | Backend | API Key | Best For | |----------|---------|---------|----------| | Hardcode zones in your app | None | Not needed | Static zones, full control, privacy-first apps | | Fetch from your own API | Your backend | Not needed | Existing infrastructure, custom zone logic | | Use the Polyfence dashboard | polyfence.io | Required | Visual zone editor, analytics dashboard |
Same plugin API in all cases. The Polyfence platform layer (SDK + dashboard + API) is the geofence layer underneath your product, whether you store zones in code or in the dashboard.
Requirements
| Requirement | Version | |-------------|---------| | React Native | 0.73+ | | Node.js | 18.0+ | | Android | API 24+ (Android 7.0), tested up to API 35 (Android 15) | | iOS | 14.0+ |
Platform Support
| Feature | Android | iOS | |---------|---------|-----| | Circle geofences | Yes | Yes | | Polygon geofences | Yes | Yes | | Dwell detection | Yes | Yes | | Zone clustering | Yes | Yes | | Scheduled tracking | Yes | Yes | | Activity recognition | Yes | Yes | | Background tracking | Yes (foreground service) | Yes ("Always" permission) | | Battery optimization bypass | Yes | N/A | | GPS accuracy profiles | Yes | Partial (iOS manages GPS) |
Installation
npm install polyfence-react-native
# or
yarn add polyfence-react-nativeCurrent version: 3.0.0
Native dependency: Polyfence uses polyfence-core for native geofencing engines. It's included automatically — Maven for Android, CocoaPods for iOS. On iOS, run cd ios && pod install after adding the dependency.
cd ios && pod installPlatform Setup
Android — android/app/src/main/AndroidManifest.xml
The minimum viable set. Tracking runs as a foreground service typed location, which holds location access for as long as it runs, so foreground location is all it needs:
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />You must also declare the tracker service. This package declares no <service> of its own, and foregroundServiceType="location" is what grants a foreground service its location access — required from API 29, hard-enforced from API 34, where startForeground() throws without it:
<service
android:name="io.polyfence.core.LocationTracker"
android:foregroundServiceType="location"
android:exported="false" />Required only if you set osGeofenceWakeEnabled: true:
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />ACCESS_BACKGROUND_LOCATION governs location access outside a foreground service, which is exactly what OS wake fences are: they fire when nothing of yours is running. Declaring it puts your app into Google Play's manual background-location review, which is why base tracking does not need it. RECEIVE_BOOT_COMPLETED lets Polyfence re-register wake fences after a device restart — Play Services drops all registered geofences on reboot.
If the grant is missing or revoked, tracking still runs: wake fences degrade to polling-only and emit an osGeofencePermissionDenied error on onError with context.severity === 'warning', and debugInfo().systemStatus.osGeofenceRegistrationHealth reports lastError === 'background_location_denied'.
Ensure your android/app/build.gradle has the correct minimum SDK version:
android {
defaultConfig {
minSdkVersion 24 // Required for Polyfence
}
}Foreground Service Notification: Polyfence requires a foreground service notification on Android. The plugin automatically creates the notification channel — no additional setup required. The notification uses low priority and is silent.
iOS — ios/[YourApp]/Info.plist
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app needs location access to detect when you enter or exit defined zones.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Background location access is required for continuous zone monitoring.</string>
<key>NSLocationAlwaysUsageDescription</key>
<string>Background location access is required for continuous zone monitoring.</string>
<key>UIBackgroundModes</key>
<array>
<string>location</string>
</array>iOS Background Mode in Xcode:
- Open
ios/[YourApp].xcworkspacein Xcode - Select the [YourApp] target → Signing & Capabilities
- Click + Capability → add Background Modes
- Check Location updates
iOS Permission Flow:
iOS requires "Always" location permission for background geofencing:
- First Request: When you call
requestPermissions({ always: true }), iOS shows a "While in use" permission dialog - Manual Step Required: The user must manually enable "Always" permission in Settings → Privacy & Security → Location Services → Your App → "Always"
- Check Permission Status:
const isEnabled = await Polyfence.instance.isLocationServiceEnabled();
if (!isEnabled) {
// Guide user to enable location services
}
const granted = await Polyfence.instance.requestPermissions({ always: true });
if (granted) {
// User granted "While in use" — they still need to enable "Always" in Settings
// You may want to show a dialog guiding them to Settings
}Quick Start
Step 1: Import and Initialize
import { Polyfence } from 'polyfence-react-native';
await Polyfence.instance.initialize();Wire
onErrorbefore the calls below. Several SDK methods (including some used in steps 3 and 5) can emit errors as a side effect of being called. IfonErrorisn't subscribed at the time, those errors are dropped silently — no replay, no warning in the return value. Skip ahead to Step 6, wireonErroronce, then come back here. The remaining steps assume you've done that.
Want your sessions on your dashboard? Telemetry is anonymous by default — anonymous sessions feed only aggregate stats, not your account's analytics. To attribute them to your Polyfence account, pass your API key (the same one you use for the zone API) to
initialize:await Polyfence.instance.initialize(undefined, { apiKey: YOUR_POLYFENCE_API_KEY });It's sent as
x-api-keywith each telemetry upload. React Native has no compile-time env, so supply the key however your app manages config (e.g.react-native-config, an env file, or a constant). To turn telemetry off entirely:initialize(undefined, { disableTelemetry: true }).
Step 2: Request Permissions
Foreground location is the whole requirement — "While in use" on Android, "When In Use" on iOS. The stronger background grant is needed only for osGeofenceWakeEnabled; request it only when you set that flag, and never otherwise. On Android, requesting ACCESS_BACKGROUND_LOCATION once it has been denied shows no prompt and sends the user to the system settings screen.
iOS: requestPermissions() triggers the system permission dialog. Pass { always: true } only alongside osGeofenceWakeEnabled: true.
Android: requestPermissions() does not show a dialog — it only reads the current permission state and returns a boolean. To trigger the OS dialog on Android, use a library like react-native-permissions first, then call requestPermissions() to verify the result.
import { Platform } from 'react-native';
// One flag drives both the permission request and the configuration —
// requesting the background grant without enabling the feature buys a Play
// review for nothing, and enabling the feature without the grant leaves it
// permanently degraded.
const osGeofenceWakeEnabled = false;
// Android only — trigger the OS permission dialog.
// import { request, PERMISSIONS } from 'react-native-permissions';
// if (Platform.OS === 'android') {
// await request(PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION);
// if (osGeofenceWakeEnabled) {
// await request(PERMISSIONS.ANDROID.ACCESS_BACKGROUND_LOCATION);
// }
// }
// Both platforms — verify the result. On iOS this ALSO shows the
// system dialog on first call.
const hasPermission = await Polyfence.instance.requestPermissions({
always: osGeofenceWakeEnabled,
});
if (!hasPermission) {
// Handle permission denied — e.g. guide the user to Settings.
return;
}Step 3: Add Zones
// Circle zone
await Polyfence.instance.addZone({
id: 'office',
name: 'Office',
type: 'circle',
center: { latitude: 37.422, longitude: -122.084 },
radius: 150,
});
// Polygon zone
await Polyfence.instance.addZone({
id: 'campus',
name: 'Campus',
type: 'polygon',
polygon: [
{ latitude: 37.422, longitude: -122.084 },
{ latitude: 37.423, longitude: -122.085 },
{ latitude: 37.424, longitude: -122.083 },
],
});Zones are automatically persisted across app restarts. No hard limit on zone count (tested with 100+ zones on both platforms). Large polygons (1000+ points) are supported; the plugin uses Douglas-Peucker simplification to optimize complex polygons.
Step 4: Listen for Events
const subscription = Polyfence.instance.onGeofenceEvent((event) => {
switch (event.type) {
case 'enter':
console.log(`Entered: ${event.zoneId}`);
break;
case 'exit':
console.log(`Exited: ${event.zoneId}`);
break;
case 'dwell':
console.log(`Stayed in ${event.zoneId} for ${event.dwellDurationMs}ms`);
break;
// Recovery events fire ONLY when the tracking service was killed
// and restarted (Doze kill / OOM / force-stop / phone reboot). On
// the first GPS fix after restart, the SDK reconciles persisted
// zone state against the actual location and emits recoveryEnter /
// recoveryExit for any mismatch. Treat recovery events like
// enter/exit unless you specifically want to distinguish "user just
// crossed the boundary" from "user was already inside/outside when
// the tracking process resumed after being killed."
case 'recoveryEnter':
console.log(`Confirmed inside (post-restart): ${event.zoneId}`);
break;
case 'recoveryExit':
console.log(`Confirmed outside (post-restart): ${event.zoneId}`);
break;
// Signal-lost / restored fire only when degraded-GPS handling is
// enabled (gpsStalenessTimeoutMs > 0), and are distinct from recovery
// events: recoveryEnter/Exit fire when the tracking SERVICE was killed
// and restarted, whereas signalLost fires while the service is RUNNING
// but GPS has gone stale. If GPS goes fully stale while you're inside a
// zone, the SDK reports signalLost — membership is now uncertain, NOT
// exited — then signalRestored once a valid fix confirms you're still
// inside (or a normal exit if you actually left). NOTE: on signalLost,
// event.location is the LAST-KNOWN (stale / approximate) position, not
// a current fix — don't render it as the user's live location.
case 'signalLost':
console.log(`GPS lost, still assumed inside: ${event.zoneId}`);
break;
case 'signalRestored':
console.log(`GPS restored, confirmed inside: ${event.zoneId}`);
break;
}
});
// Don't forget to unsubscribe in cleanup
subscription.remove();Events fire whether your app is foregrounded, backgrounded, or the screen is locked. Here's what users see on each platform when zones fire in the background:
Step 5: Start Tracking
await Polyfence.instance.startTracking();Step 6: Handle Errors (Recommended)
Subscribe to
onErrorbefore calling any other SDK method.onErroris the SDK's central error channel — GPS failures, permission revocations, service issues, battery warnings, zone validation errors, and any other runtime error all route through this one subscription. Methods such asbatteryOptimizationStatus(),addZone(), andrequestPermissions()can emit errors as a side effect of being called; if no listener is attached at the time, that error is silently dropped — no retry, no replay, no warning in the return value.
const errorSubscription = Polyfence.instance.onError((error) => {
switch (error.type) {
case 'gpsPermissionDenied':
// Guide user to settings
break;
case 'gpsServiceDisabled':
// Prompt to enable GPS
break;
default:
console.log(`Error: ${error.message}`);
}
});API Reference
Lifecycle Methods
| Method | Returns | Description |
|--------|---------|-------------|
| initialize(config?) | Promise<void> | Initialize the geofencing engine |
| startTracking() | Promise<void> | Start background GPS tracking |
| stopTracking() | Promise<void> | Stop GPS tracking |
| dispose() | Promise<void> | Clean up resources and stop tracking. Safe to re-initialize() afterwards (e.g. logout → login) — re-acquire via Polyfence.instance. |
| removeAllListeners() | void | Remove all event listeners without disposing the engine |
Zone Management
| Method | Returns | Description |
|--------|---------|-------------|
| addZone(zone) | Promise<void> | Add a circle or polygon zone |
| removeZone(zoneId) | Promise<void> | Remove a zone by ID |
| clearAllZones() | Promise<void> | Remove all zones |
| getZoneStates() | Promise<ZoneState[]> | Get current INSIDE/OUTSIDE state for all zones |
getZoneStates()only reports reliable inside/outside state afterstartTracking(). Inside/outside is computed by the running location service, so before tracking starts there is nothing evaluated to report — on AndroidgetZoneStates()returns[]even afteraddZone(). Always follow this order:initialize()→addZone()→startTracking()→getZoneStates(). (To track membership while running, use theonZoneEnter/onZoneExitevents.)
addZone(zone)treatszone.idas the primary key — duplicate IDs silently overwrite. CallingaddZonewith anidalready being monitored replaces the previous zone entry; no error is thrown. Re-adding also resets the persisted INSIDE/OUTSIDE state for that zone (and on iOS, its confidence state). If the device is currently inside the zone, the next reconciliation may fire a freshenter/recoveryEnterevent. In-place metadata edits without a re-enter are a known limitation. If your workflow requires unique IDs, dedupe before calling.
Configuration
| Method | Returns | Description |
|--------|---------|-------------|
| getConfiguration() | Promise<PolyfenceConfiguration> | Get current configuration |
| updateConfiguration(config) | Promise<void> | Update configuration |
| resetConfiguration() | Promise<void> | Reset to defaults |
| setAccuracyProfile(profile) | Promise<void> | Set GPS accuracy profile |
Permissions & System
| Method | Returns | Description |
|--------|---------|-------------|
| requestPermissions(options?) | Promise<boolean> | Request location permissions. Android: reads current state only, does not show a dialog — see Step 2 for the two-step Android flow. |
| isLocationServiceEnabled() | Promise<boolean> | Check if location services are enabled |
| batteryOptimizationStatus() | Promise<BatteryOptimizationStatus> | Check battery optimization status (Android) |
| requestBatteryOptimizationExemption() | Promise<void> | Launch the Android system exemption dialog (fire-and-forget — re-poll batteryOptimizationStatus() to observe the user's response) |
Debug & Telemetry
| Method | Returns | Description |
|--------|---------|-------------|
| debugInfo() | Promise<PolyfenceDebugInfo> | Get operational diagnostics in 5 groups: systemStatus, performance, battery, zones, recentErrors. For current configuration use getConfiguration(); for current zone membership use getZoneStates() |
| getSessionTelemetry() | Promise<SessionTelemetry> | Get session metrics (GPS updates, zone events, battery impact) |
| errorHistory(options?) | Promise<PolyfenceError[]> | Get recent errors |
Pending events queue
Zone crossings that fire while the JS runtime is torn down but the native tracker is still alive (Doze kill, RN bundle reload, background foreground-service) can be persisted on disk and delivered on the next attach.
| Method | Returns | Description |
|--------|---------|-------------|
| drainPendingEvents() | Promise<GeofenceEvent[]> | Read + clear the durable queue atomically; oldest-first. Each event carries deliveredLate: true, capturedTs (original native detection time), and queuedDurationMs. Returns [] when the queue is empty or the feature is off. |
| pendingEventsDroppedCount() | Promise<number> | Cumulative count of events dropped by oldest-first eviction since a store was first constructed on this device. Persists across process restarts. |
Enable persistence via pendingEventsQueueSize on PolyfenceConfiguration:
await Polyfence.instance.initialize({
pendingEventsQueueSize: 500, // 0 (default) disables persistence — no behaviour change
});
// on next foreground / cold start
const missed = await Polyfence.instance.drainPendingEvents();
missed.forEach((event) => {
// Same shape as onGeofenceEvent — plus event.deliveredLate === true.
});Replayed events arrive out of order — sort by event time, not arrival
A replayed crossing carries the timestamp of when it happened, not when you received it. It is delivered through the same subscription as a live event, so an event list that simply prepends new arrivals will put a crossing from half an hour ago above one that happened since — reading as though the driver were entering that zone now.
That is the exact confusion this feature exists to prevent: a late-delivered congestion-charge entry presenting as current. Order by the event's own time:
events.sort((a, b) => b.timestamp - a.timestamp);Use deliveredLate to mark a replayed crossing in your UI rather than folding it into the event type — anything
that compares the type by equality will silently misclassify a decorated value. capturedTs and
queuedDurationMs tell you when it happened and how long it waited, so you can decide whether an action that made
sense at the time still makes sense now.
Silent-loss visibility surfaces through onError: a PolyfenceError with type: 'pendingEventsEvicted' fires when the queue evicts oldest-first at cap. The native eviction payload lands nested under the generic PolyfenceError.context envelope, so read the eviction fields via error.context?.context?.droppedCount and error.context?.context?.severity.
Events
| Method | Callback | Description |
|--------|----------|-------------|
| onLocationUpdate(callback) | (location: PolyfenceLocation) => void | Raw GPS location updates |
| onGeofenceEvent(callback) | (event: GeofenceEvent) => void | Zone enter / exit / dwell / recoveryEnter / recoveryExit / signalLost / signalRestored events |
| onError(callback) | (error: PolyfenceError) => void | Central error channel — subscribe before any other SDK call. GPS / permission / service / battery / zone-validation errors all route here; errors fired without a listener are dropped silently |
| onPerformance(callback) | (payload: PerformanceEventPayload) => void | Live GPS performance snapshots (type: 'runtime_status') emitted periodically by polyfence-core while tracking. See Performance Events |
| onHealthScore(callback) | (event: HealthScoreEvent) => void | Periodic health score (0-100) with top issue |
| onZoneEnter(callback) | (event: GeofenceEvent) => void | Zone enter events only |
| onZoneExit(callback) | (event: GeofenceEvent) => void | Zone exit events only |
All event methods return a Subscription object with a remove() method to unsubscribe.
Events
Geofence Events
Polyfence.instance.onGeofenceEvent((event) => {
console.log({
zoneId: event.zoneId,
zoneName: event.zoneName,
type: event.type, // 'enter' | 'exit' | 'dwell' | 'recoveryEnter' | 'recoveryExit' | 'signalLost' | 'signalRestored'
location: event.location, // includes location.activity ('still' | 'walking' | 'running' | 'cycling' | 'driving' | 'unknown')
timestamp: event.timestamp,
detectionTimeMs: event.detectionTimeMs, // ms the engine took to detect the transition
distanceToBoundaryM: event.distanceToBoundaryM, // metres from event location to zone boundary
dwellDurationMs: event.dwellDurationMs, // only set on DWELL events
});
});Location Events
Polyfence.instance.onLocationUpdate((location) => {
console.log({
latitude: location.latitude,
longitude: location.longitude,
accuracy: location.accuracy, // metres, undefined until the first GPS fix
speed: location.speed, // m/s
activity: location.activity, // 'still' | 'walking' | 'running' | 'cycling' | 'driving' | 'unknown'
timestamp: location.timestamp,
});
});Error Events
Polyfence.instance.onError((error) => {
console.log({
type: error.type, // 'gpsPermissionDenied' | 'gpsServiceDisabled' | ...
message: error.message,
context: error.context,
correlationId: error.correlationId,
timestamp: error.timestamp,
});
});Performance Events
onPerformance surfaces live GPS performance snapshots emitted periodically by polyfence-core LocationTracker while tracking is running (typically every ~30 seconds and on strategy / accuracy-profile change). The channel filters to type: 'runtime_status' — health-score events travel on their own onHealthScore subscription, and internal SDK state broadcasts are not surfaced.
const subscription = Polyfence.instance.onPerformance((payload) => {
// Engine runtime status — emitted periodically (and on change) by
// polyfence-core LocationTracker. Shape: { type: 'runtime_status',
// data: { strategy, intervalMs, accuracyProfile, nearestZoneDistanceM,
// isStationary, batteryMode, gpsAccuracy, currentGpsAccuracy,
// secondsSinceLastGpsFix, gpsAvailabilityDrops5Min, timestamp } }
const data = payload.data as Record<string, unknown>;
console.log({
strategy: data.strategy, // 'CONTINUOUS' | 'INTELLIGENT' | ...
intervalMs: data.intervalMs,
gpsAccuracy: data.gpsAccuracy, // metres (current fix)
currentGpsAccuracy: data.currentGpsAccuracy, // metres (last health-tracked fix; null until first fix)
nearestZoneDistanceM: data.nearestZoneDistanceM,
});
});
// Remove the subscription during cleanup (e.g. useEffect return).
subscription.remove();Configuration
GPS Accuracy Profiles
// Maximum accuracy (highest battery usage)
await Polyfence.instance.setAccuracyProfile('maxAccuracy');
// Balanced accuracy/battery (DEFAULT - recommended)
await Polyfence.instance.setAccuracyProfile('balanced');
// Battery-optimized for background monitoring
await Polyfence.instance.setAccuracyProfile('batteryOptimal');
// Intelligent auto-adjustment
await Polyfence.instance.setAccuracyProfile('adaptive');| Profile | Update Interval | Battery Impact | Use Case | |---------|-----------------|----------------|----------| | maxAccuracy | 5 seconds | High | Delivery, navigation, fleet tracking | | balanced | 10 seconds | Medium | Most location-aware apps (DEFAULT) | | batteryOptimal | 30 seconds | Low | Background monitoring, casual use | | adaptive | Dynamic | Variable | Apps with varying accuracy needs |
Dwell Detection
await Polyfence.instance.updateConfiguration({
dwellSettings: {
enabled: true,
dwellThresholdMs: 5 * 60 * 1000, // 5 minutes
},
});
Polyfence.instance.onGeofenceEvent((event) => {
if (event.type === 'dwell') {
console.log(`User confirmed in ${event.zoneId}`);
}
});Zone Clustering
For apps with 100+ zones, clustering improves performance:
await Polyfence.instance.updateConfiguration({
clusterSettings: {
enabled: true,
activeRadiusMeters: 5000, // Check zones within 5km
},
});Scheduled Tracking
Track only during specific time windows:
await Polyfence.instance.updateConfiguration({
scheduleSettings: {
enabled: true,
timeWindows: [
{
startTime: { hour: 9, minute: 0 },
endTime: { hour: 17, minute: 0 },
daysOfWeek: [1, 2, 3, 4, 5], // Monday-Friday
},
],
},
});Activity Recognition
Automatically detect activity and optimize GPS:
await Polyfence.instance.updateConfiguration({
activitySettings: {
enabled: true,
// Per-activity update intervals in milliseconds. Defaults are
// sensible; override only the ones you want to tune.
stillIntervalMs: 120000, // 2 min when stationary
walkingIntervalMs: 15000, // 15s when walking
runningIntervalMs: 10000,
cyclingIntervalMs: 8000,
drivingIntervalMs: 5000,
},
});Additional Permissions Required (Android):
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />Expo Support
Polyfence requires native modules and cannot run on Expo Go. Use expo-dev-client for a custom development build:
npx create-expo-app MyApp
cd MyApp
npx expo install expo-dev-client
npx expo prebuild --clean
npm install polyfence-react-native
npx expo run:ios # or run:androidSee Expo Custom Development Client docs for more details.
Privacy
Zero PII about your end users. The only personal information Polyfence holds is the developer's account info (email, billing) — same as any paid SaaS.
Different defaults for different data classes:
- Positions — opt-in. Never persisted on Polyfence servers by default. If you turn retention on, positions are stored in your tenant — never names, phones, emails, or health data.
- Anonymous telemetry — opt-out, one line disables (see below). Never coordinates, never identifiers, never PII — only aggregates (platform, plugin version, accuracy averages, error counts).
- Zone events — always on. They're the value we deliver, not surveillance.
This is the deliberate posture, not an inconsistency. See PRIVACY.md for the full breakdown.
Disable telemetry (one line)
await Polyfence.instance.initialize(undefined, { disableTelemetry: true });Architecture Guarantees
- On-device geofencing: All zone detection runs locally using native GPS APIs
- Local persistence: Zones stored in SharedPreferences (Android) / UserDefaults (iOS)
- No tracking: No user behavior tracking, no cross-app tracking
- GDPR/CCPA-friendly: Anonymous aggregates only by default, one-line disable for telemetry, opt-in for position retention
Upgrading
From 2.0.x to 2.1.0
Additive release — no public API removed. New surfaces:
GeofenceEventType.signalLost/.signalRestored— reported when GPS goes stale while a device is inside a zone. Opt-in viaPolyfenceConfiguration.gpsStalenessTimeoutMs(0= off, the default).
Behavioural change on Android — initialize(config). If you pass any tracking-config key to initialize, the LocationTracker foreground Service now starts as part of initialize rather than being deferred until startTracking(). On Android 8+, context.startService from a backgrounded context throws IllegalStateException — so initialize will now REJECT where it previously silently dropped the config. This is a bug fix (the old behaviour hid misconfigured init calls), but callers that init from a background service must catch the rejection and retry once the app foregrounds. Apps that call initialize on cold-start or from the foreground see no change. iOS is unaffected.
For the full change list, see CHANGELOG.md.
Common Gotchas
Stream Subscription Management
Always remove event subscriptions in cleanup to prevent memory leaks:
useEffect(() => {
const subscription = Polyfence.instance.onGeofenceEvent((event) => {
// handle event
});
return () => {
subscription.remove();
};
}, []);Zone Persistence
Zones are automatically persisted across app restarts — no manual persistence needed. When loading zones from an external source, consider delta-based sync to avoid re-registering all zones.
OEM Battery Restrictions (Android)
Some Android manufacturers aggressively kill background services. If tracking stops, the user likely needs to whitelist your app. See dontkillmyapp.com for device-specific instructions.
NativeModule Not Found
If you see "NativeModule not found" error:
- Rebuild the app so autolinking picks up the module (RN 0.60+ autolinks —
react-native linkis obsolete); on iOS runcd ios && pod installthen rebuild - For Expo: Use
expo-dev-client(not Expo Go) - Clear build caches:
rm -rf node_modules && npm install && cd ios && rm -rf Pods && pod install
Pod Install Fails (iOS)
cd ios
rm -rf Pods Podfile.lock
pod repo update
pod installAndroid Build Fails
Ensure minimum SDK is 24+:
android {
defaultConfig {
minSdkVersion 24
}
}Known Differences from Flutter
The following Flutter APIs are intentionally deferred from this package:
enableIntelligentOptimization(),enableProximityOptimization(),enableMovementOptimization()— ML-powered optimization APIs. These will be added when the intelligence layer is integrated (planned for a future release).zonesgetter — UsegetZoneStates()to query current zone state.currentConfigurationgetter — UsegetConfiguration()instead.statusStream— UseonPerformance()event listener for runtime status updates.
These gaps will be addressed in subsequent releases.
Troubleshooting
Debugging
Android — Filter logcat:
adb logcat | grep -E "LocationTracker|GeofenceEngine|Polyfence"iOS — Filter Xcode console:
LocationTracker GeofenceEngine PolyfenceProgrammatic debugging — Use the debug API:
const debug = await Polyfence.instance.debugInfo();
// systemStatus — permissions, GPS, last fix
console.log('Last fix:', debug.systemStatus.lastLocationUpdate); // ms since epoch, 0 if no fix yet
console.log('GPS enabled:', debug.systemStatus.isGpsEnabled);
console.log('Permission granted:', debug.systemStatus.isLocationPermissionGranted);
// zones — counts (use getZoneStates() for inside/outside membership)
console.log('Active zones:', debug.zones.activeZones);
// performance / battery
console.log('Detections:', debug.performance.totalZoneDetections);
console.log('Battery:', debug.battery.batteryLevel ?? 'not measured', debug.battery.isCharging ? '⚡' : '');
// recent errors (already normalized to PolyfenceError shape)
debug.recentErrors.forEach((err) => console.log(err.type, err.message));Note:
debugInfo()is for operational diagnostics. For the current configuration (accuracy profile, update strategy, intervals) callgetConfiguration(). For the current zone membership (which zones the user is inside right now) callgetZoneStates()or subscribe toonZoneEnter/onZoneExit.
Where a platform cannot measure one of the following, the field is null
rather than a filler value, so absence is distinguishable from a genuine zero:
| Field | null when |
|---|---|
| systemStatus.isBatteryOptimizationDisabled | always on iOS — no such setting exists |
| systemStatus.isWakeLockAcquired | always on iOS; on Android when no tracking service is running |
| performance.restartCount | always on iOS — no foreground service to restart |
| performance.averageDetectionLatency | until at least one crossing has been timed |
| battery.batteryLevel | when the platform has not reported a level |
performance.timedZoneDetections says how many crossings contributed a latency
sample. It is lower than totalZoneDetections when the engine synthesised a
crossing outside a timed evaluation — a degraded-GPS exit, for instance. Those
crossings are real and are counted; they simply carry no timing.
memoryUsageMB measures whole-process resident size on iOS and Java heap only
on Android, so the two are not comparable across platforms.
Two older fields still use sentinels rather than null: lastKnownAccuracy
is -1 and lastLocationUpdate is 0 when no fix has arrived yet.
The iOS nulls in the table come from polyfence-core and hold from core
3.0.0 onward, the version this package pins. What the bridge enforces on
every response, whatever core it is paired with, is narrower: a battery charge
outside 0–100, a latency average with no samples behind it, and any
non-finite number are reported as null, because none of those can be a
reading at any version.
Reporting Issues
When opening a GitHub issue, include:
- Output of
Polyfence.instance.debugInfo() - Device manufacturer and OS version (e.g., Samsung Galaxy S24, Android 14)
- Whether battery optimization is disabled
- Logcat/Xcode console output
- Minimal code sample to reproduce
Contributing
Contributions are welcome. See CONTRIBUTING.md for development setup, code style, and PR guidelines.
Support
- Plugin Issues: GitHub Issues
- Questions & Discussions: Open an issue with the
questionlabel - Security Issues: See SECURITY.md
- Commercial Support: polyfence.io
License
MIT — see LICENSE
Copyright (c) 2026 Polyfence
