(updated)
|
min. read

Keep Background Apps Fresh with Expo Background Tasks and PowerSync

Dean Braun

PowerSync ensures that your app’s local database is always kept fresh. However, users frequently move in and out of apps. By default, apps only sync when they’re in the foreground. What happens when your app is running in the background? 

Apps that have been backgrounded might get stale as new server-side data stops syncing down to the user’s app. Users could then face a loading state while the local database catches up. Depending how much data has changed, the impact could range from imperceptible to highly frustrating. Thankfully, Expo’s BackgroundTask API provides a solution.

This article walks through how to implement background sync in an Expo app using PowerSync and Supabase. We'll cover the problem, the solution, and a step-by-step implementation, complete with code and a demo to show it in action. 

A React Native app running on Android, being minimized, and loading data in the background through Expo background tasks.

The Problem: Stale Data and Slow Catch-Up

PowerSync ensures fast, responsive apps by storing data in a local SQLite database that syncs with a remote backend (like Supabase). When the app is in the foreground, sync works like a charm and users get instant updates. But when the app is backgrounded, sync stops. This leads to two issues:

  • Stale Data Offline: Users opening the app offline have data that could be hours or days old.
  • Waiting For Fresh Data: When users return online, PowerSync must process all pending upload transactions and download new operations, which could lead to unnecessary delays in your app.

These issues can make your app feel sluggish, especially for users who frequently switch apps or leave them idle for long periods. 

The Solution: Background Sync with Expo

Expo’s BackgroundTask API offers a way to keep PowerSync’s local SQLite database in sync even when the app is in the background. By scheduling a background task to periodically connect to PowerSync and sync with your source database, you can reduce stale data by keeping the local SQLite database up-to-date with the latest remote changes without your users even knowing about it.

This approach uses expo-background-task to register a background task that connects to PowerSync. The task is carefully managed to run only when the app is backgrounded, avoiding conflicts with foreground sync.

While background tasks aren’t perfect — iOS and Android impose restrictions based on battery life and user behavior — they’re a best-effort enhancement that makes your app feel snappy and professional. When the stars align; users open your app to find fresh data with no waiting, creating those delightful moments where everything just works.

Implementation Steps

Let’s break down how to implement background sync with PowerSync and Expo. The full code is available in this demo app repo.

Step 1: Set Up Supabase with expo-fetch

Since I’m using a %%SupabaseConnector%%, we need to override the default fetch function with expo-fetch in the Supabase client. This is critical because background tasks normally run in headless mode, which lacks a full JavaScript environment, causing React Native's default %%fetch%% function not to work.

This demo is also configured to use anonymous sign-ins with Supabase. Read more about that here.

import { fetch } from "expo/fetch";
import { createClient } from "@supabase/supabase-js";

this.client = createClient(AppConfig.supabaseUrl, AppConfig.supabaseAnonKey, {
  auth: {
    persistSession: true
  },
  global: {
    // Override the default fetch to use expo-fetch
    fetch: fetch as any
  }
});

Step 2: Define the Background Sync Task

The background task located in %%lib/utils.ts%% reuses the system object from %%SystemContext.ts%% to avoid creating a new PowerSync instance, which could lead to inconsistent sync behavior. We simulate a pending transaction by inserting a mock entry into the %%lists%% table, then trigger PowerSync’s sync process. A custom promise ensures all uploads and downloads are complete before the task finishes.

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

const BACKGROUND_SYNC_TASK = "background-powersync-task";

TaskManager.defineTask(BACKGROUND_SYNC_TASK, async () => {
  try {
    console.log(`[Background Task] Starting at ${new Date().toISOString()}`);

    // Skip if PowerSync is already connected
    if (system?.powersync?.connected) return;

    // Simulate a pending transaction
    await system.powersync.execute(
      `INSERT INTO ${LIST_TABLE} (id, name, owner_id) VALUES (uuid(), 'From Inside BG', ?)`,
      [await system.connector.userId()]
    );

    // Upload pending transactions
    await system.connector.uploadData(system.powersync);

    console.log("[Background Task] Initializing PowerSync");
    await system.init();

    // Wait for sync to complete
    await new Promise<void>((resolve) => {
      console.log("[Background Task] Waiting for sync to complete");
      const unregister = system.powersync.registerListener({
        statusChanged: (status) => {
          const hasSynced = Boolean(status.lastSyncedAt);
          const downloading = status.dataFlowStatus?.downloading || false;
          const uploading = status.dataFlowStatus?.uploading || false;

          if (hasSynced && !downloading && !uploading) {
            console.log("[Background Task] Sync complete");
            resolve();
            unregister();
          }
        },
      });
    });
  } catch (error) {
    console.error("[Background Task] Failed to execute:", error);
    return BackgroundTask.BackgroundTaskResult.Failed;
  }
  return BackgroundTask.BackgroundTaskResult.Success;
});

This task first uploads pending transactions to Supabase and downloads new operations from the PowerSync Service, ensuring the local database stays fresh. We use a custom promise instead of %%system.powersync.waitForFirstSync()%% because %%waitForFirstSync()%% relies on the %%hasSynced%% property under the hood, which may already be true due the system object being shared.

Step 3: Register the Background Task

Register the background task when the app is backgrounded and unregister it when the app is active to avoid conflicts with foreground sync. The %%innerAppMountedPromise%% promise ensures the task is only registered after the app’s home screen mounts, and we check the app’s state to handle cases like deep links.

import * as TaskManager from "expo-task-manager";
import * as BackgroundTask from "expo-background-fetch";
import { AppState } from "react-native";

const BACKGROUND_SYNC_TASK = "background-powersync-task";
const MINIMUM_INTERVAL = 15; // Run every 15 minutes

/**
 * Initializes and manages the background task based on app state changes.
 */
export const initializeBackgroundTask = async (innerAppMountedPromise: Promise<void>) => {
  // Wait for the app to mount
  await innerAppMountedPromise;

  // Listen for app state changes
  AppState.addEventListener("change", async (nextAppState) => {
    console.log("App state changed:", nextAppState);

    if (nextAppState === "active") {
      // Unregister task when app is in foreground
      const isTaskRegistered = await TaskManager.isTaskRegisteredAsync(BACKGROUND_SYNC_TASK);
      if (isTaskRegistered) {
        console.log("App is active. Unregistering background task.");
        await BackgroundTask.unregisterTaskAsync(BACKGROUND_SYNC_TASK);
      }
    } else if (nextAppState === "background") {
      // Register task when app is in background
      const isTaskRegistered = await TaskManager.isTaskRegisteredAsync(BACKGROUND_SYNC_TASK);
      if (!isTaskRegistered) {
        console.log("App is in background. Registering background task.");
        await BackgroundTask.registerTaskAsync(BACKGROUND_SYNC_TASK, {
          minimumInterval: MINIMUM_INTERVAL,
        });
      }
    }
  });

  // Handle initial app state (e.g., app starts in background via deep link)
  const initialAppState = AppState.currentState;
  if (initialAppState === "background") {
    const isTaskRegistered = await TaskManager.isTaskRegisteredAsync(BACKGROUND_SYNC_TASK);
    if (!isTaskRegistered) {
      await BackgroundTask.registerTaskAsync(BACKGROUND_SYNC_TASK, {
        minimumInterval: MINIMUM_INTERVAL,
      });
    }
  }
};

This code reduces the risk of multiple simultaneous PowerSync connections, which could cause race conditions or SQLite conflicts.

Step 4: Test and Debug

Background tasks can be tricky to test due to OS restrictions and scheduling variability. On iOS, Expo’s %%BackgroundTask API%% uses the iOS %%BGTaskScheduler%%, which determines the best time to launch your task based on battery level, network availability, and user usage patterns. You can specify a %%minimumInterval%% (e.g., 15 minutes), but the system may delay execution, especially on iOS.

Below are detailed testing instructions from the demo app repo. The app must be in the background for the task to run, and the OS ultimately decides when to execute it (typically within 20 minutes, though iOS may delay longer). Testing on physical devices is more reliable than simulators.

Android Testing

  1. Run the App: Start the app with %%npm run android%%
  2. Change the %%AppState%% to %%background%%
  3. Check Job Scheduler: Use the following command to view the job scheduler for the app (%%com.anonymous.powersyncreactnativebackgroundsync%% is the package name):
adb shell dumpsys jobscheduler | grep -A 40 -m 1 -E "JOB #.* com.anonymous.powersyncreactnativebackgroundsync"

Look for output like:

JOB #u0a212/0: 74a550e com.anonymous.powersyncreactnativebackgroundsync/androidx.work.impl.background.systemjob.SystemJobService

The number after the %%/%% (e.g., 0) is the job ID.

  1. Force the Task: Run the task manually with:
adb shell cmd jobscheduler run -f com.anonymous.powersyncreactnativebackgroundsync 0

Note: Forcing may not work immediately due to OS scheduling.

  1. Monitor Logs: Watch all device logs using `adb logcat` to catch task activity. Look for these logs to confirm the task is running:
[Background Task] Starting background task at [timestamp]
[Background Task] Initializing PowerSync
[Background Task] Waiting for first sync to complete
[Background Task] Download complete
Validated and applied checkpoint

iOS Testing

  • Physical Device Required: Background tasks are not supported on iOS simulators. Test on a physical iOS device with Background App Refresh enabled (Settings > General > Background App Refresh).
  • To enable background tasks on iOS, you need to add both %%processing %% and %%fetch%% to the %%UIBackgroundModes%% array in your app’s %%Info.plist%% (%%ios/powersyncreactnativebackgroundsync/Info.plist%%). The %%fetch%% mode is required so your app can periodically run background fetches, allowing PowerSync to upload and download data even when the app is not in the foreground:
<key>UIBackgroundModes</key>
<array>
    <string>processing</string>
    <string>fetch</string>
</array>
  • Monitor Logs: Use Xcode’s console to view logs. Look for the same log messages as above to confirm task execution.

Debugging Tips

  • Permissions: Ensure Background App Refresh is enabled on iOS and battery optimizations are disabled on Android (Settings > Battery > Battery Optimization > Select your app > Don’t Optimize).
  • Offline Behavior: If the device is offline, the task will fail gracefully and retry when the network is available. Check for network-related errors in logs.
  • Verify Sync: After forcing the task or waiting for it to run, check Supabase to confirm the mock transaction (e.g., a list item named “From Inside BG”) was uploaded.
  • Log Monitoring: If logs don’t appear in your project's terminal, use %%adb logcat%% (Android) or Xcode’s console (iOS) to watch all device logs, as task logs may be filtered out in some environments.

For more debugging tips, check the demo app’s README.

FAQ

  • What if the device is offline during the task? The task will fail gracefully and is scheduled to retry every 15 minutes. Log errors to diagnose persistent issues.
  • Why reuse the system object? Creating a new PowerSync instance risks race conditions or duplicate connections. Reusing the global system object ensures consistent sync behavior.
  • What’s the minimum interval? The %%minimumInterval%% (15 minutes) is essentially a suggestion to the operating system. iOS may run tasks every 20 minutes to hours, while Android is more flexible, but depends on many factors.

Conclusion

Combining background tasks with PowerSync and Expo delivers a much more complete user experience that other mobile apps typically lack. It’s not perfect: iOS and Android can be stingy with background execution. But when it works, users get instant access to fresh data, online or offline, with no frustrating loading spinners. By following the steps above, you can implement this in your own React Native Expo app.

A very special thank you to AbdelHameed for his valuable insights and lessons learned from implementing background sync with PowerSync and Expo himself.