React Native Background Tasks with Expo: A Complete Guide

Learn how to set up expo-background-task in React Native for data syncing, OTA updates, and background location tracking. Covers platform gotchas, testing strategies, and migration from expo-background-fetch.

Running code while your app is in the background is, honestly, one of the trickiest challenges in mobile development. Both iOS and Android aggressively limit what apps can do when they're not in the foreground — and the rules seem to change with every OS update. Get it wrong, and the operating system throttles your app, drains the user's battery, or just silently kills your tasks altogether.

Expo introduced expo-background-task in SDK 53 as a modern, battery-friendly replacement for the now-deprecated expo-background-fetch. It's built on top of Android's WorkManager and iOS's BGTaskScheduler, giving you a single JavaScript API to schedule deferrable work that survives app backgrounding, system restarts, and even device reboots.

This guide walks through everything you need to set up, run, test, and debug background tasks in your Expo project. We'll cover real-world examples like data syncing, OTA update pre-fetching, and background location tracking — plus all the platform gotchas that'll save you hours of debugging.

Why Background Tasks Matter in Mobile Apps

Users expect mobile apps to feel instantly ready. When they open a news reader, the latest headlines should already be there. When they launch a fitness tracker, yesterday's workout data should already be synced. Background tasks make this possible by doing work while the app isn't visible:

  • Data synchronization — Push local changes to your server and pull fresh data so the app's always up to date.
  • OTA update pre-fetching — Download Expo Updates in the background so the next launch loads the latest version instantly.
  • Content caching — Pre-fetch images, articles, or API responses for offline-first experiences.
  • Cleanup jobs — Clear stale caches, rotate logs, or compact local databases during idle periods.
  • Location tracking — Record GPS coordinates for delivery, fitness, or navigation apps even when the screen is off.

Without background tasks, all of this work has to happen the moment the user opens the app. That means loading spinners and a sluggish first impression — not great.

expo-background-fetch vs expo-background-task: What Changed

Before SDK 53, the recommended approach was expo-background-fetch. It worked, but it was built on aging platform APIs with some real limitations:

Feature expo-background-fetch (deprecated) expo-background-task (current)
iOS API Background Fetch (deprecated since iOS 13) BGTaskScheduler
Android API JobScheduler + AlarmManager WorkManager
Survives app termination No Yes
Survives device reboot No Yes
Network requirement Manual check needed Built-in — tasks only run when network is available
Status Deprecated, no patches Actively maintained

The good news? Migration is straightforward because the APIs are nearly identical. The main differences are the import name and some minor tweaks to the options you pass to registerTaskAsync.

Installation and Configuration

Installing the Packages

You need two packages: expo-background-task for scheduling and expo-task-manager for defining tasks.

npx expo install expo-background-task expo-task-manager

iOS Configuration

If you're using Continuous Native Generation (CNG) with npx expo prebuild, the required Info.plist entries are added automatically. Nice. For manual setups, add these to your Info.plist:

<key>UIBackgroundModes</key>
<array>
  <string>processing</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
  <string>com.expo.modules.backgroundtask.processing</string>
</array>

One thing to note: expo-background-fetch used the fetch background mode, while expo-background-task uses processing. This is an important distinction if you're migrating.

Android Configuration

No additional configuration needed on Android. The WorkManager dependency gets included automatically when you install the package. One less thing to worry about.

Core Concepts: How Background Tasks Work

The Task Lifecycle

Background tasks in Expo follow a three-step pattern:

  1. Define the task in the global scope using TaskManager.defineTask().
  2. Register the task with a scheduling interval using BackgroundTask.registerTaskAsync().
  3. Unregister the task when it's no longer needed using BackgroundTask.unregisterTaskAsync().

Here's the critical rule: defineTask must be called at the top level of your JavaScript bundle — outside of any React component, hook, or lifecycle method. When the OS wakes your app in the background, it spins up the JavaScript runtime, executes the task function, and shuts down. No React views are mounted during this process.

This trips up a lot of people at first.

Platform Scheduling Behavior

Both platforms treat the minimumInterval option as a suggestion, not a guarantee:

  • Android (WorkManager) — Honors the minimum interval fairly reliably, but actual execution time depends on battery state, network availability, and Doze mode. The minimum allowed value is 15 minutes.
  • iOS (BGTaskScheduler) — Much more opaque. The system factors in battery level, Wi-Fi connectivity, and even the user's app usage patterns. Short intervals are often ignored — iOS typically runs background tasks during overnight charging windows, which can be frustrating during development.

The default interval if you don't specify one is 12 hours.

Single Worker, Sequential Execution

All registered JavaScript tasks run through a single underlying native worker on both platforms. If you register multiple tasks, they execute sequentially — not in parallel. The most recently registered task's minimumInterval governs when the worker fires.

Building Your First Background Task

Alright, let's get to the code. Here's a complete, working example that syncs data from a local store to a remote API every time the OS allows the background task to run.

Step 1: Define the Task

Create a file at the top level of your project (for example, tasks/backgroundSync.ts) and define the task there:

// tasks/backgroundSync.ts
import * as BackgroundTask from "expo-background-task";
import * as TaskManager from "expo-task-manager";
import AsyncStorage from "@react-native-async-storage/async-storage";

export const SYNC_TASK_NAME = "background-data-sync";

TaskManager.defineTask(SYNC_TASK_NAME, async () => {
  try {
    // Read pending changes from local storage
    const pendingChanges = await AsyncStorage.getItem("pendingChanges");
    if (!pendingChanges) {
      return BackgroundTask.BackgroundTaskResult.Success;
    }

    // Push changes to your API
    const response = await fetch("https://api.example.com/sync", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: pendingChanges,
    });

    if (response.ok) {
      await AsyncStorage.removeItem("pendingChanges");
      console.log("Background sync completed successfully");
      return BackgroundTask.BackgroundTaskResult.Success;
    }

    return BackgroundTask.BackgroundTaskResult.Failed;
  } catch (error) {
    console.error("Background sync failed:", error);
    return BackgroundTask.BackgroundTaskResult.Failed;
  }
});

Step 2: Import the Task and Register It

Import the task file in your app's entry point so it runs at the top level. Then register it inside a React component or hook:

// app/_layout.tsx
import "../tasks/backgroundSync"; // Ensures defineTask runs at module load
import * as BackgroundTask from "expo-background-task";
import { SYNC_TASK_NAME } from "../tasks/backgroundSync";
import { useEffect } from "react";
import { Stack } from "expo-router";

export default function RootLayout() {
  useEffect(() => {
    async function register() {
      const status = await BackgroundTask.getStatusAsync();
      if (status === BackgroundTask.BackgroundTaskStatus.Available) {
        await BackgroundTask.registerTaskAsync(SYNC_TASK_NAME, {
          minimumInterval: 60, // Run at most every 60 minutes
        });
        console.log("Background sync task registered");
      } else {
        console.warn("Background tasks are restricted on this device");
      }
    }
    register();
  }, []);

  return <Stack />;
}

Step 3: Unregister on Logout

If your app has user sessions, you'll want to unregister background tasks when the user signs out. Running tasks with stale credentials is a recipe for weird bugs:

import * as BackgroundTask from "expo-background-task";
import { SYNC_TASK_NAME } from "../tasks/backgroundSync";

async function handleLogout() {
  await BackgroundTask.unregisterTaskAsync(SYNC_TASK_NAME);
  // ... clear session, navigate to login
}

Real-World Example: Pre-Fetching Expo Updates

One of the most practical uses for background tasks is downloading Expo OTA updates before the user opens the app. This completely eliminates the update-then-reload delay that users find annoying.

// tasks/updateChecker.ts
import * as BackgroundTask from "expo-background-task";
import * as TaskManager from "expo-task-manager";
import * as Updates from "expo-updates";

export const UPDATE_TASK_NAME = "background-update-check";

TaskManager.defineTask(UPDATE_TASK_NAME, async () => {
  try {
    const update = await Updates.checkForUpdateAsync();
    if (update.isAvailable) {
      await Updates.fetchUpdateAsync();
      // The update is now cached locally.
      // It will be applied on the next app launch.
      console.log("Update fetched in background");
    }
    return BackgroundTask.BackgroundTaskResult.Success;
  } catch (error) {
    console.error("Background update check failed:", error);
    return BackgroundTask.BackgroundTaskResult.Failed;
  }
});

Register this task with a 12-hour interval and your users will almost always launch into the latest version without ever seeing an update screen. In my experience, this alone makes background tasks worth the setup effort.

Background Location Tracking with expo-location

Location tracking is a different beast entirely. Instead of periodic deferrable tasks, it requires continuous (or near-continuous) execution. Expo handles this through expo-location combined with expo-task-manager, using a foreground service on Android.

Permissions Setup

Location permissions must be requested in a specific order: foreground first, then background. You can't request background location permission without having foreground permission granted first — the OS will just deny it.

import * as Location from "expo-location";

async function requestLocationPermissions() {
  // Step 1: Request foreground permission
  const { status: foreground } =
    await Location.requestForegroundPermissionsAsync();
  if (foreground !== "granted") {
    console.warn("Foreground location permission denied");
    return false;
  }

  // Step 2: Request background permission
  const { status: background } =
    await Location.requestBackgroundPermissionsAsync();
  if (background !== "granted") {
    console.warn("Background location permission denied");
    return false;
  }

  return true;
}

Defining and Starting Location Updates

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

const LOCATION_TASK_NAME = "background-location-tracking";

// Define the task at the top level
TaskManager.defineTask(LOCATION_TASK_NAME, ({ data, error }) => {
  if (error) {
    console.error("Location task error:", error.message);
    return;
  }
  if (data) {
    const { locations } = data as { locations: Location.LocationObject[] };
    const latest = locations[0];
    console.log("New location:", latest.coords.latitude, latest.coords.longitude);
    // Save to local DB or send to server
  }
});

// Start tracking (call this after permissions are granted)
async function startBackgroundLocation() {
  await Location.startLocationUpdatesAsync(LOCATION_TASK_NAME, {
    accuracy: Location.Accuracy.Balanced,
    timeInterval: 10000,       // Update every 10 seconds
    distanceInterval: 20,      // Or every 20 meters
    deferredUpdatesInterval: 60000, // Batch updates every 60 seconds
    showsBackgroundLocationIndicator: true, // iOS blue bar
    foregroundService: {
      notificationTitle: "Tracking your route",
      notificationBody: "Location is being recorded in the background",
      notificationColor: "#4A90D9",
    },
  });
}

Battery Optimization for Location Tracking

Background location tracking is the single biggest battery drain in mobile development. Seriously. Use these settings to reduce power consumption significantly:

  • Use Accuracy.Balanced instead of Accuracy.Highest unless you genuinely need sub-meter precision.
  • Increase timeInterval to 10 seconds or more.
  • Set deferredUpdatesDistance to batch location updates and reduce wake-ups.
  • On Android, use a visible foreground service notification — it prevents the system from killing your process.

App Configuration for Background Location

Add the required permissions to your app.json:

{
  "expo": {
    "ios": {
      "infoPlist": {
        "UIBackgroundModes": ["location"]
      }
    },
    "android": {
      "permissions": [
        "ACCESS_FINE_LOCATION",
        "ACCESS_COARSE_LOCATION",
        "ACCESS_BACKGROUND_LOCATION",
        "FOREGROUND_SERVICE",
        "FOREGROUND_SERVICE_LOCATION"
      ]
    }
  }
}

A heads up: on Android, Google Play requires you to submit a declaration explaining why your app needs background location access. Apps without a valid justification get rejected, so plan for this during your submission process.

Platform-Specific Behavior and Gotchas

Understanding how each platform handles background execution is essential. I've spent more time debugging platform-specific issues than I'd like to admit, so here's what you need to know.

iOS Gotchas

  • BGTaskScheduler doesn't work on simulators. You must test on a physical device. The triggerTaskWorkerForTestingAsync() method only works in development builds.
  • iOS aggressively throttles background tasks. Even with a 15-minute minimum interval, the system may only run your task a few times per day — often during overnight charging. Don't panic if it seems like nothing's happening.
  • 30-second execution limit. Your task function has to complete within 30 seconds on iOS, or the system terminates it. Use the addExpirationListener callback to save state if you're about to get interrupted.
  • User kill stops everything. If a user force-quits your app from the app switcher, background tasks won't run until the user manually opens the app again. There's no workaround for this.

Android Gotchas

  • Doze mode and App Standby. On Android 6.0+, Doze mode defers background work when the device is idle. WorkManager handles this gracefully, but your tasks may be delayed longer than you'd expect.
  • Vendor-specific battery optimizations. This is the big one. Manufacturers like Samsung, Xiaomi, Huawei, and OnePlus add their own aggressive battery optimizations on top of stock Android. These can kill background processes even when WorkManager is in play. There's no universal workaround — you may need to guide users to disable battery optimization for your app.
  • App removal from recents. On some Android devices, swiping your app away from the recent apps list terminates it. On others, it doesn't. This behavior is vendor-specific and can't be controlled programmatically.

Handling the Expiration Listener (iOS)

iOS can interrupt your background task at any time. Use the expiration listener to handle this gracefully:

import * as BackgroundTask from "expo-background-task";

const subscription = BackgroundTask.addExpirationListener(() => {
  console.warn("Background task is about to be terminated by iOS");
  // Save partial progress, close database connections, etc.
});

// Clean up when no longer needed
subscription.remove();

Testing Background Tasks

Testing background tasks is notoriously difficult because the OS controls when they run. Here are the strategies that actually work.

Development Build Testing

Expo provides a built-in method to trigger all registered background tasks manually:

import * as BackgroundTask from "expo-background-task";

// Add a button in dev mode to test your task
async function triggerTestTask() {
  const result = await BackgroundTask.triggerTaskWorkerForTestingAsync();
  console.log("Task triggered:", result);
}

// In your component (only show in development)
{__DEV__ && (
  <Button title="Trigger Background Task" onPress={triggerTestTask} />
)}

This only works in debug/development builds — it won't function in production.

Physical Device Testing

Background tasks on iOS absolutely require a physical device — they don't work on simulators at all. Build a development build using EAS:

npx eas build --profile development --platform ios

Install it on your device, then use the trigger function above to verify your task logic. For end-to-end testing of actual OS scheduling, background the app and wait. Yes, this part involves a lot of waiting around.

Verifying Task Registration

You can check whether a specific task is currently registered:

import * as TaskManager from "expo-task-manager";

const isRegistered = await TaskManager.isTaskRegisteredAsync(SYNC_TASK_NAME);
console.log("Task registered:", isRegistered);

// List all registered tasks
const tasks = await TaskManager.getRegisteredTasksAsync();
console.log("All registered tasks:", tasks);

Migrating from expo-background-fetch

If your project currently uses expo-background-fetch, migration is pretty straightforward. The API surface is nearly identical — here's a side-by-side comparison.

Before (expo-background-fetch)

import * as BackgroundFetch from "expo-background-fetch";
import * as TaskManager from "expo-task-manager";

TaskManager.defineTask("my-task", async () => {
  // ... your logic
  return BackgroundFetch.BackgroundFetchResult.NewData;
});

await BackgroundFetch.registerTaskAsync("my-task", {
  minimumInterval: 15 * 60, // seconds
  stopOnTerminate: false,
  startOnBoot: true,
});

After (expo-background-task)

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

TaskManager.defineTask("my-task", async () => {
  // ... your logic (unchanged)
  return BackgroundTask.BackgroundTaskResult.Success;
});

await BackgroundTask.registerTaskAsync("my-task", {
  minimumInterval: 15, // minutes (not seconds!)
});

Key differences to watch for:

  • The minimumInterval is now in minutes, not seconds. This is the most common migration mistake — don't multiply by 60 out of habit.
  • The result enum changed from BackgroundFetchResult.NewData to BackgroundTaskResult.Success.
  • The stopOnTerminate and startOnBoot options are gone — the new API handles this automatically.
  • Update the iOS UIBackgroundModes from fetch to processing.

Best Practices for Background Tasks

Keep Tasks Light and Fast

iOS gives you 30 seconds. Android is more generous, but heavy tasks drain battery and invite OS throttling. Fetch only what changed, use delta syncing, and defer heavy processing to when the app is in the foreground.

Use Persistent Queues for Reliability

If a background task fails — network issues, server errors, time limits — you need a way to retry. Store pending operations in a persistent queue (SQLite, WatermelonDB, or even AsyncStorage). Your task function should read from the queue, attempt the work, and remove completed items. Simple, but effective.

Check Task Status Before Registering

Always check getStatusAsync() before registering a task. Some devices have background tasks restricted by the user or system policy:

const status = await BackgroundTask.getStatusAsync();
if (status === BackgroundTask.BackgroundTaskStatus.Restricted) {
  // Inform the user that background sync is not available
  // Suggest enabling it in device settings
}

Don't Assume Execution Timing

Never build features that depend on a background task running at a precise time. The OS may delay your task by hours, or skip it entirely if the device is in a power-saving state. Design your app to work correctly even if the background task never runs — treat it as an optimization, not a requirement.

Respect User Privacy

Be transparent about what your app does in the background. If you collect location data or sync personal information, disclose this clearly in your app's privacy policy. On both app stores, unexpected background activity can lead to rejections or removal.

Frequently Asked Questions

Can I run a background task every minute in React Native?

No. Both iOS and Android enforce a minimum interval of 15 minutes for deferrable background tasks. iOS often delays tasks even further, sometimes only running them a few times per day during optimal conditions (like overnight charging). If you need more frequent updates, look into a foreground service with expo-location or react-native-background-actions, but be aware of the significant battery impact.

Do background tasks work in Expo Go?

Nope. Background task APIs require native code that isn't included in Expo Go. You'll need to create a development build using npx eas build --profile development to test background tasks on a physical device. And remember — iOS background tasks don't work on simulators at all.

What happens to background tasks when the user force-quits the app?

On iOS, force-quitting from the app switcher completely stops all background tasks until the user manually opens the app again. On Android, it's vendor-specific — some implementations treat swiping from recents as a full kill, while others keep WorkManager tasks alive. In both cases, tasks will resume when the user reopens the app.

How do I debug a background task that isn't running?

Start by verifying the task is registered using TaskManager.isTaskRegisteredAsync(). Then check that background tasks are available with BackgroundTask.getStatusAsync(). On Android, make sure battery optimization isn't restricting your app. On iOS, remember that BGTaskScheduler doesn't work on simulators — grab a physical device. Finally, use triggerTaskWorkerForTestingAsync() in a development build to confirm your task logic works independently of OS scheduling.

Should I migrate from expo-background-fetch to expo-background-task?

Absolutely. expo-background-fetch is officially deprecated and no longer receives patches. It'll be removed in an upcoming Expo SDK release. The migration is straightforward since the APIs are nearly identical — the main changes are the import name, the interval unit switching from seconds to minutes, and the result enum values. Migrating now means your app uses stable, modern platform APIs and gains automatic support for surviving app termination and device reboots. There's really no reason to wait on this one.

About the Author Editorial Team

Our team of expert writers and editors.