MediumLocation Tracking in Capacitor with the Geolocation Plugin | by Pranjal Vyas | Nov, 2026 | Medium
Sitemap
Pranjal Vyas

Pranjal Vyas

Passionate iOS, Android, and Flutter developer creating seamless mobile experiences. Backend explorer diving into Node.js and Rust for robust APIs.

Location Tracking in Capacitor with the Geolocation Plugin

6 min read2 days ago

--

Maps, delivery tracking, “stores near me”, run trackers: sooner or later most apps need to know where the user is. In a Capacitor app the browser’s navigator.geolocation technically works inside the WebView, but it gives you no control over native permission prompts and behaves differently on iOS and Android. The official @capacitor/geolocation plugin talks to Core Location and the Android location APIs directly, with one TypeScript API for all three platforms. In this guide we will install it, configure permissions, get a one-off position, stream live updates, and wrap it all in a small service that handles the annoying edge cases.

Press enter or click to view image in full size

What you’ll build

  • A @capacitor/geolocation setup with the right iOS and Android permission entries.
  • A permission flow that checks first, asks only when needed, and handles “denied” gracefully.
  • A one-off “where am I?” lookup with sensible accuracy and timeout options.
  • A live tracker using watchPosition that you can start and stop cleanly.
  • A tiny distance calculator that turns raw coordinates into kilometres walked.
Press enter or click to view image in full size
From permission to live location: check, ask, then fix or watch.

1. Install the plugin

Add the plugin and sync it into the native projects:

npm install @capacitor/geolocation
npx cap sync

cap sync copies the plugin into ios/ and android/. Run it again any time you add or update a Capacitor plugin, otherwise the native side will not know it exists.

2. Configure iOS permissions

iOS will crash the app (yes, crash) if you ask for location without a usage description. Open ios/App/App/Info.plist and add the keys below. The text is shown to the user in the system prompt, so make it specific:

<key>NSLocationWhenInUseUsageDescription</key>
<string>We use your location to show nearby stores and track your runs.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We use your location to keep tracking your run while the screen is off.</string>

The first key is all you need for foreground use. Only add the second if you genuinely need “Always” access, because App Review will ask why.

3. Configure Android permissions

On Android, declare the permissions in android/app/src/main/AndroidManifest.xml, inside the <manifest> tag:

<!-- Approximate location (city block level) -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Precise location (GPS level) -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- Lets the Play Store show the app on devices without GPS too -->
<uses-feature android:name="android.hardware.location.gps" android:required="false" />

Since Android 12 the user can pick “Approximate” even when you ask for precise location, so always design for the coarse case. A position that is 1–2 km off should not break your screen.

4. Check and request permission

Never fire the system prompt on app launch. Ask at the moment the user taps something that needs location, and check the current state first so you do not re-prompt someone who already said no:

import { Geolocation } from '@capacitor/geolocation';

export async function ensureLocationPermission(): Promise<boolean> {
let status = await Geolocation.checkPermissions();

if (status.location === 'prompt' || status.location === 'prompt-with-rationale') {
status = await Geolocation.requestPermissions({
permissions: ['location', 'coarseLocation'],
});
}

// 'location' is precise, 'coarseLocation' is approximate (Android)
return status.location === 'granted' || status.coarseLocation === 'granted';
}

If the result is denied, the OS will not show the prompt again. The best you can do is explain why location helps and point the user to the app settings.

5. Get the current position

For a one-off lookup, such as filling in a delivery address, getCurrentPosition is all you need:

import { Geolocation, Position } from '@capacitor/geolocation';

export async function whereAmI(): Promise<Position | null> {
if (!(await ensureLocationPermission())) return null;

try {
const position = await Geolocation.getCurrentPosition({
enableHighAccuracy: true, // use GPS, not just Wi-Fi/cell
timeout: 10000, // give up after 10 seconds
maximumAge: 30000, // accept a cached fix up to 30 seconds old
});

const { latitude, longitude, accuracy } = position.coords;
console.log(`You are at ${latitude}, ${longitude} (±${Math.round(accuracy)} m)`);
return position;
} catch (err) {
console.warn('Could not get location', err);
return null;
}
}

Get Pranjal Vyas’s stories in your inbox

Join Medium for free to get updates from this writer.

A quick tour of the options:

  1. enableHighAccuracy turns on GPS. It is more precise but slower and heavier on the battery.
  2. timeout stops the request from hanging forever indoors or underground.
  3. maximumAge lets the OS return a recent cached fix instantly instead of waking the GPS.

Always look at coords.accuracy. It is the radius in metres, so a value of 1500 means "somewhere in this neighbourhood", not a street address.

6. Stream live updates with watchPosition

For anything that moves (a run, a delivery, a live map pin) use watchPosition. It calls your callback every time the device reports a new fix and returns an id you use to stop it later:

import { Geolocation, Position } from '@capacitor/geolocation';

let watchId: string | null = null;

export async function startTracking(onUpdate: (p: Position) => void) {
if (watchId || !(await ensureLocationPermission())) return;

watchId = await Geolocation.watchPosition(
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 0 },
(position, err) => {
if (err) {
console.warn('Watch error', err);
return;
}
if (position) onUpdate(position);
}
);
}

export async function stopTracking() {
if (!watchId) return;
await Geolocation.clearWatch({ id: watchId });
watchId = null;
}

Two rules keep this healthy. First, guard against starting a second watch while one is running, or you will get duplicate callbacks and a hot phone. Second, always call clearWatch when the screen unmounts, otherwise GPS keeps running in the background of your WebView.

7. Turn coordinates into distance

Raw coordinates are not very interesting to users. Kilometres are. The haversine formula gives the distance between two points on a sphere, which is accurate enough for a run tracker:

export function distanceKm(
a: { latitude: number; longitude: number },
b: { latitude: number; longitude: number }
): number {
const R = 6371; // Earth's radius in km
const toRad = (deg: number) => (deg * Math.PI) / 180;

const dLat = toRad(b.latitude - a.latitude);
const dLon = toRad(b.longitude - a.longitude);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(a.latitude)) * Math.cos(toRad(b.latitude)) * Math.sin(dLon / 2) ** 2;

return 2 * R * Math.asin(Math.sqrt(h));
}

GPS jitters even when you stand still, so filter the points before you add them up. Skip fixes with poor accuracy and ignore tiny jumps:

import { Position } from '@capacitor/geolocation';

let last: Position | null = null;
let totalKm = 0;

export function onPosition(p: Position) {
if (p.coords.accuracy > 30) return; // too fuzzy, skip it

if (last) {
const step = distanceKm(last.coords, p.coords);
if (step < 0.005) return; // under 5 m is probably jitter
totalKm += step;
}
last = p;
console.log(`Distance so far: ${totalKm.toFixed(2)} km`);
}

8. Wire it into a component

Here is the whole thing inside a plain framework-free example. The same pattern works in an Angular ngOnDestroy, a React useEffect cleanup, or a Vue onUnmounted hook:

const startBtn = document.querySelector<HTMLButtonElement>('#start')!;
const stopBtn = document.querySelector<HTMLButtonElement>('#stop')!;
const label = document.querySelector<HTMLSpanElement>('#distance')!;

startBtn.addEventListener('click', async () => {
await startTracking((p) => {
onPosition(p);
label.textContent = `${totalKm.toFixed(2)} km`;
});
});

stopBtn.addEventListener('click', () => stopTracking());

// Clean up when the page goes away
window.addEventListener('pagehide', () => stopTracking());

9. Common pitfalls

  • Location services are off. If the device-wide toggle is disabled, calls fail with an error even when your app has permission. Catch it and show a “Turn on location” hint.
  • Testing on the web. The plugin falls back to the browser API, which only works on https:// or localhost. Use the browser's sensor override in DevTools to fake a location.
  • Simulators. In the iOS Simulator use Features → Location; in the Android Emulator use the Location tab in Extended Controls to play a route.
  • Background tracking. @capacitor/geolocation is a foreground plugin. When the app is suspended, updates stop. For true background tracking look at a dedicated community plugin and be ready to justify it in store review.

Practical checklist

  1. Install @capacitor/geolocation and run npx cap sync.
  2. Add NSLocationWhenInUseUsageDescription to Info.plist with a specific reason.
  3. Declare coarse and fine location permissions in AndroidManifest.xml.
  4. Check permission before requesting, and only ask when the user needs the feature.
  5. Read coords.accuracy and handle approximate locations.
  6. Always pair watchPosition with clearWatch.

That is it. With a permission helper, one-off lookups, and a clean watcher, location in Capacitor stops being scary and becomes just another TypeScript API. Happy tracking!

--

--

Pranjal Vyas
Pranjal Vyas

Passionate iOS, Android, and Flutter developer creating seamless mobile experiences. Backend explorer diving into Node.js and Rust for robust APIs.