+1 (415) 843-4662

Background work in Ionic apps: background tasks, background geolocation and a sync queue that survives the OS

Every few months a client asks us for the same feature: "the app should keep tracking the driver / syncing the forms / checking for new jobs while it is in the background." On the web that request is trivial. On iOS and Android it runs straight into the battery rules the platforms have been tightening for a decade, and most of the disappointment in these projects comes from finding out about those rules after the estimate was signed.

This tutorial is the version of that conversation we wish we could hand people up front. It covers what background execution an Ionic + Capacitor app can actually get, and then builds three working patterns: a short background task on app suspend, a real background location tracker, and a deferred sync queue that drains when the OS allows it. Examples are Ionic Angular, but every plugin call here is framework-agnostic.

The rules, before the code

Four things are true on both platforms, and no plugin changes them:

  1. A backgrounded WebView is frozen. Once your app leaves the foreground, JavaScript timers stop being reliable within seconds. setInterval is not a background job.
  2. You get a short grace period, not a thread. iOS gives you roughly 30 seconds of beginBackgroundTask time to finish work in flight. Android's equivalent is a foreground service or WorkManager job. Either way it is "finish this", not "keep running".
  3. Continuous background work requires a declared, user-visible reason. Background location needs an iOS background mode plus Always permission, and on Android a foreground service with a persistent notification and a FOREGROUND_SERVICE_LOCATION type. Reviewers do reject apps that declare these without a matching user-facing feature.
  4. The scheduler decides when, you decide what. Periodic background work is opportunistic: expect "roughly every 15 minutes at best, maybe hours on a dozing device", never "exactly every 5 minutes".

Design rule that falls out of this: the server is the clock, the device is a buffer. If a workflow genuinely needs something to happen at a specific time, do it server-side and push the result to the device. Use device background work only for capturing data the device alone can see (location, sensors, offline edits) and for flushing it later.

2. Finishing work when the app suspends

The cheapest win. When the user backgrounds the app mid-upload, ask the OS for a moment to finish instead of letting the WebView freeze halfway.

npm i @capacitor/background-runner @capawesome/capacitor-background-task
npx cap sync
import { App } from '@capacitor/app';
import { BackgroundTask } from '@capawesome/capacitor-background-task';
import { flushQueue } from './sync/queue';

App.addListener('appStateChange', async ({ isActive }) => {
  if (isActive) { return; }

  const taskId = await BackgroundTask.beforeExit(async () => {
    try {
      // Keep it small and interruptible: one batch, hard timeout.
      await flushQueue({ maxItems: 25, timeoutMs: 20_000 });
    } finally {
      await BackgroundTask.finish({ taskId });
    }
  });
});

Two mistakes we fix constantly: forgetting finish() (iOS kills the app and you get crash reports that look random), and trying to drain a 5,000-item queue in the grace window. Batch it, and make every batch idempotent so a kill mid-flush costs you nothing but a retry.

3. Real background location

Only build this if the product truly needs a trail rather than a position. @capacitor/geolocation is a foreground API; continuous tracking needs a plugin that owns a native service, e.g. @capacitor-community/background-geolocation.

iOS configuration

ios/App/App/Info.plist:

<key>UIBackgroundModes</key>
<array><string>location</string></array>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Shows your position on the job map.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Records your route while a shift is active so timesheets and mileage are accurate.</string>

Write those strings for a reviewer and a user, not for a linter. "Required for app functionality" is a rejection.

Android configuration

android/app/src/main/AndroidManifest.xml:

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />

Android 10+ requires background location to be granted in a second, separate prompt that only appears after foreground location is already allowed, and on Android 11+ the user must choose "Allow all the time" in Settings. Build the flow accordingly: request foreground, show your own explainer screen, then escalate.

The tracker service

import { Injectable } from '@angular/core';
import { BackgroundGeolocation } from '@capacitor-community/background-geolocation';
import { enqueue } from './sync/queue';

@Injectable({ providedIn: 'root' })
export class ShiftTracker {
  private watcherId?: string;

  async start(shiftId: string) {
    if (this.watcherId) { return; }

    this.watcherId = await BackgroundGeolocation.addWatcher(
      {
        backgroundMessage: 'Recording your route for this shift.',
        backgroundTitle: 'Shift in progress',
        requestPermissions: true,
        stale: false,
        distanceFilter: 25,        // metres: the single biggest battery lever
      },
      (location, error) => {
        if (error) {
          if (error.code === 'NOT_AUTHORIZED') { this.promptForSettings(); }
          return;
        }
        // Callback may fire while the WebView is frozen-ish: do no UI work here.
        void enqueue('location', {
          shiftId,
          lat: location!.latitude,
          lng: location!.longitude,
          accuracy: location!.accuracy,
          at: location!.time,
        });
      },
    );
  }

  async stop() {
    if (!this.watcherId) { return; }
    await BackgroundGeolocation.removeWatcher({ id: this.watcherId });
    this.watcherId = undefined;
  }

  private promptForSettings() { /* route to an in-app explainer + openSettings() */ }
}

Four things that make the difference between a shipped tracker and a support queue:

  • distanceFilter over time intervals. Distance-based updates cost a fraction of the battery of a 10-second timer and produce a cleaner trail.
  • Write, don't send. The callback enqueues locally. Network calls from a location callback fail on the exact bad networks where tracking matters most.
  • Bound the watcher to a real state. Start on "shift started", stop on "shift ended", and stop defensively on logout. A watcher that outlives its purpose is the #1 source of one-star battery reviews.
  • Tell the user it is on. A visible in-app banner plus the Android notification. Silent tracking is a trust and a compliance problem.

4. A sync queue that drains opportunistically

The queue is the piece that makes background work forgiving: capture locally, flush whenever the OS gives you a window. Store it in SQLite so it survives process death.

// sync/queue.ts
import { CapacitorSQLite } from '@capacitor-community/sqlite';

export async function enqueue(kind: string, payload: unknown) {
  await CapacitorSQLite.run({
    database: 'app',
    statement:
      'INSERT INTO outbox (id, kind, payload, created_at, attempts) VALUES (?,?,?,?,0)',
    values: [crypto.randomUUID(), kind, JSON.stringify(payload), Date.now()],
  });
}

export async function flushQueue({ maxItems = 50, timeoutMs = 25_000 } = {}) {
  const started = Date.now();
  const { values: rows = [] } = await CapacitorSQLite.query({
    database: 'app',
    statement: 'SELECT * FROM outbox ORDER BY created_at LIMIT ?',
    values: [maxItems],
  });

  for (const row of rows) {
    if (Date.now() - started > timeoutMs) { break; }   // leave the rest for next window
    try {
      const res = await fetch('/api/ingest', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'Idempotency-Key': row.id },
        body: row.payload,
      });
      if (res.ok || res.status === 409) {               // 409 = server already has it
        await remove(row.id);
      } else if (res.status >= 500) {
        await bumpAttempts(row.id);                     // retry later with backoff
      } else {
        await quarantine(row.id);                       // 4xx: bad payload, stop retrying
      }
    } catch {
      break;                                            // offline: stop, keep order
    }
  }
}

The Idempotency-Key header is not optional. Background flushes get killed mid-request routinely, so the same item will be delivered twice; the server must turn the duplicate into a no-op.

Then hook the flush to every window you can get:

import { Network } from '@capacitor/network';
import { App } from '@capacitor/app';

Network.addListener('networkStatusChange', s => { if (s.connected) { void flushQueue(); } });
App.addListener('appStateChange', ({ isActive }) => { if (isActive) { void flushQueue(); } });
// plus the beforeExit task from section 2, and a data-only push that wakes the app

A silent/data-only push is the most reliable "wake up and sync now" signal you have when the server knows something changed — cheaper and more predictable than any periodic scheduler.

5. Periodic background work, honestly

If you do want the device to poll on its own, use the platform scheduler via @capacitor/background-runner, which runs a small isolated JS file (not your Angular app) on a native schedule.

// capacitor.config.json
{
  "plugins": {
    "BackgroundRunner": {
      "label": "com.example.app.sync",
      "src": "runners/sync.js",
      "event": "syncOutbox",
      "repeat": true,
      "interval": 15,
      "autoStart": true
    }
  }
}
// runners/sync.js — plain JS, no DOM, no app bundle
addEventListener('syncOutbox', async (resolve, reject) => {
  try {
    const pending = await CapacitorKV.get('outboxCount');
    if (Number(pending?.value ?? 0) > 0) {
      await CapacitorNotifications.schedule([{ title: 'New jobs available', id: 1 }]);
    }
    resolve();
  } catch (e) { reject(e); }
});

Constraints to design around: the runner has its own tiny API surface (CapacitorKV, CapacitorNotifications, fetch) and no access to your app's services or DOM; interval: 15 is a floor, not a promise; and iOS will simply stop scheduling you if the user rarely opens the app. Treat it as a bonus path, never the only one.

6. Testing it, because simulators lie

  • iOS: Xcode → Debug → Simulate Background Fetch for the runner; for location use Features → Location → City Run on the simulator, then repeat on a real device with the app swiped into the background and the screen off.
  • Android: force a job with adb shell cmd jobscheduler run -f com.example.app 999, and simulate power saving with adb shell dumpsys deviceidle force-idle. Confirm your service survives it.
  • Both: test airplane-mode capture → 30 minutes backgrounded → reconnect, and verify the server received each item exactly once.
  • Battery: Android Vitals and iOS Xcode Energy gauges over a full shift. Anything above ~5% per hour of active tracking needs a bigger distanceFilter.

What to take away

Background execution in an Ionic app is not a plugin choice, it is an architecture: capture locally, declare a real user-visible reason for anything continuous, flush on every window the OS hands you, and make the server idempotent and authoritative about time. Do that and Capacitor gets you the same background behaviour as a native app, because it is using the same native APIs.

Building a tracking, field-service or offline-heavy Ionic app and want the background layer reviewed before it reaches the App Store? Get in touch — our senior Ionic consultants have shipped this pattern in production more than once.