All articles
Mobile DevelopmentOctober 2, 2026 7 min read

Background Tasks in React Native 2026: What Still Breaks on iOS and Android

Background execution is where cross-platform promises go to die. Here's what actually runs on iOS 18 and Android 15 in 2026, where Expo helps, and when you have to drop into Swift or Kotlin.

Background Tasks in React Native 2026: What Still Breaks on iOS and Android

Background execution is the quiet graveyard of cross-platform promises. A feature that works perfectly in the simulator will silently stop firing on a real device three days after install, and nobody will know until a user emails support. Here's what actually runs in React Native and Expo on iOS 18 and Android 15 in 2026 — and where you'll still end up writing Swift or Kotlin.

Why background work is the hardest part of mobile

Both platforms spent the last five years tightening the screws on what apps can do when the user isn't looking. iOS treats background CPU as a privilege the system grants based on usage patterns it won't document. Android's Doze, App Standby Buckets, and background execution limits mean a job you scheduled for "every 15 minutes" might fire once a day on a phone that's been in a pocket.

That matters for React Native because the JavaScript thread, by default, is not alive when your app is backgrounded. Anything that needs to run — a sync, a location fetch, a receipt validation — has to be scheduled through a native scheduler and either executed natively or wake up a headless JS context.

There are, broadly, four buckets of background work you'll want:

  • Periodic sync (fetch new data every N minutes/hours)
  • Deferred one-shot jobs (upload this video when wifi is back)
  • Location / geofencing (fire when the user enters a region)
  • Long-running foreground services (navigation, audio, fitness tracking)

Each has a different story on iOS vs Android, and a different story in Expo vs bare React Native.

Periodic sync: expo-background-task and its limits

Expo's old expo-background-fetch module is deprecated in favor of expo-background-task, which wraps BGTaskScheduler on iOS and WorkManager on Android. If you've been avoiding this upgrade because "it worked fine", upgrade anyway — the new API models the asymmetry between the platforms more honestly.

import * as BackgroundTask from 'expo-background-task';
import * as TaskManager from 'expo-task-manager';

const SYNC_TASK = 'app.sync.inbox';

TaskManager.defineTask(SYNC_TASK, async () => {
  try {
    const result = await syncInbox();
    return result.hadNewData
      ? BackgroundTask.BackgroundTaskResult.Success
      : BackgroundTask.BackgroundTaskResult.Success;
  } catch (e) {
    return BackgroundTask.BackgroundTaskResult.Failed;
  }
});

export async function registerSync() {
  await BackgroundTask.registerTaskAsync(SYNC_TASK, {
    minimumInterval: 15 * 60, // seconds — treated as a hint
  });
}

The word to internalize is hint. On iOS, BGAppRefreshTask fires when the OS decides your app has earned it, based on how often the user opens the app, battery state, network, and Low Power Mode. In our experience with mid-engagement consumer apps, this means somewhere between a few times a day and once every two days. On Android, WorkManager is more honest about honoring intervals, but Doze on an idle device will still batch and defer.

If your product requires freshness on a predictable cadence, periodic background tasks are not your answer. Silent push notifications are — more on that below.

When to use silent push instead

For "wake the app to pull new data" use cases, a silent push (content-available: 1 on iOS, data-only message on FCM) gives you a server-controlled trigger. It's not a guarantee — iOS throttles aggressively, and if the user force-quit your app from the switcher, nothing will wake it — but it's usually better than hoping the OS schedules you.

Pair silent push with a background task as a fallback. Don't rely on either one alone.

Deferred one-shot jobs: upload queues done right

This is where React Native developers most often reach for a hack and regret it. The pattern: user records a video, taps post, backgrounds the app before upload finishes. You want the upload to continue.

The only reliable answer on iOS is URLSession with a background configuration. fetch and axios will not survive backgrounding past the ~30 second grace period. On Android, you need either a WorkManager job with a Worker that does the upload in native code, or a foreground service with a persistent notification.

For React Native, this almost always means a native module. react-native-background-upload and its forks handle the plumbing, but check maintenance status before you commit — this is an area where libraries die quietly. We've shipped our own thin wrappers around URLSessionUploadTask and WorkManager more than once when the ecosystem options were stale.

If you're on Expo managed workflow, this is one of the first features that will push you toward a config plugin and a custom native module. That's fine — EAS Build makes it workable — but plan for it.

Geofencing and location: the one place Expo really shines

expo-location combined with expo-task-manager gives you one of the better cross-platform geofencing stories available. The task runs even when the app is terminated (within platform limits), and the API is the same on both sides.

import * as Location from 'expo-location';
import * as TaskManager from 'expo-task-manager';

const GEOFENCE_TASK = 'app.geofence';

TaskManager.defineTask(GEOFENCE_TASK, ({ data, error }) => {
  if (error) return;
  const { eventType, region } = data as any;
  if (eventType === Location.GeofencingEventType.Enter) {
    // enqueue a notification or sync
  }
});

await Location.startGeofencingAsync(GEOFENCE_TASK, [
  { identifier: 'office', latitude: 52.23, longitude: 21.01, radius: 150 },
]);

Caveats worth knowing before you ship:

  • iOS caps you at 20 active geofences per app. Design around it — swap regions as the user moves.
  • On Android 14+, you need ACCESS_BACKGROUND_LOCATION and a clear rationale, and Play review will scrutinize it. Have a privacy-policy page and an in-app disclosure ready.
  • Terminated-state wakeups are reliable on iOS, less so on aggressive OEM Android skins (Xiaomi, Huawei, some OnePlus). Users on those devices will need to whitelist your app in battery settings. There is no API fix for this.

Long-running foreground work: when you need a native module

Turn-by-turn navigation, workout tracking, audio playback, and VoIP all need genuine long-running execution. On iOS you declare the appropriate UIBackgroundModes; on Android you run a foreground service with a visible notification. Neither is something you can bolt on from JavaScript alone.

For audio, react-native-track-player is still the pragmatic choice. For fitness-style continuous location, you'll likely write a Kotlin foreground service and bridge it. For VoIP, use CallKit on iOS and ConnectionService on Android via a dedicated library — don't try to wing it.

The honest Swift/Kotlin tradeoff

If 80% of your background behavior is one specific long-running feature — say, a run tracker — writing the native pieces in Swift and Kotlin and treating React Native as the UI layer is often the lower-pain path. You get platform-idiomatic lifecycle handling and you stop fighting the JS bridge for a feature that doesn't need it. We've reviewed codebases where three generations of "background location libraries" were layered on top of each other; the cleanup was always to write ~200 lines of native code and delete the libraries.

That doesn't mean abandoning React Native. It means recognizing that background execution is one of the few places where native code is genuinely less work than cross-platform code.

Testing and the "it works on my device" trap

Background tasks will lie to you in development. Both platforms give you debug hooks that bypass the real scheduler:

  • iOS: e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"your.task.id"] from the LLDB console while paused.
  • Android: adb shell cmd jobscheduler run -f <package> <jobId> for WorkManager jobs.

Use these, but also test on a real device for at least a week with the app backgrounded, battery at various levels, and the device idle overnight. Instrument your task handlers with analytics events (task_fired, task_completed, task_failed) so you can see real-world firing rates in production. The gap between simulator behavior and a user's phone on day 14 is the whole story.

Also: force-quitting the app from the switcher kills most background behavior on both platforms. iOS treats this as "the user doesn't want this app running" and will stop scheduling background refresh entirely. There is nothing you can do about this. Document it for support.

Where we'd start

If you're adding background work to a React Native app today, we'd sequence it like this:

  1. Write down which bucket your feature falls into — periodic, deferred, location, or long-running. Don't skip this; it determines everything.
  2. For periodic freshness, lead with silent push and treat expo-background-task as a backup, not the primary mechanism.
  3. For uploads, budget for a native module from day one. Don't ship fetch in a setTimeout and hope.
  4. For geofencing, use expo-location + expo-task-manager, and build the OEM battery-whitelist conversation into your onboarding for Android power users.
  5. For anything long-running, write the native service in Swift or Kotlin and bridge it. You'll ship faster and sleep better.

And instrument everything. The only way to know if background work is actually happening in production is to count it. If you want a hand untangling an existing background mess, that's exactly the kind of work our mobile team does.

#React Native#Expo#iOS#Android#Background Tasks

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project