expo-background-refresh-status
v1.0.0
Published
Real iOS Background App Refresh status (UIApplication.backgroundRefreshStatus), with a change listener. Fills the gap where expo-background-task hardcodes its iOS status.
Maintainers
Readme
expo-background-refresh-status
Reads the real iOS Background App Refresh setting — UIApplication.backgroundRefreshStatus —
and tells you when it changes.
Why this exists
If you use expo-background-task,
you might reasonably expect getStatusAsync() to tell you whether the user has Background App
Refresh switched on. On iOS, it does not. Its status is:
// expo-background-task/ios/BackgroundTaskScheduler.swift
public static func supportsBackgroundTasks() -> Bool {
#if targetEnvironment(simulator)
return false
#else
return true
#endif
}That value is fed straight into getStatusAsync(). It returns restricted on the Simulator and
available on every physical device, unconditionally — it never reads the OS permission at all.
So a "background refresh is off" warning built on it can never fire on a shipping build, no
matter what the user has actually set in Settings → General → Background App Refresh.
(The predecessor library, expo-background-fetch, did read the real status. The check appears
to have been dropped, likely unintentionally, in the rewrite to a BGTaskScheduler-based
implementation.)
This package does the read properly, and adds the change notification that
expo-background-task doesn't surface either.
What you get
| | expo-background-task (iOS) | this package |
| ------------------------ | ------------------------------- | ------------------------------------------ |
| Reads the real OS status | ❌ hardcoded per build target | ✅ UIApplication.backgroundRefreshStatus |
| Status values | available | restricted | available | denied | restricted |
| Change notification | ❌ (consumers poll on AppState) | ✅ addStatusChangeListener |
The three values mirror Apple's UIBackgroundRefreshStatus exactly.
Installation
npx expo install expo-background-refresh-statusNo config plugin, no Info.plist keys, no entitlements. Autolinking picks it up. It is native
code, so it requires a development build or a store build — it does not work in Expo Go.
Requirements
Expo SDK 54 or newer. Two things set that floor: expo-module.config.json uses the apple
platform key, which autolinking only understands from SDK 51 on, and the JS entry point imports
requireOptionalNativeModule from expo. On an older SDK the install succeeds and the module is
simply never linked — isAvailable is false with nothing in the build log to explain it. The
expo peer range is >=54, so npx expo install will warn you rather than let that happen
quietly.
Usage
import {
addStatusChangeListener,
getStatusAsync,
isAvailable,
} from 'expo-background-refresh-status';
const status = await getStatusAsync();
// 'available' | 'denied' | 'restricted' | null
if (status !== null && status !== 'available') {
// Background refresh won't run. Consider prompting the user.
}
const sub = addStatusChangeListener(({ status }) => {
console.log('background refresh is now', status);
});
// later
sub.remove();API
getStatusAsync(): Promise<BackgroundRefreshStatus | null>
The current setting.
'available'— Background App Refresh is on for this app.'denied'— the user switched it off, either for this app specifically or system-wide. Apple reports both as the same case.'restricted'— parental controls or MDM forbid it; the user cannot change it.null— no reading was possible (seeisAvailable).
Check status !== 'available', not status === 'restricted'. Both non-available cases mean
background refresh won't run, and denied is by far the common one.
null means "no reading", not "available". Leave whatever state you had unchanged rather
than treating it as permission granted.
addStatusChangeListener(listener): EventSubscription
Subscribes to UIApplication.backgroundRefreshStatusDidChangeNotification (iOS 7+). The listener
receives { status }. Returns an inert subscription when isAvailable is false.
EventSubscription is { remove(): void } and is exported from this package. It is deliberately
not imported from expo-modules-core: doing that would require declaring expo-modules-core
as a peer dependency, which makes expo-doctor report a missing peer in every consuming app and
forces you to add a direct dependency on something you already get through expo. The real
subscription satisfies this type structurally, so nothing is lost.
isAvailable: boolean
Whether the native module is present in this binary. false on Android, on web, and in Expo Go.
Both functions above fail soft when it's false — check this first if you'd rather fail loudly.
Platform support
Apple platforms only. There is deliberately no Android implementation: Android has no equivalent per-app background-refresh toggle to read.
The podspec declares iOS, tvOS and visionOS, because Apple documents both APIs on all three (iOS 7+, tvOS 11+, visionOS 1+) and because an undeclared platform is skipped by the same silent autolinking filter described below. Only iOS is verified on hardware.
On Android and web, importing this package is safe — it resolves to null internally, isAvailable
is false, getStatusAsync() resolves null, and listeners are inert. Nothing throws at import
time, so you don't need to guard the import itself.
Three deliberate behaviours worth knowing
- An unknown future enum case maps to
'available'. If Apple adds a fourthUIBackgroundRefreshStatuscase, this returns'available'rather than inventing a warning. A hint that fabricates itself is worse than one that stays quiet. 'restricted'ships unverified. It requires a supervised device (parental controls / MDM), which isn't reachable in ordinary testing. The mapping is a one-lineswitchcase and is correct by inspection, but it has not been exercised on a device. This is a further reason to branch on!== 'available'rather than per value.- Low Power Mode does not change the status. Verified on device: iOS keeps reporting
'available'under Low Power Mode even though it suppresses background execution. So'available'means "not switched off", not "background work will run" — treat any metric built on it as a ceiling. It also means you should probably not warn the user about Low Power Mode from this API: the switch they'd be sent to is genuinely still on.
Verified on device
getStatusAsync() (available and denied), addStatusChangeListener() and isAvailable are all
confirmed on a physical iPhone against an Expo SDK 54 app, as of 1.0.0. restricted is the sole
exception, per the note above.
SDK 54 is the oldest supported target, which is why it's the one verified by hand; the example/
app in this repo tracks the current SDK instead, so both ends of the supported range get exercised.
The fail-soft path — everything isAvailable === false implies — is covered by unit tests rather
than by hand, since it's the branch an iPhone can't reach.
Deployment target, if you're on an older SDK: this package's podspec targets iOS 15.1 to match Expo SDK 54 and every first-party
expo-*module. If your app's deployment target is lower than a module's podspec floor, Expo's CocoaPods autolinking silently skips that module — the build succeeds, no pod is compiled, nothing appears in the log, andisAvailableis simplyfalseat runtime. (expo-modules-autolinking resolvestill reports the module as resolved, because that filter only runs duringpod install.) 0.1.0 shipped with a 16.4 floor and was inert on SDK 54 apps for exactly this reason; if you're on 0.1.0, upgrade.
Contributing
Issues and PRs welcome. The native surface is one Swift file
(ios/BackgroundRefreshStatusModule.swift) — it is
meant to stay that small.
See also
expo/expoissue tracker — the upstream fix forexpo-background-task's hardcoded status is the long-term home for the read. Even if it lands, the collapsed two-value enum and the missing change notification would remain.
License
MIT © Arnau Tresserras
