Every app eventually gets the ticket: "can we sync in the background?" Someone wants the inbox pre-fetched before the user opens the app, or a queue of offline edits flushed while the phone is in a pocket, or a location breadcrumb trail for a field-service crew. In React Native this is one of the few areas where the JavaScript layer cannot paper over the platform differences, because iOS and Android disagree about almost everything: who schedules the work, how long it may run, and whether it runs at all.
This tutorial covers what background execution actually looks like in a modern React Native app — the Expo-managed path, the bare-workflow path, and the operational reality of budgets and testing.
First, decide which kind of "background" you mean
The word covers four different mechanisms, and picking the wrong one is the most common mistake we see in code review.
- Backgrounded but alive. The app is in the app switcher and your JS thread is still running for a few seconds (iOS) or until the process is reaped (Android). Good for finishing an in-flight upload. Not a scheduler.
- Deferred periodic work. The OS wakes your app occasionally — maybe every few hours, maybe not today — to refresh content. iOS
BGAppRefreshTask, AndroidWorkManager. This is whatexpo-background-taskwraps. - Push-triggered work. The server decides when. Silent pushes (
content-available: 1on APNs, data-only messages on FCM) wake the app to do a short job. - Continuous foreground-service work. Location tracking, audio, navigation. On Android this is a foreground service with a persistent notification; on iOS it is a background mode entitlement. It requires store justification and should be a deliberate product decision, not a convenience.
Most "background sync" requests are really #2 or #3. Be honest with stakeholders early: neither one guarantees a schedule.
The managed path: expo-background-task
expo-background-task replaced expo-background-fetch as the recommended API. The important change is the implementation underneath: it uses WorkManager on Android and BGTaskScheduler on iOS, rather than the older BackgroundFetch APIs, which means it plays by the same rules as modern native code and survives reboots on Android.
Register the task at module scope — not inside a component — so it exists when the OS relaunches your app into the background and no React tree has mounted yet.
// tasks/syncTask.js (imported once from index.js / App entry)
import * as TaskManager from 'expo-task-manager';
import * as BackgroundTask from 'expo-background-task';
import { flushOutbox } from '../sync/outbox';
export const SYNC_TASK = 'outbox-sync';
TaskManager.defineTask(SYNC_TASK, async () => {
try {
const pushed = await flushOutbox({ budgetMs: 20_000 });
return pushed > 0
? BackgroundTask.BackgroundTaskResult.Success
: BackgroundTask.BackgroundTaskResult.Success;
} catch (e) {
// Report, then tell the OS it failed so it can back off.
reportError(e, { scope: 'background-sync' });
return BackgroundTask.BackgroundTaskResult.Failed;
}
});
export async function ensureSyncRegistered() {
const status = await BackgroundTask.getStatusAsync();
if (status === BackgroundTask.BackgroundTaskStatus.Restricted) return;
const already = await TaskManager.isTaskRegisteredAsync(SYNC_TASK);
if (!already) {
await BackgroundTask.registerTaskAsync(SYNC_TASK, {
minimumInterval: 60, // minutes — a floor, not a promise
});
}
}
Three things to internalize:
minimumIntervalis a minimum. iOS treats it as a hint and weights it by how often the user actually opens your app. A user who launches daily may get a refresh most days; a user who opened you once in March will effectively never be woken.- The task body must be idempotent. It may be killed mid-flight and re-run later with the same pending work.
- Give yourself an internal time budget (the
budgetMsabove) and return cleanly when it expires. iOS gives you roughly 30 seconds of wall clock before it kills the process and penalizes future scheduling.
If you are on a bare workflow with checked-in native folders, the config plugin still adds the iOS BGTaskSchedulerPermittedIdentifiers entry and the processing/fetch background modes — verify them in Info.plist after a prebuild, because a missing identifier fails silently rather than throwing.
The bare path: Headless JS and native schedulers
When you need more than "wake me sometimes," you drop to native.
On Android, Headless JS lets a native Service invoke a registered JS task with no UI attached:
// index.js
AppRegistry.registerHeadlessTask('GeofenceSync', () => require('./tasks/geofenceSync').default);
Pair it with a WorkManager worker or a BroadcastReceiver on the native side. The rule that bites teams: Headless JS tasks started from the background on Android 8+ must either be short-lived or be started from a foreground service, or the system throws IllegalStateException: Not allowed to start service. If the work is longer than a few seconds, use setForegroundAsync on the worker and ship a real notification.
On iOS, BGProcessingTask is the long-form sibling of BGAppRefreshTask — minutes rather than seconds, but only when the device is idle and usually charging. It is the right home for a database compaction or a big media re-encode, and the wrong home for anything the user expects to see immediately.
Silent pushes: the most reliable trigger you have
If the server knows when data changed, do not poll. Send a silent push and let it carry the work. On iOS that is content-available: 1 with apns-priority: 5; on Android a data-only FCM message. The rules of the road:
- iOS throttles silent pushes aggressively, and delivery is best-effort. Never use them for anything the user must see — that is what a visible notification is for.
- Android data-only messages are dropped into the app's own message handler; if the app was force-stopped by the user, nothing arrives until the user opens it again.
- Combine: silent push for the common case,
expo-background-taskas the safety net that catches whatever the pushes missed.
Our push tutorial covers the delivery side in detail; the pattern here is that background execution and push should be designed together, not bolted on separately.
Doze, App Standby, and the battery reality
Android's Doze mode batches deferred work into maintenance windows when the device is stationary and unplugged. App Standby buckets (active / working set / frequent / rare / restricted) then decide how often you get those windows. Several OEM skins — Xiaomi, Huawei, Samsung's aggressive battery optimizer — add their own layers on top and will kill background work entirely for apps the user has not opened recently.
The practical consequences for design:
- Never build a feature whose correctness depends on a background task running on time. Background work is an optimization; the foreground path must always be able to catch up on launch.
- Batch aggressively. One wake that does ten things costs far less battery than ten wakes.
- Respect constraints: require unmetered network for large syncs and require charging for anything expensive. Users notice battery drain long before they notice a stale cache.
Testing it without waiting three days
You cannot ship this by hoping. Force the triggers:
- iOS, debug build: pause in the debugger after backgrounding and call the private scheduler hook:
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"your.task.id"] - Expo:
BackgroundTask.triggerTaskWorkerForTestingAsync()in development builds fires the registered task immediately. - Android:
adb shell cmd jobscheduler run -f <package> <jobId>, and simulate Doze withadb shell dumpsys deviceidle force-idle. - Always: add a persistent log line (task name, start time, duration, items processed, outcome) and ship it to your crash/analytics backend. Background failures are invisible by definition, so the only way you will ever know the feature works in the field is a dashboard of task completion rates by OS version.
A checklist before you call it done
- The task is registered at module scope and survives a cold background launch.
- The handler is idempotent and enforces its own time budget.
- Failures return the "failed" result so the OS backs off instead of hammering a broken endpoint.
- Constraints (network type, charging) match the cost of the work.
- The foreground path can reconcile everything the background path missed.
- Store metadata justifies every background mode and entitlement you declared — reviewers do ask.
- Task outcomes are logged and monitored per platform and OS version.
Background execution is one of the places where React Native leaks the platform through, and that is fine — the leak is where the engineering judgment lives. If your team is designing a sync layer and wants a second opinion on where the work should run, get in touch.