RORK LABJP
PLAY — Google Play's target API level 36 requirement took effect yesterday, August 31. From today, new apps and updates must target Android 16VISIBILITY — Apps still on API 35 stay listed but disappear for users on newer Android versions. No error is raised; new installs simply fade, which makes the change easy to missEXTENSION — If you missed the deadline, an extension through November 1, 2026 can be requested in Play Console — best filed alongside a concrete migration planAPPLE — On the Apple side, the event lands September 9 and iOS 27 is reported to ship September 14. Testing generated apps on iOS 27 hardware before release week is time well spentEXPO — Expo released expo-paste-input on August 28, a native module that brings image, GIF, and sticker paste to React Native TextInputEAS — EAS Observe reached general availability on August 20, putting crash and performance monitoring on the same EAS platform as builds and updatesPLAY — Google Play's target API level 36 requirement took effect yesterday, August 31. From today, new apps and updates must target Android 16VISIBILITY — Apps still on API 35 stay listed but disappear for users on newer Android versions. No error is raised; new installs simply fade, which makes the change easy to missEXTENSION — If you missed the deadline, an extension through November 1, 2026 can be requested in Play Console — best filed alongside a concrete migration planAPPLE — On the Apple side, the event lands September 9 and iOS 27 is reported to ship September 14. Testing generated apps on iOS 27 hardware before release week is time well spentEXPO — Expo released expo-paste-input on August 28, a native module that brings image, GIF, and sticker paste to React Native TextInputEAS — EAS Observe reached general availability on August 20, putting crash and performance monitoring on the same EAS platform as builds and updates
Articles/Dev Tools
Dev Tools/2026-05-28Intermediate

Tracking Down BGTaskScheduler.submit Error Code=1 (Unavailable) in Rork iOS Apps

When BGTaskScheduler.submit returns Error Code=1, the cause space is finite — six of them. Includes the identifier trap specific to Expo-based Rork apps and a getPendingTaskRequests self-check, ordered the way I actually hit them.

Rork547iOS110BGTaskScheduler3Background Refresh3Expo193React Native234

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 .denied or .restricted, show a single non-pushy banner suggesting the user enable Background App Refresh so the app can fetch updates while closed.
  • Forward backgroundRefreshStatus to 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 or Info.plist (causes 1, 4, 6)
  • Populated right after submit but 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 observeLikely causeWhere to look first
Code=1 only in the simulator, device is fineCause 3Fire the task from LLDB and test the handler alone
Code=1 only on device, and only on certain onesCause 2Settings > General > Background App Refresh, and Low Power Mode
Code=1 on every device and every buildCause 1 / Cause 4The two arrays in Info.plist after prebuild
App refresh works, processing alone returns Code=1Cause 4Whether processing is in UIBackgroundModes
Swift side succeeds but the JavaScript task never firesMissing Expo identifierWhether com.expo.modules.backgroundtask.processing is in the permitted array
submit succeeds but the pending list is emptyCause 5Whether earliestBeginDate is too close to now
Code=1 volume jumped starting on a specific dayCause 1 / Cause 4Whether 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:

  1. Add userInfo-aware logging around submit in your AppDelegate or Expo module (a few minutes)
  2. Verify BGTaskSchedulerPermittedIdentifiers and UIBackgroundModes in Xcode after prebuild (5 minutes)
  3. Reproduce on a physical device with Background App Refresh ON in Settings
  4. Pin register and submit identifiers to a Swift enum (10 minutes)
  5. 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.

Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $15 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

Dev Tools2026-05-07
Keeping Rork Apps Fresh While Closed — iOS BGTaskScheduler, Silent Push, and Android WorkManager
Silent Pushes that never arrive. A BGTaskScheduler that stays quiet. This is how I pushed background refresh in a Rork-built app up to production quality on both iOS and Android — and what the success-rate numbers told me to fix first.
Dev Tools2026-07-27
When to Raise Your Minimum iOS Version — Count Leftover Branches, Not User Percentages
Judging a minimum OS bump by usage share produces the same answer every year, so the decision never happens. Here is the annotation convention, the sweep script that counts how many branches each candidate floor would retire, and what to watch for 30 days after.
Dev Tools2026-06-14
Actually Delivering 'It Updates Without Opening' in Expo — A Realistic Background Task Design
Building 'content refreshes every morning' into a Rork-generated Expo app runs into iOS background execution being far less dutiful than you expect. Here is a minimal expo-background-task setup plus a design that doesn't break when the task never runs.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →