React Native Background Tasks: expo-task-manager, Background Fetch, and Push Notifications
Implement background tasks in React Native with Expo. Covers expo-task-manager for background fetch and location, background push notification handling, iOS background modes, Android foreground services, and battery-efficient scheduling.
Background tasks in React Native are a platform minefield. iOS severely restricts what apps can do in the background. Android gives more freedom but requires a Foreground Service for anything meaningful. Expo's expo-task-manager abstracts the worst platform differences, but you still need to understand what each platform allows and how to stay within battery budget.
This guide covers the patterns that actually work in production: background fetch for data sync, background location, push-triggered background processing, and the constraints you need to design around.
Platform Constraints
iOS background limitations:
- Apps are suspended after 30 seconds in the background
- Background Fetch is iOS-controlled โ you request a minimum interval but iOS decides when (typically 15โ30 min)
- Background push notifications give ~30 seconds of processing time
- Background Location requires explicit user permission and drains battery fast
- No persistent background processes โ iOS kills apps that try
Android background limitations:
- Doze mode restricts CPU, network, and GPS when the device is idle
- Background execution limits (Android 8+): apps cannot start background services unless the device is in a "foreground state"
- Foreground Services (with visible notification) run indefinitely
- WorkManager is the recommended approach for deferrable work
Installation
npx expo install expo-task-manager expo-background-fetch expo-location expo-notifications
For bare React Native:
npm install expo-modules-core expo-task-manager expo-background-fetch
npx pod-install
๐ SaaS MVP in 8 Weeks โ Seriously
We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment โ all handled by one senior team.
- Week 1โ2: Architecture design + wireframes
- Week 3โ6: Core features built + tested
- Week 7โ8: Launch-ready on AWS/Vercel with CI/CD
- Post-launch: Maintenance plans from month 3
iOS Configuration
<!-- ios/YourApp/Info.plist -->
<key>UIBackgroundModes</key>
<array>
<string>fetch</string> <!-- Background App Refresh -->
<string>remote-notification</string> <!-- Silent push notifications -->
<string>location</string> <!-- Background location (only if needed) -->
<string>processing</string> <!-- BGTaskScheduler (iOS 13+) -->
</array>
Background Fetch: Data Sync
// tasks/background-sync.ts
import * as BackgroundFetch from "expo-background-fetch";
import * as TaskManager from "expo-task-manager";
import { syncPendingData } from "@/lib/sync";
export const BACKGROUND_SYNC_TASK = "background-data-sync";
// Register task handler BEFORE any component mounts
// (Must be at module level, not inside a component)
TaskManager.defineTask(BACKGROUND_SYNC_TASK, async () => {
console.log("[BackgroundFetch] Task started at:", new Date().toISOString());
try {
const result = await syncPendingData();
console.log("[BackgroundFetch] Synced:", result.itemsSynced, "items");
return result.itemsSynced > 0
? BackgroundFetch.BackgroundFetchResult.NewData
: BackgroundFetch.BackgroundFetchResult.NoData;
} catch (error) {
console.error("[BackgroundFetch] Failed:", error);
return BackgroundFetch.BackgroundFetchResult.Failed;
}
});
export async function registerBackgroundSync(): Promise<void> {
const status = await BackgroundFetch.getStatusAsync();
if (status === BackgroundFetch.BackgroundFetchStatus.Restricted
|| status === BackgroundFetch.BackgroundFetchStatus.Denied) {
console.warn("[BackgroundFetch] Not available:", status);
return;
}
await BackgroundFetch.registerTaskAsync(BACKGROUND_SYNC_TASK, {
minimumInterval: 15 * 60, // Request 15 minutes minimum (iOS decides actual interval)
stopOnTerminate: false, // Continue after app killed
startOnBoot: true, // Android: start after reboot
});
console.log("[BackgroundFetch] Registered");
}
export async function unregisterBackgroundSync(): Promise<void> {
const isRegistered = await TaskManager.isTaskRegisteredAsync(BACKGROUND_SYNC_TASK);
if (isRegistered) {
await BackgroundFetch.unregisterTaskAsync(BACKGROUND_SYNC_TASK);
}
}
// Register in app root โ call once at startup
// app/_layout.tsx or App.tsx
import { useEffect } from "react";
import { registerBackgroundSync } from "@/tasks/background-sync";
export default function RootLayout() {
useEffect(() => {
registerBackgroundSync().catch(console.error);
}, []);
// ...
}
๐ก The Difference Between a SaaS Demo and a SaaS Business
Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments โ with architecture that does not need to be rewritten at 1,000 users.
- Multi-tenant PostgreSQL with row-level security
- Stripe subscriptions, usage billing, annual plans
- SOC2-ready infrastructure from day one
- We own zero equity โ you own everything
Silent Push Notifications for Background Triggers
iOS silent push (content-available: 1) wakes your app for ~30 seconds. Use this to trigger a sync when your server has new data:
Server: Send Silent Push
// lib/notifications/send-silent-push.ts
import Expo from "expo-server-sdk";
const expo = new Expo();
export async function sendSilentPush(
expoPushToken: string,
data: Record<string, string>
): Promise<void> {
if (!Expo.isExpoPushToken(expoPushToken)) {
throw new Error(`Invalid Expo push token: ${expoPushToken}`);
}
const message = {
to: expoPushToken,
// Silent push โ no sound, no badge, no visible notification
title: undefined,
body: undefined,
data,
// iOS: triggers content-available background wake
_contentAvailable: true,
priority: "normal" as const, // "high" would drain battery
};
const [ticket] = await expo.sendPushNotificationsAsync([message]);
if (ticket.status === "error") {
throw new Error(`Push failed: ${ticket.message}`);
}
}
// Usage: trigger background sync when new order arrives
await sendSilentPush(user.expoPushToken, {
type: "sync_trigger",
resource: "orders",
since: new Date().toISOString(),
});
Client: Handle Silent Push in Background
// tasks/notification-handler.ts
import * as Notifications from "expo-notifications";
import * as TaskManager from "expo-task-manager";
import { syncOrders } from "@/lib/sync/orders";
export const NOTIFICATION_TASK = "background-notification-handler";
// Handle notifications when app is in background / killed
TaskManager.defineTask(NOTIFICATION_TASK, async ({ data, error }: any) => {
if (error) {
console.error("[NotificationTask] Error:", error);
return;
}
const notification = data?.notification as Notifications.Notification;
const notifData = notification?.request?.content?.data;
if (!notifData?.type) return;
switch (notifData.type) {
case "sync_trigger":
await syncOrders({ since: notifData.since as string });
break;
case "invalidate_cache":
// Clear local cache
break;
}
});
// Register the background notification handler
export async function registerNotificationHandler(): Promise<void> {
await Notifications.registerTaskAsync(NOTIFICATION_TASK);
}
Background Location Tracking
Only use for apps where location tracking is the core feature (delivery tracking, field service). Requires explicit user permission and disclosure.
// tasks/location-tracking.ts
import * as Location from "expo-location";
import * as TaskManager from "expo-task-manager";
import { uploadLocationBatch } from "@/lib/location";
export const LOCATION_TASK = "background-location";
TaskManager.defineTask(LOCATION_TASK, async ({ data, error }: any) => {
if (error) {
console.error("[LocationTask] Error:", error.message);
return;
}
if (data) {
const { locations } = data as { locations: Location.LocationObject[] };
console.log(`[LocationTask] ${locations.length} locations received`);
await uploadLocationBatch(
locations.map((loc) => ({
latitude: loc.coords.latitude,
longitude: loc.coords.longitude,
accuracy: loc.coords.accuracy ?? undefined,
altitude: loc.coords.altitude ?? undefined,
timestamp: new Date(loc.timestamp),
}))
);
}
});
export async function startLocationTracking(): Promise<void> {
const { status: fg } = await Location.requestForegroundPermissionsAsync();
if (fg !== "granted") {
throw new Error("Foreground location permission required");
}
const { status: bg } = await Location.requestBackgroundPermissionsAsync();
if (bg !== "granted") {
throw new Error("Background location permission required");
}
await Location.startLocationUpdatesAsync(LOCATION_TASK, {
accuracy: Location.Accuracy.Balanced,
distanceInterval: 50, // Update every 50 meters (battery friendly)
deferredUpdatesInterval: 60000, // Batch updates every 60 seconds
deferredUpdatesDistance: 100,
showsBackgroundLocationIndicator: true, // iOS: blue bar in status bar
foregroundService: { // Android: required for background location
notificationTitle: "Location Active",
notificationBody: "Tracking your delivery route",
notificationColor: "#2563eb",
},
});
}
export async function stopLocationTracking(): Promise<void> {
const isRegistered = await TaskManager.isTaskRegisteredAsync(LOCATION_TASK);
if (isRegistered) {
await Location.stopLocationUpdatesAsync(LOCATION_TASK);
}
}
Visible Push Notifications with Action Buttons
// lib/notifications/setup.ts
import * as Notifications from "expo-notifications";
import { router } from "expo-router";
// Configure notification appearance (foreground)
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});
// Register action categories (iOS) โ must be set before requesting permissions
export async function setupNotificationCategories(): Promise<void> {
await Notifications.setNotificationCategoryAsync("order_update", [
{
identifier: "view_order",
buttonTitle: "View Order",
options: { opensAppToForeground: true },
},
{
identifier: "dismiss",
buttonTitle: "Dismiss",
options: { opensAppToForeground: false, isDestructive: false },
},
]);
}
export async function registerForPushNotifications(): Promise<string | null> {
const { status: existing } = await Notifications.getPermissionsAsync();
let finalStatus = existing;
if (existing !== "granted") {
const { status } = await Notifications.requestPermissionsAsync({
ios: {
allowAlert: true,
allowBadge: true,
allowSound: true,
},
});
finalStatus = status;
}
if (finalStatus !== "granted") return null;
// Get Expo push token
const tokenData = await Notifications.getExpoPushTokenAsync({
projectId: process.env.EXPO_PROJECT_ID!,
});
return tokenData.data;
}
// hooks/use-push-notifications.ts
import { useEffect, useRef } from "react";
import * as Notifications from "expo-notifications";
import { router } from "expo-router";
import { registerForPushNotifications } from "@/lib/notifications/setup";
import { savePushToken } from "@/lib/api/user";
export function usePushNotifications() {
const notificationListener = useRef<Notifications.Subscription>();
const responseListener = useRef<Notifications.Subscription>();
useEffect(() => {
// Register and save token
registerForPushNotifications().then((token) => {
if (token) savePushToken(token);
});
// Listen for incoming notifications (foreground)
notificationListener.current = Notifications.addNotificationReceivedListener(
(notification) => {
console.log("Notification received in foreground:", notification);
}
);
// Listen for user tapping notification
responseListener.current = Notifications.addNotificationResponseReceivedListener(
(response) => {
const data = response.notification.request.content.data;
const actionId = response.actionIdentifier;
if (actionId === "view_order" && data?.orderId) {
router.push(`/orders/${data.orderId}`);
} else if (
actionId === Notifications.DEFAULT_ACTION_IDENTIFIER &&
data?.screen
) {
router.push(data.screen as string);
}
}
);
return () => {
notificationListener.current?.remove();
responseListener.current?.remove();
};
}, []);
}
Android: Foreground Service for Long Tasks
For Android tasks that need more than a few seconds (e.g., large file download, continuous sync):
// tasks/foreground-service.ts
import * as TaskManager from "expo-task-manager";
import * as Notifications from "expo-notifications";
export const FOREGROUND_SERVICE_TASK = "long-running-sync";
TaskManager.defineTask(FOREGROUND_SERVICE_TASK, async () => {
// This runs as an Android Foreground Service
// The notification keeps it alive
await performLongRunningSync();
});
export async function startForegroundService(): Promise<void> {
// Android only: start as foreground service with sticky notification
await Notifications.startActivityAsync(
Notifications.ActivityType.FOREGROUND_SERVICE,
{
taskName: FOREGROUND_SERVICE_TASK,
taskTitle: "Syncing data",
taskDesc: "Please wait while your data syncs...",
taskIcon: { name: "ic_launcher", type: "mipmap" },
color: "#2563eb",
parameters: { delay: 1000 },
}
);
}
Battery-Efficient Scheduling
// lib/tasks/scheduler.ts
import { AppState, Platform } from "react-native";
import NetInfo from "@react-native-community/netinfo";
export async function shouldRunBackgroundTask(): Promise<boolean> {
// Only sync on WiFi to avoid mobile data costs
const netInfo = await NetInfo.fetch();
if (!netInfo.isConnected) return false;
if (Platform.OS === "ios") {
// iOS: trust the OS scheduler; don't add extra logic
return true;
}
// Android: check if on WiFi for large syncs
if (netInfo.type === "wifi") return true;
// Allow on mobile data for small payloads
return true;
}
// Adaptive sync frequency based on user activity
export function getAdaptiveSyncInterval(
lastUserActivityAt: Date
): number {
const minutesSinceActivity =
(Date.now() - lastUserActivityAt.getTime()) / (1000 * 60);
if (minutesSinceActivity < 30) return 5 * 60; // Active: 5 min
if (minutesSinceActivity < 120) return 15 * 60; // Semi-active: 15 min
return 60 * 60; // Idle: 1 hour
}
Cost and Timeline Estimates
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| Basic push notifications (no background) | 1 dev | 1โ2 days | $400โ800 |
| Background fetch + silent push sync | 1 dev | 3โ5 days | $1,000โ2,000 |
| Background location tracking | 1 dev | 1 week | $1,500โ3,000 |
| Full system (fetch + location + foreground service) | 1โ2 devs | 2โ3 weeks | $5,000โ10,000 |
See Also
- React Native Push Notifications with Expo
- React Native Camera Integration
- React Native Maps and Location
- React Native Gesture Handler
- React Native Payments with Stripe
Working With Viprasol
Background tasks are one of the most frustrating areas of mobile development โ the platform constraints are strict, the failure modes are silent, and testing is painful. Our team has shipped background sync, location tracking, and silent push triggers in production React Native apps for logistics, field service, and SaaS products.
What we deliver:
- expo-task-manager setup for background fetch and location
- Silent push notification flow (server sends, client wakes and syncs)
- iOS background mode configuration (Info.plist)
- Android Foreground Service for long-running tasks
- Battery-efficient scheduling with adaptive intervals
Talk to our team about your mobile background task requirements โ
Or explore our web and mobile development services.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours โ fast.
Free consultation โข No commitment โข Response within 24 hours
Add AI automation to your SaaS product?
Viprasol builds custom AI agent crews that plug into any SaaS workflow โ automating repetitive tasks, qualifying leads, and responding across every channel your customers use.