DEV Community

Cover image for React Native Background Sync That Survives App Kill (Plus Local Reminders)
Amit Kumar
Amit Kumar

Posted on

React Native Background Sync That Survives App Kill (Plus Local Reminders)

React Native Background Sync That Survives App Kill (Plus Local Reminders)

You swipe the app away.

Five minutes later you still expect:

  1. data to sync in the background
  2. that reminder to fire on time

React Native doesn’t give you one magic API for that. The OS does — and Android / iOS play by different rules.

We shipped both in a RN 0.86 (New Arch) app. This is the practical write-up: what we used, how we wired it, and how to prove it works after the app is dead.


TL;DR

Job Library Survives kill on Android?
Sync every ~15+ min react-native-background-fetch ✅ Headless JS
Fire a reminder at a time @notifee/react-native ✅ AlarmManager
Remember last sync AsyncStorage

iOS reality check: force-quit = background fetch stops. No workaround without silent push. We went Android-first.


Two problems, two tools

People mash these together. Don’t.

  • Background sync → “wake me when you can, do short work” → JobScheduler / BGAppRefresh → react-native-background-fetch
  • Reminders → “wake me at 3:00 PM sharp” → AlarmManager → Notifee timestamp triggers

If you try to schedule a 1‑minute reminder with Background Fetch… you’ll have a bad day.


Mental model

App open
  → init Notifee (channel + permissions)
  → configure BackgroundFetch

App killed (Android)
  → OS starts Headless JS
  → runSyncJob() → AsyncStorage
  → BackgroundFetch.finish(taskId)   ← never skip this

Reminder
  → Notifee TIMESTAMP + alarmManager.allowWhileIdle
  → notification shows even if app process is gone
Enter fullscreen mode Exit fullscreen mode

Rule of thumb: headless code must be boring. No navigation. No React tree. No “just import the store that mounts the app”.


Install

yarn add react-native-background-fetch \
  @notifee/react-native \
  @react-native-async-storage/async-storage

cd ios && pod install
Enter fullscreen mode Exit fullscreen mode

Android permissions that actually matter

<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
Enter fullscreen mode Exit fullscreen mode

Quick why:

  • RECEIVE_BOOT_COMPLETEDstartOnBoot: true isn’t cosplay
  • POST_NOTIFICATIONS → Android 13+ will ghost you without it
  • SCHEDULE_EXACT_ALARM → reminders that fire when the app is dead

Autolinking handles the rest. We didn’t touch MainApplication.


iOS (minimal, honest)

Info.plist:

  • UIBackgroundModes: fetch, processing
  • BGTaskSchedulerPermittedIdentifiers: com.transistorsoft.fetch

AppDelegate.swift:

import TSBackgroundFetch

TSBackgroundFetch.sharedInstance().didFinishLaunching()
Enter fullscreen mode Exit fullscreen mode

That’s enough for backgrounded iOS.

Force-quit? You’re done. Apple said so.


Keep the sync job pure

This is the whole trick.

// syncJob.js — safe for Headless JS
export async function runSyncJob() {
  const startedAt = new Date().toISOString();
  try {
    const res = await fetch('https://jsonplaceholder.typicode.com/todos/1');
    const payload = await res.json();
    const result = { lastSyncAt: startedAt, lastPayload: payload, lastError: null, ok: true };
    await AsyncStorage.setItem(KEY, JSON.stringify(result));
    return result;
  } catch (e) {
    const result = { lastSyncAt: startedAt, lastPayload: null, lastError: e.message, ok: false };
    await AsyncStorage.setItem(KEY, JSON.stringify(result));
    return result;
  }
}
Enter fullscreen mode Exit fullscreen mode

Same function for:

  1. “Sync now” button
  2. BackgroundFetch while app is alive
  3. Headless wake after kill

One job. Three doors.


Configure BackgroundFetch for “app is dead”

await BackgroundFetch.configure(
  {
    minimumFetchInterval: 15,
    stopOnTerminate: false, // Android: keep going after kill
    startOnBoot: true,
    enableHeadless: true,   // Android Headless JS
    requiredNetworkType: BackgroundFetch.NETWORK_TYPE_ANY,
  },
  async (taskId) => {
    try {
      await runSyncJob();
    } finally {
      BackgroundFetch.finish(taskId);
    }
  },
  async (taskId) => {
    // timeout — still finish
    BackgroundFetch.finish(taskId);
  },
);
Enter fullscreen mode Exit fullscreen mode

If you forget finish(), Android will quietly throttle you into oblivion.


Register Headless in index.js (order matters)

import BackgroundFetch from 'react-native-background-fetch';
import { backgroundHeadlessTask } from './src/services/backgroundHeadlessTask';

BackgroundFetch.registerHeadlessTask(backgroundHeadlessTask);
AppRegistry.registerComponent(appName, () => App);
Enter fullscreen mode Exit fullscreen mode

Headless handler:

export async function backgroundHeadlessTask({ taskId, timeout }) {
  if (timeout) {
    BackgroundFetch.finish(taskId);
    return;
  }
  try {
    await runSyncJob();
  } finally {
    BackgroundFetch.finish(taskId);
  }
}
Enter fullscreen mode Exit fullscreen mode

Register before AppRegistry. After is how you get “works on my emulator until I kill it”.


Reminders: Notifee + AlarmManager

await notifee.createTriggerNotification(
  {
    id: 'demo-reminder-1min',
    title: 'Reminder',
    body: 'Still fired after you killed the app.',
    android: {
      channelId: 'reminders',
      pressAction: { id: 'default' },
      importance: AndroidImportance.HIGH,
    },
  },
  {
    type: TriggerType.TIMESTAMP,
    timestamp: Date.now() + 60_000,
    alarmManager: { allowWhileIdle: true },
  },
);
Enter fullscreen mode Exit fullscreen mode

Also check exact-alarm permission on Android 12+:

const settings = await notifee.getNotificationSettings();
if (settings.android.alarm === AndroidNotificationSetting.DISABLED) {
  await notifee.openAlarmPermissionSettings();
}
Enter fullscreen mode Exit fullscreen mode

No AlarmManager → notification often waits until the user opens the app. Super confusing. Super common.


Wire it on launch

useEffect(() => {
  (async () => {
    await NotificationService.init();
    await BackgroundSyncService.init();
  })();
}, []);
Enter fullscreen mode Exit fullscreen mode

Give yourself a Settings lab

Don’t wait 15 minutes staring at Logcat.

We put this in Settings:

  • fetch status
  • last sync timestamp / error
  • exact-alarm allowed?
  • Run sync now
  • Schedule reminder in 1 min
  • Cancel reminders
  • open exact-alarm settings

If you can’t demo the feature in 90 seconds, you can’t ship it.


How to verify on a real Android device

1. Sync while alive

Settings → Run sync now → last sync updates.

2. Reminder after kill

Schedule 1‑min reminder → swipe app from Recents → wait → notification shows.

3. Headless sync

Kill app → wait for OS wake (15+ min; OEMs vary) → reopen → lastSyncAt moved without a tap.

OEM drama: Xiaomi / Oppo / some Samsung builds love murdering background work. Battery optimization + autostart settings are part of the product story, not “user error”.


File map

index.js                 # registerHeadlessTask
App.js                   # init services
src/services/
  syncJob.js
  BackgroundSyncService.js
  NotificationService.js
  backgroundHeadlessTask.js
src/screens/Settings/    # verification UI
AndroidManifest.xml
Info.plist + AppDelegate.swift
Enter fullscreen mode Exit fullscreen mode

Takeaways

  1. Sync ≠ reminders — different native systems.
  2. Headless code stays pure — no UI imports.
  3. Always finish(taskId) — including timeouts.
  4. Persist results — otherwise you can’t prove headless ran.
  5. Exact alarms are a permission — treat them like camera access.
  6. Don’t lie about iOS — force-quit ends the story without push.

What’s next (if you go to prod)

  • Real API + auth + retry/backoff
  • Offline queue
  • Silent push for iOS kill survival
  • Don’t promise exact 15‑minute sync — the OS owns the clock

If this helped, drop a ❤️ and tell me what you’re syncing in the background (chat? habits? crypto prices you definitely shouldn’t sync every 15 minutes?). Happy to dig into OEM battery hell in a follow-up.

Top comments (0)