React Native Background Sync That Survives App Kill (Plus Local Reminders)
You swipe the app away.
Five minutes later you still expect:
- data to sync in the background
- 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
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
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" />
Quick why:
-
RECEIVE_BOOT_COMPLETED→startOnBoot: trueisn’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()
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;
}
}
Same function for:
- “Sync now” button
- BackgroundFetch while app is alive
- 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);
},
);
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);
Headless handler:
export async function backgroundHeadlessTask({ taskId, timeout }) {
if (timeout) {
BackgroundFetch.finish(taskId);
return;
}
try {
await runSyncJob();
} finally {
BackgroundFetch.finish(taskId);
}
}
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 },
},
);
Also check exact-alarm permission on Android 12+:
const settings = await notifee.getNotificationSettings();
if (settings.android.alarm === AndroidNotificationSetting.DISABLED) {
await notifee.openAlarmPermissionSettings();
}
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();
})();
}, []);
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
Takeaways
- Sync ≠ reminders — different native systems.
- Headless code stays pure — no UI imports.
-
Always
finish(taskId)— including timeouts. - Persist results — otherwise you can’t prove headless ran.
- Exact alarms are a permission — treat them like camera access.
- 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)