---
title: How to Track Location in the Background with Capacitor
description: Build background location tracking in a Capacitor app — two-step permissions, a native watch session, a persistent queue and batched uploads to your server.
date:
  created: 2026-10-12
  updated: 2026-10-12
authors:
  - robingenz
categories:
  - Capacitor
  - Guides
  - SDKs
links:
  - Capacitor Background Geolocation: sdks/capacitor/background-geolocation.md
faq: true
---

# How to Track Location in the Background with Capacitor

Capacitor background location tracking needs native code, because the web view that runs your JavaScript is suspended shortly after the user leaves the app. This guide builds the tracking part of a courier app: the driver starts a shift, the app records positions while the phone sits in a pocket or a car mount, and every position reaches your server in batches, with nothing lost when the network drops. We use the [Capacitor Background Geolocation plugin](../../sdks/capacitor/background-geolocation.md), which 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:**

- The `watchPosition` callbacks of `@capacitor/geolocation` run in the web view and stop with it; the Capacitor Background Geolocation plugin runs the session in a foreground service on Android and under the `location` background mode on iOS.
- Request `location` and `notifications` first and `backgroundLocation` in a second call; iOS shows the "Always" upgrade prompt once per installation.
- `setConfig({ url })` stores every position in a native SQLite queue and uploads batches of 100 positions, or whatever arrived within 60 seconds, with exponential backoff.
- Treat the queue as the source of truth and `positionChange` as a display feed, because the event only fires while the web view is alive.
- A force-quit ends the session on both platforms, so call `startWatching(...)` again on every app launch while a shift is active.

## What you will build

The app in this guide tracks a courier from the moment they start a shift until they end it, on Android and iOS, with the phone locked most of the time. Three things have to work: the driver sees their own position on a map while the app is open, your dispatch server receives every recorded position even after an hour without network, and the battery survives the shift.

The official `@capacitor/geolocation` plugin cannot do this part. Its [`watchPosition(...)`](https://capacitorjs.com/docs/apis/geolocation#watchposition){:target="_blank"} delivers positions to a JavaScript callback, and iOS suspends the web view that hosts the callback shortly after the app leaves the screen. The positions that Core Location collects in the meantime arrive late or not at all, and nothing stores them. The Capacitor Background Geolocation plugin moves both jobs into native code: a watch session that the operating system keeps alive, and a queue with an uploader that runs without the web view.

We build it in five steps: platform setup, the two-step permission flow, the upload configuration, the watch session itself, and the lifecycle around a shift. If you are coming from a commercial SDK, [An Alternative to Transistorsoft Background Geolocation](./alternative-to-transistorsoft-background-geolocation.md) maps its API to the plugin.

## Platform setup

To install the Capacitor Background Geolocation plugin, please refer to the [Installation](../../sdks/capacitor/background-geolocation.md/#installation) section in the plugin documentation. The plugin requires Capacitor 8 or later and supports CocoaPods and Swift Package Manager.

On iOS, the app needs two usage descriptions and the `location` background mode in `Info.plist`:

```xml
<key>NSLocationWhenInUseUsageDescription</key>
<string>Your position is shown to dispatch during an active shift.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Your position is recorded during a shift, also while the app is in the background.</string>
<key>UIBackgroundModes</key>
<array>
  <string>location</string>
</array>
```

Without the background mode, iOS stops delivering positions a few seconds after the app leaves the foreground. [Why iOS Stops Sending Background Location Updates](./why-ios-stops-background-location-updates.md) covers that and every other reason updates stop on iOS, so this guide does not repeat the iOS rules.

On Android, the plugin's own manifest declares the fine and coarse location permissions, the foreground service permissions and the service with the `location` foreground service type. One permission is left to your app on purpose, because Google Play requires a [policy declaration and review](https://support.google.com/googleplay/android-developer/answer/9799150){:target="_blank"} for apps that use it:

```xml
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
```

Add it to `android/app/src/main/AndroidManifest.xml`. A courier app that tracks a shift has a clear case for the declaration; an app that only needs the position while it is open should not add the permission at all.

## Request permissions

The Capacitor Background Geolocation plugin splits the location permissions into three types, `location`, `notifications` and `backgroundLocation`, and the background one has to be requested in a separate, second call. Both platforms enforce this order. On Android 11 and newer, the request for background location sends the user to the app's location settings, where they have to pick "Allow all the time". On iOS, the second request shows the system prompt that upgrades the permission from "While Using the App" to "Always".

```typescript
import { BackgroundGeolocation } from '@capawesome-team/capacitor-background-geolocation';

const requestForegroundPermissions = async () => {
  const status = await BackgroundGeolocation.requestPermissions({
    permissions: ['location', 'notifications'],
  });
  return status.location === 'granted';
};

const requestBackgroundPermission = async () => {
  const status = await BackgroundGeolocation.requestPermissions({
    permissions: ['backgroundLocation'],
  });
  return status.backgroundLocation;
};
```

Call the first function when the driver taps "Start shift", and the second one after a screen of your own that explains why the app needs the position while it is closed. Google Play's policy expects this in-app disclosure before the system dialog, and on iOS the explanation matters because the operating system shows the upgrade prompt only once per installation. If the driver declines it, later calls to [`requestPermissions(...)`](../../sdks/capacitor/background-geolocation.md#requestpermissions) return the current status without a prompt, and [`openSettings()`](../../sdks/capacitor/background-geolocation.md#opensettings) is the only way left to get to "Always". Show a button for it when `backgroundLocation` comes back as `denied` or stays `prompt` after the request.

The `notifications` permission belongs in the first call because the foreground service on Android shows a persistent notification, and Android 13 and newer require permission to display it. On Android 12 and older and on iOS, it is always `granted`. On Android 9 and older, `backgroundLocation` mirrors the `location` state, because no separate background permission exists there.

A session without `backgroundLocation` still works. The plugin documents that position updates "may be suspended while the app is in the background" in that case, and on iOS a "While Using the App" authorization does keep delivering positions in the background as long as the session was started in the foreground. For a shift that lasts hours, with the app possibly terminated and relaunched in between, request "Always".

## Configure the upload

The upload configuration of the Capacitor Background Geolocation plugin lives in native storage and turns the queue on. [`setConfig(...)`](../../sdks/capacitor/background-geolocation.md#setconfig) takes the endpoint, the headers and the batching parameters, and as soon as a `url` is set, every recorded position is stored in a SQLite database and uploaded from there:

```typescript
import { BackgroundGeolocation } from '@capawesome-team/capacitor-background-geolocation';

const configureUpload = async (accessToken: string, driverId: string) => {
  await BackgroundGeolocation.setConfig({
    url: 'https://api.example.com/v1/positions',
    headers: { Authorization: `Bearer ${accessToken}` },
    extras: { driverId },
    batchSize: 50,
    flushInterval: 30000,
  });
};
```

Call it on every app start, before the watch session. The stored copy is a cache that the plugin discards when its format changes, and `setConfig(...)` replaces the whole configuration rather than merging it: a call with only `{ maxSize: 5000 }` removes the `url` and stops the upload. Use [`getConfig()`](../../sdks/capacitor/background-geolocation.md#getconfig) and spread the result when you change a single value.

The plugin sends a `POST` request with `Content-Type: application/json; charset=utf-8` and a body of this shape, with `extras` only present when you set it:

```json
{
  "positions": [
    {
      "id": 1042,
      "latitude": 52.520008,
      "longitude": 13.404954,
      "accuracy": 8.5,
      "altitude": 38.2,
      "altitudeAccuracy": 3.1,
      "bearing": 271.4,
      "speed": 11.8,
      "simulated": false,
      "timestamp": 1760270400000
    }
  ],
  "extras": { "driverId": "d-4711" }
}
```

Your server answers with a status code and the plugin ignores the body. A `2xx` response acknowledges the batch, which is deleted from the queue, and the next batch goes out right away. `401`, `408`, `429`, `5xx`, a network error and a timeout of 30 seconds are retried with a backoff that starts at 5 seconds, doubles, and stops at 10 minutes. Any other status, such as `400` or `422`, drops the batch permanently and increments a counter you can read later. Delivery is at-least-once, so store the `id` per device and ignore a position you have already seen.

The two batching values decide how often the cellular radio wakes up. With the defaults of 100 positions or 60 seconds, a driving courier at a `distanceFilter` of 25 meters produces an upload roughly every minute. Smaller batches give dispatch a fresher map and cost battery; a `batchSize` of 1 uploads every position on its own. The `401` case is retryable on purpose: when the access token expires, call `setConfig(...)` again with the new header, and the queued positions go out afterwards, without interrupting the watch session.

Register the [`uploadFailed`](../../sdks/capacitor/background-geolocation.md#addlisteneruploadfailed-) listener to see rejected batches during development. It carries the message and the HTTP status code, and the positions stay queued unless the server rejected them permanently.

## Start the watch session

A watch session of the Capacitor Background Geolocation plugin starts with [`startWatching(...)`](../../sdks/capacitor/background-geolocation.md#startwatching), and on Android the `androidNotification` option is mandatory, because the foreground service that keeps the session alive needs a visible notification:

```typescript
import {
  Accuracy,
  ActivityType,
  BackgroundGeolocation,
} from '@capawesome-team/capacitor-background-geolocation';

const startWatching = async () => {
  await BackgroundGeolocation.addListener('positionChange', event => {
    moveDriverMarker(event.position.latitude, event.position.longitude);
  });
  await BackgroundGeolocation.addListener('positionError', event => {
    console.error(`Tracking error ${event.code}: ${event.message}`);
  });
  await BackgroundGeolocation.startWatching({
    accuracy: Accuracy.High,
    distanceFilter: 25,
    androidInterval: 10000,
    iosActivityType: ActivityType.AutomotiveNavigation,
    androidNotification: {
      title: 'Shift in progress',
      text: 'Your position is shared with dispatch.',
    },
  });
};
```

Call it from a foreground moment, such as the tap on "Start shift". Only one session runs at a time; a second call rejects with `ALREADY_WATCHING`, so stop the session before you restart it with different options.

The options tune the trade-off between detail and battery:

- **`distanceFilter`**: the minimum distance in meters between two reported positions, 10 by default. `0` reports every position, which fills the queue with duplicates while the vehicle waits at a loading dock. 25 meters keeps a route recognizable and drops most of the noise.
- **`androidInterval`**: how often Android requests a position, 5 seconds by default, even while the device does not move; the distance filter then decides whether the position is reported. iOS has no equivalent and decides itself, far less often while the device is stationary.
- **`accuracy`**: `High` for a route that dispatch looks at, `Balanced` (about 100 meters) when a rough position is enough.
- **`iosActivityType`**: a hint for Core Location about the expected movement (the Core Location property behind this option is `activityType`). It changes when iOS may pause updates, which only happens with `iosPausesAutomatically` set to `true`; this guide leaves that at its default of `false`, so the session keeps running while the courier waits.

The `positionChange` event is for the map on the driver's screen. It fires only while the web view is alive, which is exactly the situation in which the driver looks at the map. With the queue enabled, every position also lands in the database, so the event and the queue carry the same positions, and your code should never treat the event as the record. [`positionError`](../../sdks/capacitor/background-geolocation.md#addlistenerpositionerror-) fires when the driver disables location services or revokes the permission mid-shift, with codes such as `LOCATION_SERVICES_DISABLED` and `PERMISSION_DENIED`.

## Handle the shift lifecycle

A shift outlives the app process, so the Capacitor Background Geolocation plugin needs a restart from your code after every launch. iOS terminates apps under memory pressure, and a force-quit by the driver ends the session on both platforms. Keep your own "shift active" flag in storage and call `startWatching(...)` again on launch when it is set:

```typescript
import { BackgroundGeolocation } from '@capawesome-team/capacitor-background-geolocation';

const restoreShift = async (shiftActive: boolean) => {
  const { watching } = await BackgroundGeolocation.isWatching();
  if (shiftActive && !watching) {
    await startWatching();
  }
};
```

The queue survives the restart and the force-quit, so the positions recorded before the kill are still uploaded. What the plugin cannot do is record the gap between the kill and the next launch: no process means no positions. If arrivals and departures matter more than the route, the [Capacitor Geofences plugin](../../sdks/capacitor/geofences.md) covers that case: the operating system wakes the app for a region crossing even while the app is terminated, but it reports crossings, not a trail of positions.

Ending the shift takes three calls. [`stopWatching()`](../../sdks/capacitor/background-geolocation.md#stopwatching) ends the session and removes the Android notification, `removeAllListeners()` drops the handlers that `startWatching()` registered so that a second shift in the same app process does not get them twice, and [`triggerUpload()`](../../sdks/capacitor/background-geolocation.md#triggerupload) skips the current backoff and uploads what is left in the queue right away:

```typescript
import { BackgroundGeolocation } from '@capawesome-team/capacitor-background-geolocation';

const endShift = async () => {
  await BackgroundGeolocation.stopWatching();
  await BackgroundGeolocation.removeAllListeners();
  await BackgroundGeolocation.triggerUpload();
  const { pendingCount } = await BackgroundGeolocation.getQueueStatus();
  return pendingCount;
};

const signOut = async () => {
  await BackgroundGeolocation.stopWatching();
  await BackgroundGeolocation.clearQueue();
  await BackgroundGeolocation.resetConfig();
};
```

`triggerUpload()` resolves when the attempt is scheduled, not when the server has acknowledged the batch, so read [`getQueueStatus()`](../../sdks/capacitor/background-geolocation.md#getqueuestatus) if the UI should tell the driver that everything has been delivered. The upload runs whenever the app process is alive and a `url` is set, with or without a session, so the remaining positions also go out on the next launch if the driver closes the app first.

On sign-out, `stopWatching()` comes first, because a driver who signs out without ending the shift would otherwise leave the session collecting positions for the next user; the call is a no-op when no session is active. Then [`clearQueue()`](../../sdks/capacitor/background-geolocation.md#clearqueue) deletes the stored positions of the previous driver and resets the dropped counter, and [`resetConfig()`](../../sdks/capacitor/background-geolocation.md#resetconfig) removes the endpoint and the token. The queue is not encrypted at rest, and a precise location history is sensitive data, so do not leave one driver's positions on a shared device.

## Test the pipeline

Testing background location tracking takes a server that shows what arrived and a device that moves. For the server, the plugin's README points to the [Background Geolocation Playground](https://background-geolocation-playground.capawesome.io){:target="_blank"}: set its `/v1/positions` endpoint as `url` and a session key as bearer token, and the positions appear in a table and on a map. Sessions are deleted after 14 days and anyone with the key can view them, so use it with test data only.

For your own server, the Android emulator reaches your machine at `10.0.2.2`. Plain `http://` needs an App Transport Security exception on iOS and a network security configuration on Android, so an HTTPS tunnel to your local port is less work. Answer with `503` to watch the backoff in the `uploadFailed` events, and with `400` to see a batch counted in `droppedCount`.

For movement, Xcode's simulator offers simulated routes under Features > Location, and the Android emulator plays back GPX and KML files from its extended controls. Neither replaces a test on a real device with the screen off for an hour, which is the only way to see Android's battery optimizations and the behavior of the specific manufacturer's firmware. Watch `pendingCount` over that hour: it should return to zero after every upload, and a number that only grows means the server rejects batches or the network constraint is never met.

## FAQ

### How do I track a user's location in the background in Capacitor?

Use a plugin that runs the location session in native code, such as the [Capacitor Background Geolocation plugin](../../sdks/capacitor/background-geolocation.md). Request the `location` and `notifications` permissions first and `backgroundLocation` in a second call, enable the `location` background mode on iOS and add `ACCESS_BACKGROUND_LOCATION` on Android, then call `startWatching(...)` with an Android notification. To get the positions to your server, set a `url` with `setConfig(...)`, and the plugin uploads them in batches from a persistent queue.

### Does tracking continue after the user force-quits the app?

No. A force-quit ends the watch session on Android and iOS, and this is an operating system rule rather than a plugin limitation. Positions recorded before the force-quit stay in the queue and are uploaded on the next launch. Call `startWatching(...)` again when the app starts and a shift is still active.

### Do I need the "Always" permission on iOS?

For a session started in the foreground, "While Using the App" keeps delivering positions in the background as long as the `location` background mode is enabled. "Always" is needed for the plugin's `backgroundLocation` permission to report `granted`, and it is what lets iOS relaunch an app for significant-change and region monitoring events. For a shift that runs for hours, request it.

### Does it work on devices without Google Play services?

Yes. The plugin falls back to Android's `LocationManager` when Google Play services are not available, for example on recent Huawei devices, and `androidForceLocationManager` forces that path on any device. The fallback reports raw positions from a single provider and uses more battery than the fused provider.

### Is the Capacitor Background Geolocation 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.

## Related posts

- [Announcing the Capacitor Background Geolocation Plugin](./announcing-the-capacitor-background-geolocation-plugin.md)
- [Why iOS Stops Sending Background Location Updates](./why-ios-stops-background-location-updates.md)
- [An Alternative to Transistorsoft Background Geolocation](./alternative-to-transistorsoft-background-geolocation.md)
- [How to Add Geofencing to an Ionic App](./how-to-add-geofencing-to-an-ionic-app.md)

## Try Capawesome Cloud

The `Info.plist` keys, the background mode and the manifest permission in this guide all live in the native project, so background tracking ships with a new native build rather than a web update. Capawesome Cloud builds and signs that app for Android and iOS without a Mac, and [How to Sign & Build Capacitor Apps in the Cloud](./how-to-sign-and-build-your-capacitor-app-in-the-cloud.md) shows the steps.

[Try Capawesome Cloud Free](https://capawesome.io){ .md-button .md-button--primary }

## Conclusion

Start with a `distanceFilter` of 25 meters, an `androidInterval` of 10 seconds and the default batch settings, measure the battery over one real shift, and only then lower the filter or the batch size where dispatch needs more detail. Keep the queue as the record and the map as a view, request `backgroundLocation` only after your own explanation screen, and restart the session on every launch. When positions stop arriving on iOS, work through the checklist in [Why iOS Stops Sending Background Location Updates](./why-ios-stops-background-location-updates.md) before changing any option.

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