MediumBuilding a Reliable Background Location Tracker with Ionic, Angular, and Capacitor | by Vishal Darekar | Medium
Sitemap

Building a Reliable Background Location Tracker with Ionic, Angular, and Capacitor

12 min readJun 27, 2026

--

Press enter or click to view image in full size

Subtitle: What I learned while building a mobile location tracker that keeps working in the background, avoids drift, filters bad GPS fixes, and recovers from silent failures.

Tags: Ionic, Angular, Capacitor, Background Geolocation, Mobile Development

Background geolocation looks simple until you need it to work reliably on real phones.

Reading the current location once is easy. Keeping location tracking alive while the app is backgrounded, the phone is locked, GPS quality changes, permissions are revoked, or the operating system starts optimizing battery is a very different problem.

In this post, I will walk through an Ionic + Angular + Capacitor implementation I built for reliable background tracking. The goal was not just “get a coordinate”. The goal was to build a system that:

  • Tracks location continuously in foreground and background.
  • Saves location at a configurable interval.
  • Avoids cumulative timing drift.
  • Rejects poor-accuracy GPS fixes.
  • Gives users live GPS quality feedback.
  • Restarts tracking automatically when updates stop.
  • Restores tracking state when the app is reopened.
  • Handles Android and iOS differences explicitly.

This article is implementation-focused. I will include the important code snippets directly so you can adapt the same structure in your own app.

Tech Stack

The implementation uses:

  • Ionic 8
  • Angular 20
  • Capacitor 8
  • TypeScript
  • RxJS
  • cordova-background-geolocation-plugin

Install the relevant packages:

npm install cordova-background-geolocation-plugin
npm install @capacitor/app @capacitor/core

The examples below use this structure:

  • BackgroundGeolocationService handles native GPS tracking, permissions, timing, quality checks, and recovery.
  • LocationSyncService listens only to scheduled save events and persists clean location payloads.
  • HomePage controls the start/stop UI.
  • AppComponent restores tracking when the app is reopened.
  • capacitor.config.ts contains the iOS location permission metadata.

The main design decision was to isolate native geolocation complexity inside one service, then expose simple reactive streams to the rest of the app.

The Problem with Naive Location Tracking

A first implementation usually looks like this:

watchPosition((location) => {
saveLocation(location);
});

That works for demos, but real background tracking needs more structure.

For example:

  • GPS updates are not guaranteed at exact intervals.
  • Stationary devices may receive fewer updates.
  • Bad GPS fixes can report locations hundreds of meters away.
  • Native plugins can stop producing updates silently.
  • Android and iOS have different background rules.
  • Saving every GPS update can drain battery and produce too much data.
  • Saving based on Date.now() after each callback can create time drift.

So I separated the problem into two different flows:

  1. Live GPS updates for the UI.
  2. Scheduled saved points for storage or server sync.

This is the core idea behind the service.

import { Injectable } from '@angular/core';
import { Capacitor } from '@capacitor/core';
import {
BackgroundGeolocationPlugin,
ConfigureOptions,
} from 'cordova-background-geolocation-plugin';
import { BehaviorSubject, Subject } from 'rxjs';
declare const BackgroundGeolocation: BackgroundGeolocationPlugin;export enum TrackingState {
IDLE = 'idle',
STARTING = 'starting',
RUNNING = 'running',
STOPPING = 'stopping',
PERMISSION_DENIED = 'permission_denied',
LOCATION_DISABLED = 'location_disabled',
ERROR = 'error',
}
export interface LocationPayload {
latitude: number;
longitude: number;
accuracy: number;
altitude: number | null;
speed: number | null;
bearing: number | null;
timestamp: number;
deviceId: string;
}
@Injectable({ providedIn: 'root' })
export class BackgroundGeolocationService {
public position$ = new BehaviorSubject<any>(undefined);
public savePosition$ = new Subject<any>();
public trackingState$ = new BehaviorSubject<TrackingState>(TrackingState.IDLE);
private nextScheduledSave = 0;
private intervalTime = 60000;
private foregroundTimer?: ReturnType<typeof setInterval>;
private watchdogTimer?: ReturnType<typeof setInterval>;
private lastLocationTime = 0;
private recoveryAttempts = 0;
private isInitialized = false;
private authorizationAlertShown = false;
private readonly MAX_ACCURACY = 50;
private readonly WATCHDOG_TIMEOUT = 120000;
private readonly MAX_RECOVERY_ATTEMPTS = 5;
private readonly ACCURACY_HISTORY_SIZE = 10;
private recentAccuracies: number[] = [];
public gpsSignalQuality$ =
new BehaviorSubject<'good' | 'fair' | 'poor' | 'unknown'>('unknown');
// The methods below fill in this service.
}

position$ updates whenever the app receives a GPS fix. The UI can use this for live location display.

savePosition$ emits only when the app decides a location should actually be saved.

This gives us high-frequency tracking without forcing high-frequency persistence.

High-Reliability Plugin Configuration

The background geolocation plugin is configured in BackgroundGeolocationService.

The implementation chooses the native provider based on platform:

public initTracking(): void {
if (this.isInitialized) {
return;
}
  this.intervalTime = parseInt(localStorage.getItem('interval') || '60000');
this.nextScheduledSave = 0;
this.isInitialized = true;
this.trackingState$.next(TrackingState.STARTING);
const isIos = Capacitor.getPlatform() === 'ios'; const config: ConfigureOptions = {
locationProvider: isIos
? BackgroundGeolocation.DISTANCE_FILTER_PROVIDER
: BackgroundGeolocation.ACTIVITY_PROVIDER,
desiredAccuracy: BackgroundGeolocation.HIGH_ACCURACY,
distanceFilter: 0,
stationaryRadius: 0,
stopOnStillActivity: false,
pauseLocationUpdates: false,
saveBatteryOnBackground: false,
stopOnTerminate: false,
startOnBoot: !isIos,
startForeground: !isIos,
notificationsEnabled: !isIos,
interval: 5000,
fastestInterval: 2000,
activitiesInterval: 5000,
activityType: isIos ? 'OtherNavigation' : 'Other',
maxLocations: 10000,
debug: false,
};
BackgroundGeolocation.configure(config, () => {
BackgroundGeolocation.start();
this.startWatchdog();
this.startForegroundTimer();
BackgroundGeolocation.getCurrentLocation(
(location) => {
const coords = { coords: location };
this.position$.next(coords);
if (this.nextScheduledSave === 0) {
this.nextScheduledSave = location.time + this.intervalTime;
this.savePosition$.next(coords);
}
},
(err) => console.warn('[WARN] First GPS fix failed:', err),
{ timeout: 60000, maximumAge: 0, enableHighAccuracy: true }
);
});
}

This configuration intentionally prioritizes reliability over battery life.

The native GPS layer is allowed to update frequently:

  • interval: 5000 asks for GPS checks every 5 seconds.
  • fastestInterval: 2000 accepts faster updates when available.
  • distanceFilter: 0 avoids missing small movements.
  • stopOnStillActivity: false avoids stopping just because the device is stationary.
  • saveBatteryOnBackground: false avoids plugin-level battery reduction.

That does not mean every GPS update is saved. It only means the app keeps a strong location signal. Saving is controlled separately.

This distinction matters. Polling and persistence are not the same thing.

Permission and Service Startup

Before starting, the app checks native location status.

checkPermissionStatus(): Promise<{
isRunning: boolean;
locationServicesEnabled: boolean;
authorization: number;
}> {
return new Promise((resolve, reject) => {
try {
BackgroundGeolocation.checkStatus((status) => resolve(status as any));
} catch (err) {
reject(err);
}
});
}
async startWithPermissionCheck(): Promise<boolean> {
try {
const status = await this.checkPermissionStatus();
if (!status.locationServicesEnabled) {
this.trackingState$.next(TrackingState.LOCATION_DISABLED);
return false;
}
if (status.authorization === 0) {
this.trackingState$.next(TrackingState.PERMISSION_DENIED);
if (!this.authorizationAlertShown) {
this.authorizationAlertShown = true;
BackgroundGeolocation.showAppSettings();
}
return false;
}
this.initTracking();
return true;
} catch (err) {
this.initTracking();
return true;
}
}

The UI does not need to know how the native plugin works. It calls startWithPermissionCheck(), then starts the save pipeline if tracking was started successfully.

async startTracking(): Promise<void> {
const started = await this.geoService.startWithPermissionCheck();
if (started) {
this.syncService.startSync();
localStorage.setItem('tracking_active', 'true');
}
}

This keeps the page component focused on user actions, not platform details.

Solving Timing Drift with Clock-Based Scheduling

One subtle issue in interval-based location tracking is drift.

Consider this naive approach:

if (Date.now() - lastSavedTime >= intervalTime) {
saveLocation(location);
lastSavedTime = Date.now();
}

This looks fine, but it can drift.

If the app was supposed to save at 10:00:00, but the callback arrives at 10:00:07, the next save is scheduled from 10:00:07. Over time, delays accumulate.

Expected:

10:00:00
10:10:00
10:20:00
10:30:00

Drifted:

10:00:07
10:10:14
10:20:21
10:30:28

The fix is to schedule against the next expected target time, not against the callback time.

const now = location.time;
if (this.nextScheduledSave === 0) {
this.nextScheduledSave = now + this.intervalTime;
this.lastSavedTime = now;
this.savePosition$.next({ coords: location });
} else if (now >= this.nextScheduledSave) {
const drift = now - this.nextScheduledSave;
this.lastSavedTime = now;
this.savePosition$.next({ coords: location });
this.nextScheduledSave += this.intervalTime; console.log(
'[SAVE] Scheduled |',
new Date(now).toLocaleTimeString(),
'| drift:',
(drift / 1000).toFixed(1) + 's',
'| next:',
new Date(this.nextScheduledSave).toLocaleTimeString()
);
}

The important line is:

this.nextScheduledSave += this.intervalTime;

The next scheduled time is based on the previous scheduled time, not the delayed callback. This keeps saves aligned to the intended interval.

Why I Added a Foreground Timer

Native GPS events can slow down when a device is stationary. That is usually good for battery life, but it can be a problem when the product requirement says “save every configured interval”.

To handle this, the app uses a foreground-only timer that calls getCurrentLocation() at the configured save interval.

private startForegroundTimer(): void {
this.stopForegroundTimer();
  this.foregroundTimer = setInterval(() => {
BackgroundGeolocation.getCurrentLocation(
(location) => {
this.notifyWatchdog();
this.position$.next({ coords: location });
if (location.accuracy > this.MAX_ACCURACY) {
return;
}
const now = location.time; if (this.nextScheduledSave === 0) {
this.nextScheduledSave = now + this.intervalTime;
this.lastSavedTime = now;
this.savePosition$.next({ coords: location });
} else if (now >= this.nextScheduledSave) {
this.lastSavedTime = now;
this.savePosition$.next({ coords: location });
this.nextScheduledSave += this.intervalTime;
}
},
(err) => console.warn('[WARN] Foreground timer getCurrentLocation failed:', err),
{ timeout: 30000, maximumAge: 0, enableHighAccuracy: true }
);
}, this.intervalTime);
}

The foreground timer is stopped when the app backgrounds and restarted when it returns.

BackgroundGeolocation.on('background', () => {
this.stopForegroundTimer();
if (isIos) {
(BackgroundGeolocation as any).switchMode(
(BackgroundGeolocation as any).BACKGROUND_MODE
);
}
});
BackgroundGeolocation.on('foreground', () => {
if (isIos) {
(BackgroundGeolocation as any).switchMode(
(BackgroundGeolocation as any).FOREGROUND_MODE
);
}
this.startForegroundTimer();
});

The native plugin handles background movement. The JavaScript timer is only used while the app is active.

Filtering Bad GPS Fixes

Not every coordinate should be saved.

Phones can report poor fixes indoors, near tall buildings, in tunnels, or during GPS warmup. If you save every fix, your history can contain large jumps that never happened.

This implementation rejects saved locations with accuracy worse than 50 meters.

private readonly MAX_ACCURACY = 50;
if (location.accuracy > this.MAX_ACCURACY) {
console.warn(
`[SKIP] Poor accuracy (${location.accuracy.toFixed(1)}m > ${this.MAX_ACCURACY}m) - not saving.`
);
return;
}

The UI still receives live position updates, even if a fix is poor. But the saved history only gets acceptable points.

That gives users visibility without polluting the stored data.

GPS Signal Quality Feedback

Users need to know when tracking quality is poor. Otherwise, the app looks broken when the real issue is weak GPS.

Get Vishal Darekar’s stories in your inbox

Join Medium for free to get updates from this writer.

The service keeps a rolling list of recent accuracy values and converts them into a simple status: good, fair, or poor.

private updateSignalQuality(accuracy: number): void {
this.recentAccuracies.push(accuracy);
  if (this.recentAccuracies.length > this.ACCURACY_HISTORY_SIZE) {
this.recentAccuracies.shift();
}
const avgAccuracy =
this.recentAccuracies.reduce((a, b) => a + b, 0) /
this.recentAccuracies.length;
let quality: 'good' | 'fair' | 'poor' | 'unknown'; if (avgAccuracy < 20) {
quality = 'good';
} else if (avgAccuracy < 50) {
quality = 'fair';
} else {
quality = 'poor';
}
if (this.gpsSignalQuality$.getValue() !== quality) {
this.gpsSignalQuality$.next(quality);
}
}

The page subscribes to this observable.

this.subs.add(
this.geoService.gpsSignalQuality$.subscribe((quality) => {
this.gpsSignalQuality = quality;
})
);

And the template shows a simple indicator.

<div class="signal-indicator" *ngIf="isRunning && gpsSignalQuality !== 'unknown'">
<ion-icon [name]="gpsQualityIcon" [color]="gpsQualityColor"></ion-icon>
<span class="signal-text" [style.color]="'var(--ion-color-' + gpsQualityColor + ')'">
GPS Signal: {{ gpsSignalQuality | titlecase }}
</span>
</div>

This is a small UI detail, but it makes the tracking system easier to trust.

Watchdog Recovery for Silent Failures

One of the hardest problems in background tracking is the silent failure.

Sometimes the app does not crash. The service does not throw. The UI still says tracking is active. But no new location updates arrive.

To detect this, the service starts a watchdog timer.

private startWatchdog(): void {
this.stopWatchdog();
this.lastLocationTime = Date.now();
this.recoveryAttempts = 0;
  this.watchdogTimer = setInterval(() => {
const timeSinceLastLocation = Date.now() - this.lastLocationTime;
if (timeSinceLastLocation > this.WATCHDOG_TIMEOUT) {
console.error(
`[WATCHDOG] No location updates for ${Math.round(timeSinceLastLocation / 1000)}s - triggering recovery.`
);
this.attemptRecovery();
}
}, 30000);
}

Every location update calls notifyWatchdog().

private notifyWatchdog(): void {
this.lastLocationTime = Date.now();
this.recoveryAttempts = 0;
}

If updates stop for more than two minutes, the service restarts the native plugin.

private async attemptRecovery(): Promise<void> {
if (this.recoveryAttempts >= this.MAX_RECOVERY_ATTEMPTS) {
this.trackingState$.next(TrackingState.ERROR);
this.stopWatchdog();
return;
}
  this.recoveryAttempts++;  try {
this.isInitialized = false;
BackgroundGeolocation.stop();
await new Promise((resolve) => setTimeout(resolve, 2000)); this.initTracking();
} catch (err) {
this.trackingState$.next(TrackingState.ERROR);
}
}

This does not make mobile background tracking perfect. Nothing does. But it prevents a common failure mode: believing tracking is running when the location stream has actually stopped.

Saving Location Locally

The sync service subscribes to savePosition$, not position$.

import { Injectable, OnDestroy } from '@angular/core';
import { BehaviorSubject, Subscription } from 'rxjs';
const HISTORY_KEY = 'geo_location_history';
const MAX_HISTORY = 200;
interface SyncStatus {
pending: number;
lastSyncTime: number | null;
lastError: string | null;
isOnline: boolean;
}
@Injectable({ providedIn: 'root' })
export class LocationSyncService implements OnDestroy {
private positionSub?: Subscription;
public locationHistory$ =
new BehaviorSubject<LocationPayload[]>(this.loadHistory());
public syncStatus$ = new BehaviorSubject<SyncStatus>({
pending: this.loadHistory().length,
lastSyncTime: null,
lastError: null,
isOnline: true,
});
constructor(private geoService: BackgroundGeolocationService) {} startSync(): void {
this.stopSync();
this.positionSub = this.geoService.savePosition$.subscribe((position) => {
this.saveLocation(position);
});
}
stopSync(): void {
this.positionSub?.unsubscribe();
this.positionSub = undefined;
}
ngOnDestroy(): void {
this.stopSync();
}
}

This is important because position$ can update every few seconds, while savePosition$ only emits at the configured save interval.

The saved payload is normalized before persistence.

private buildPayload(position: any): LocationPayload {
const c = position?.coords ?? position;
return {
latitude: c.latitude ?? c.lat ?? 0,
longitude: c.longitude ?? c.lng ?? c.lon ?? 0,
accuracy: c.accuracy ?? 0,
altitude: c.altitude ?? null,
speed: c.speed != null && c.speed >= 0 ? c.speed : null,
bearing: c.bearing ?? c.heading ?? null,
timestamp: c.time
? Math.floor(c.time / 1000)
: Math.floor(Date.now() / 1000),
deviceId: this.getOrCreateDeviceId(),
};
}

For this implementation, the history is stored locally in localStorage and capped at 200 records.

private saveLocation(position: any): void {
const payload = this.buildPayload(position);
const history = this.loadHistory();
  history.push(payload);  if (history.length > MAX_HISTORY) {
history.splice(0, history.length - MAX_HISTORY);
}
this.persistHistory(history);
this.locationHistory$.next([...history]);
this.syncStatus$.next({
...this.syncStatus$.getValue(),
pending: history.length,
lastSyncTime: Date.now(),
lastError: null,
});
}

The helper methods keep the example local-only:

private getOrCreateDeviceId(): string {
let id = localStorage.getItem('geo_device_id');
if (!id) {
id =
'device_' +
Date.now().toString(36) +
'_' +
Math.random().toString(36).slice(2, 9);
localStorage.setItem('geo_device_id', id);
}
return id;
}
private loadHistory(): LocationPayload[] {
try {
const raw = localStorage.getItem(HISTORY_KEY);
return raw ? (JSON.parse(raw) as LocationPayload[]) : [];
} catch {
return [];
}
}
private persistHistory(history: LocationPayload[]): void {
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
}

In a production app, this is where you would add API upload, retry queues, encryption, or a database such as SQLite.

Restoring Tracking After App Reopen

The app stores a simple tracking_active flag when the user starts or stops tracking.

async startTracking(): Promise<void> {
const started = await this.geoService.startWithPermissionCheck();
if (started) {
this.syncService.startSync();
localStorage.setItem('tracking_active', 'true');
}
}
stopTracking(): void {
this.geoService.stopBackgroundLocation();
this.syncService.stopSync();
localStorage.setItem('tracking_active', 'false');
}

On app startup, AppComponent checks that flag and restores tracking if needed.

async ngOnInit(): Promise<void> {
const wasTracking = localStorage.getItem('tracking_active') === 'true';
  if (wasTracking) {
const started = await this.geoService.startWithPermissionCheck();
if (started) {
this.syncService.startSync();
} else {
localStorage.setItem('tracking_active', 'false');
}
}
}

This is especially useful after the app is closed and reopened. Android can do more in the background than iOS, but both platforms benefit from a deliberate restore path.

Native iOS Configuration

For iOS, location permission strings and background mode are configured through Capacitor.

ios: {
infoPlist: {
NSLocationAlwaysAndWhenInUseUsageDescription:
'This app tracks your location in the background to report your position to the server.',
NSLocationWhenInUseUsageDescription:
'This app needs access to your location while in use to track your position.',
NSLocationAlwaysUsageDescription:
'This app tracks your location at all times to report your position to the server.',
NSMotionUsageDescription:
'This app uses motion data to optimise battery usage during location tracking.',
UIBackgroundModes: ['location'],
CFBundleURLTypes: [
{
CFBundleTypeRole: 'Editor',
CFBundleURLSchemes: ['prefs'],
},
],
},
}

The important part is:

UIBackgroundModes: ['location']

Without the background location mode and correct permission strings, iOS will not behave the way your code suggests.

Also remember: iOS has strict background execution policies. You should test on a real device with the screen locked, app backgrounded, and after force-close scenarios.

Android Notes

Android generally gives more flexibility for continuous background tracking, but you still need to handle foreground services and battery optimization behavior.

In the plugin config, Android uses:

startOnBoot: !isIos,
startForeground: !isIos,
notificationsEnabled: !isIos,
locationProvider: BackgroundGeolocation.ACTIVITY_PROVIDER,

The foreground notification is not just a UI detail. On modern Android, it is part of keeping long-running background location work alive.

You should also test with:

  • Battery saver enabled.
  • App in background for a long period.
  • Phone locked.
  • Device reboot.
  • Poor GPS conditions.
  • Permission revoked while tracking is active.

UI Pattern: One Toggle, Clear Status

The UI uses a single start/stop button instead of separate actions.

<ion-card class="hero-card" [class.tracking]="isRunning">
<ion-card-content>
<ion-button
expand="block"
size="large"
[color]="isRunning ? 'danger' : 'primary'"
(click)="isRunning ? stopTracking() : startTracking()"
class="toggle-button">
<ion-icon [name]="isRunning ? 'stop-circle' : 'play-circle'" slot="start"></ion-icon>
{{ isRunning ? 'Stop' : 'Start' }} Tracking
</ion-button>
</ion-card-content>
</ion-card>

The component exposes simple template helpers:

get isRunning(): boolean {
return (
this.trackingState === TrackingState.RUNNING ||
this.trackingState === TrackingState.STARTING
);
}
get statusColor(): string {
switch (this.trackingState) {
case TrackingState.RUNNING:
return 'success';
case TrackingState.STARTING:
case TrackingState.STOPPING:
return 'warning';
case TrackingState.PERMISSION_DENIED:
case TrackingState.LOCATION_DISABLED:
case TrackingState.ERROR:
return 'danger';
default:
return 'medium';
}
}

This keeps the template readable and avoids spreading tracking-state logic across the UI.

Testing Checklist

For background geolocation, simulator testing is not enough. I recommend testing these scenarios on real devices:

  • Start tracking, lock the phone, walk for 10–15 minutes.
  • Keep the phone stationary and verify scheduled saves.
  • Move indoors and verify poor GPS signal status.
  • Disable location permission while tracking.
  • Turn on battery saver mode.
  • Background the app for a long period.
  • Force close the app and reopen it.
  • Reboot an Android device while tracking was active.
  • Test with mobile data disabled.
  • Test in urban areas with tall buildings.

If you use this pattern in production, I recommend writing your own reliability checklist and keeping it close to the code. Background tracking behavior changes with OS versions, device vendors, permissions, battery modes, and real-world GPS conditions.

At minimum, document your expected behavior for Android and iOS separately, because the two platforms do not make the same guarantees.

Key Lessons

The biggest lesson is that reliable location tracking is not a single API call. It is a system.

The system needs:

  • A native tracking configuration.
  • A separate save schedule.
  • Accuracy filtering.
  • Foreground/background lifecycle handling.
  • User-visible GPS quality.
  • Recovery when updates stop.
  • Platform-specific configuration.
  • Real-device testing.

The most useful architectural decision was separating live positions from saved positions:

position$      // every GPS fix for UI
savePosition$ // only scheduled, acceptable fixes for persistence

That one separation made the rest of the design cleaner.

The second important decision was clock-based scheduling:

this.nextScheduledSave += this.intervalTime;

That avoids cumulative drift and keeps saved points aligned to the intended interval.

Final Thoughts

If you are building background location tracking in Ionic or Capacitor, assume the difficult cases will happen:

  • GPS will be weak.
  • Native updates will pause.
  • Users will revoke permissions.
  • The OS will optimize aggressively.
  • App lifecycle events will happen at inconvenient times.

Design for those cases from the beginning.

This implementation is not a universal solution for every product. Some apps should reduce GPS frequency to save battery. Some should upload to a backend immediately. Some should store data in SQLite instead of localStorage.

But the core structure is reusable:

  1. Keep native tracking alive.
  2. Separate live updates from saved records.
  3. Save on a stable schedule.
  4. Filter poor fixes.
  5. Monitor health.
  6. Recover from stale updates.
  7. Test on real devices.

That structure turns background geolocation from a fragile feature into a system you can reason about.

--

--

Vishal Darekar
Vishal Darekar

Written by Vishal Darekar

Web and Mobile application developer