Back to Blog

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.

Viprasol Tech Team
March 30, 2027
13 min read

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

ScopeTeamTimelineCost Range
Basic push notifications (no background)1 dev1โ€“2 days$400โ€“800
Background fetch + silent push sync1 dev3โ€“5 days$1,000โ€“2,000
Background location tracking1 dev1 week$1,500โ€“3,000
Full system (fetch + location + foreground service)1โ€“2 devs2โ€“3 weeks$5,000โ€“10,000

See Also


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.

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours โ€” fast.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท AI Agent Systems

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.