One of the easiest accidents to make in indie iOS development is shipping a background refresh path that quietly piles up BGTaskSchedulerErrorDomain Code=1 in Crashlytics for months before anyone notices. Nothing crashes, so nothing tells you. By the time you notice six months later, daily active users have been eroding in ways that are hard to attribute back to anything.
Code=1 is officially BGTaskSchedulerErrorCodeUnavailable, and Apple's documentation summarizes it in a single line: "Background App Refresh is disabled or scheduling is not currently possible." In practice it splits into about six distinct causes, and if you work through them top to bottom you will always land on the real one. This article assumes you bolted BGTaskScheduler onto a Rork-generated iOS app after the fact, and walks the six causes in order.
What makes Code=1 awkward is that the error text tells you nothing about the cause. The flip side is that once you pin down the finite set of causes, the work reduces to walking the list. Below is that list, in the order I actually hit them.
Pin down which kind of Code=1 you are looking at
Code=1 multiplexes four or five different root causes into one number, so the single most useful thing you can do up front is decide when the error is occurring. Wrap submit with verbose logging and reproduce it on a physical device, simulator, and TestFlight build separately.
// AppDelegate.swift or your Expo native module
import BackgroundTasks
import os
let log = Logger(subsystem: "net.rorklab.app", category: "bgtask")
func scheduleAppRefresh() {
let request = BGAppRefreshTaskRequest(identifier: "net.rorklab.app.refresh")
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
do {
try BGTaskScheduler.shared.submit(request)
log.info("bgtask submitted: id=\(request.identifier)")
} catch let error as NSError {
log.error("bgtask submit failed: domain=\(error.domain) code=\(error.code) info=\(error.userInfo)")
// Forward userInfo (especially NSLocalizedFailureReason) into Crashlytics
}
}If you drop userInfo here, you will end up looping back through this checklist a second time. I always forward the whole userInfo dictionary into Crashlytics as a custom key.
In Expo / Rork projects, the identifier is not yours
Before the six causes, there is one trap that never appears if you wrote the Swift yourself. Rork generates React Native (Expo) apps, so most people wire up background work through expo-background-task rather than touching Swift at all.
In that setup the identifier submitted under the hood is not a string you chose. It is the Expo module's own com.expo.modules.backgroundtask.processing. If that exact string is absent from BGTaskSchedulerPermittedIdentifiers in Info.plist, iOS will not run your task no matter how correct the JavaScript side is.
{
"expo": {
"ios": {
"infoPlist": {
"BGTaskSchedulerPermittedIdentifiers": [
"com.expo.modules.backgroundtask.processing"
]
}
}
}
}If you use CNG (Continuous Native Generation) and let prebuild do its job, both UIBackgroundModes and this identifier get injected automatically. In other words, it works as long as you leave it alone. The failure shows up the moment you add a native task of your own and start writing ios.infoPlist.BGTaskSchedulerPermittedIdentifiers by hand. List only your identifier and Expo's drops out of the array, silently killing the JavaScript-side task.
That is what produces the seemingly contradictory state where your Swift submit succeeds but BackgroundTask never fires. If you need both, list both.
"BGTaskSchedulerPermittedIdentifiers": [
"com.expo.modules.backgroundtask.processing",
"net.rorklab.app.refresh"
]This requirement went undocumented for a long stretch, and it is still tracked as expo/expo issue #40440. When the official docs come up empty, searching the issue tracker first is often the faster route.
One more wrinkle: repeated prebuild runs have been reported to let config plugins append the same Info.plist keys again, leaving duplicate entries in the array. The values survive, so this is not a direct cause of Code=1, but it makes diffs noisy. When the array starts growing, run prebuild --clean once.
Cause 1: identifier is missing from BGTaskSchedulerPermittedIdentifiers
This is the most common culprit by a wide margin. If the identifier you pass to submit is not listed verbatim in the BGTaskSchedulerPermittedIdentifiers array of Info.plist, iOS returns Code=1. In Expo / Rork setups you inject this through app.json (or app.config.ts) under ios.infoPlist.
{
"expo": {
"ios": {
"infoPlist": {
"BGTaskSchedulerPermittedIdentifiers": [
"net.rorklab.app.refresh",
"net.rorklab.app.processing"
],
"UIBackgroundModes": ["fetch", "processing"]
}
}
}
}The identifier prefix does not have to match the bundle ID, but the string in Info.plist must be a character-for-character match for the one you submit. After prebuild, open Info.plist in Xcode and confirm the entry actually landed. EAS Build sometimes skips prebuild and reuses an outdated Info.plist, so running eas build --clear-cache once is a cheap safety net.
Cause 2: Background App Refresh is disabled in OS settings
If you can reproduce on a device but not in the simulator, this is almost always it. When "Settings > General > Background App Refresh" is OFF, or Low Power Mode is on, the OS rejects submit with Code=1 on a per-user basis.
There is no recovery from the app side, so I handle this with two UX touches:
- On first launch, check
UIApplication.shared.backgroundRefreshStatus. If it is.deniedor.restricted, show a single non-pushy banner suggesting the user enable Background App Refresh so the app can fetch updates while closed. - Forward
backgroundRefreshStatusto Crashlytics as a custom key on every launch. This lets you correlate Code=1 spikes with the OFF-user population after the fact.
Adding just these two hooks revealed that, in one of my apps, roughly 70 percent of Code=1 errors were users with the OS setting off, not a code defect. I stopped wasting engineering time on reproductions I could not control.
Cause 3: You are running in the simulator
This one quietly eats hours. BGTaskScheduler sometimes accepts submit in the simulator and sometimes returns Code=1 depending on iOS version (especially iOS 17+). When you want to actually exercise the handler locally, do not rely on submit succeeding. Trigger the task directly from the debugger instead.
(lldb) e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"net.rorklab.app.refresh"]That fires handler immediately, so you can focus on the handler logic without worrying about whether submit would have been accepted. Verify real-world submit success rates against Code=1 counts in Crashlytics after a TestFlight rollout, which is the only honest signal anyway.
Cause 4: Missing UIBackgroundModes entries
BGAppRefreshTaskRequest requires fetch in UIBackgroundModes, and BGProcessingTaskRequest requires processing. That is straight from the docs, but in Expo / Rork projects, prebuild can quietly overwrite your UIBackgroundModes array if you forgot to declare it in app.json.
{
"ios": {
"infoPlist": {
"UIBackgroundModes": ["fetch", "processing"]
}
}
}After prebuild, open ios/{AppName}/Info.plist and confirm UIBackgroundModes survived. If you submit a BGProcessingTaskRequest with only fetch declared, you will get Code=1 from the processing path while app refresh continues to work, which is a fun way to spend an evening.
Cause 5: earliestBeginDate is in the past or too close to now
Setting earliestBeginDate to Date() or a moment in the past can cause iOS to return Code=1 depending on the scheduler's internal state. The safe pattern is at least 15 minutes in the future.
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)A past timestamp can also surface as behavior similar to Code=3 (TooManyPendingTaskRequests). Trusting the code number alone wastes time. Keep earliestBeginDate either nil or at least 15 minutes out, and the behavior stabilizes.
Cause 6: register and submit identifiers do not match
If the identifier passed to BGTaskScheduler.shared.register differs even by a single character from the one you submit, you get Code=1. When you edit Rork-generated code by hand, copy-paste drift sneaks in easily. Lock both sides to a constant.
enum BackgroundTaskID {
static let appRefresh = "net.rorklab.app.refresh"
static let processing = "net.rorklab.app.processing"
}
// AppDelegate.swift
BGTaskScheduler.shared.register(forTaskWithIdentifier: BackgroundTaskID.appRefresh, using: nil) { task in
handleAppRefresh(task: task as! BGAppRefreshTask)
}I once spent two weeks shipping Code=1 in one of six wallpaper apps I run in parallel because of app.refresh versus appRefresh drift. Since then I always pin the identifier in an enum on day one.
Check whether submit only looked like it worked
submit not throwing and iOS actually holding on to the request are two different things. You can read back what is currently registered with getPendingTaskRequests.
func dumpPendingTasks() {
BGTaskScheduler.shared.getPendingTaskRequests { requests in
guard !requests.isEmpty else {
log.error("no pending bgtask requests — submit did not persist")
return
}
for request in requests {
log.info("pending: id=\(request.identifier) earliest=\(String(describing: request.earliestBeginDate))")
}
}
}Send the app to the background, bring it back to the foreground, and call this. The reading splits cleanly:
- Empty immediately after
submit— the problem is the identifier orInfo.plist(causes 1, 4, 6) - Populated right after
submitbut empty after resuming — the problem is device state (causes 2 and 5)
One extra round trip of logging tells you whether the cause sits on the side you can fix or on the side that belongs to the user's OS settings. Since adding this check, the time I spend chasing bugs that will not reproduce has dropped noticeably.
Working backwards from the symptom you can see
Sometimes you would rather reverse-lookup from what you are observing than walk the list top to bottom. Here is how the symptoms I ran into map back.
| What you observe | Likely cause | Where to look first |
|---|---|---|
| Code=1 only in the simulator, device is fine | Cause 3 | Fire the task from LLDB and test the handler alone |
| Code=1 only on device, and only on certain ones | Cause 2 | Settings > General > Background App Refresh, and Low Power Mode |
| Code=1 on every device and every build | Cause 1 / Cause 4 | The two arrays in Info.plist after prebuild |
| App refresh works, processing alone returns Code=1 | Cause 4 | Whether processing is in UIBackgroundModes |
| Swift side succeeds but the JavaScript task never fires | Missing Expo identifier | Whether com.expo.modules.backgroundtask.processing is in the permitted array |
| submit succeeds but the pending list is empty | Cause 5 | Whether earliestBeginDate is too close to now |
| Code=1 volume jumped starting on a specific day | Cause 1 / Cause 4 | Whether a recent prebuild overwrote Info.plist |
That last row is the one indie developers miss most often. When errors climb without a single line of code changing, diff Info.plist across the prebuild boundary before anything else. Knowing the cause lives outside your source tree changes where you look.
What to do next, in order
The triage above resolves to a short execution checklist. If you read nothing else, copy this:
- Add
userInfo-aware logging aroundsubmitin your AppDelegate or Expo module (a few minutes) - Verify
BGTaskSchedulerPermittedIdentifiersandUIBackgroundModesin Xcode afterprebuild(5 minutes) - Reproduce on a physical device with Background App Refresh ON in Settings
- Pin
registerandsubmitidentifiers to a Swift enum (10 minutes) - Ship to TestFlight and watch Code=1 counts in Crashlytics for 24 hours
Step five assumes the build reaches TestFlight at all. I once spent an afternoon chasing Code=1 when the build had simply stalled in App Store Connect processing and never shipped. If that is where you are stuck, Your Rork App Is Stuck on "Processing" in App Store Connect walks through that triage separately.
This accounts for most Code=1 cases, and what remains traces back to user OS settings or Low Power Mode, which are honestly not yours to solve in code. Once the task does fire and you are ready to build the work it performs to production standards, Keeping Rork Apps Fresh While Closed covers the design end to end, including Silent Push and Android WorkManager. BGTaskScheduler exposes very few error codes, but its cause space is finite, so once you internalize the checklist, you can pinpoint future regressions in well under 30 minutes.
This ordering is what settled out after taking the long way around a few too many times. If it hands you back the half-day it once cost me, that is enough.