Background App Refresh

Hi, I have a couple questions about background app refresh. First, is the function RefreshAppContentsOperation() where to implement code that needs to be run in the background? Second, despite importing BackgroundTasks, I am getting the error "cannot find operationQueue in scope". What can I do to resolve that? Thank you.

func scheduleAppRefresh() {
       let request = BGAppRefreshTaskRequest(identifier: "peaceofmindmentalhealth.RoutineRefresh")
       // Fetch no earlier than 15 minutes from now.
       request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
            
       do {
          try BGTaskScheduler.shared.submit(request)
       } catch {
          print("Could not schedule app refresh: \(error)")
       }
    }
    
    func handleAppRefresh(task: BGAppRefreshTask) {
       // Schedule a new refresh task.
       scheduleAppRefresh()


       // Create an operation that performs the main part of the background task.
       let operation = RefreshAppContentsOperation()
       
       // Provide the background task with an expiration handler that cancels the operation.
       task.expirationHandler = {
          operation.cancel()
       }


       // Inform the system that the background task is complete
       // when the operation completes.
       operation.completionBlock = {
          task.setTaskCompleted(success: !operation.isCancelled)
       }


       // Start the operation.
       operationQueue.addOperation(operation)
     }
    
    func RefreshAppContentsOperation() -> Operation {
        
    }
Answered by DTS Engineer in 850134022

I fear you’re getting distracted by the Operation side of this. Operation isn’t required by this API. Its presence in the docs is an artefact of history, in that those docs were written before Swift concurrency was a thing. If you were doing this today, you’d ignore Operation and just use Swift concurrency.

Similarly, the docs are within the UIKit, but that’s also not required by this API, and if you were doing it today you’d use SwiftUI.

So, here’s how I created an app that manages an app refresh task using Swift concurrency and SwiftUI:

  1. Using Xcode 16.4, I created a new project from the iOS > App template, choosing SwiftUI for my interface.

  2. I switched the Swift Language Version build setting to Swift 6. If you’re gonna do Swift concurrency, you might as well have the compiler enforce the rules!

  3. I created an AppModel type to hold the state of my app:

    import Foundation
    import Observation
    
    @Observable
    @MainActor
    final class AppModel {
    
        private(set) var lastUpdate: Date? = nil
    
        private var isUpdating: Bool = false
        
        func updateNow() async {
            do {
                if self.isUpdating { return }
                self.isUpdating = true
                defer { self.isUpdating = false }
    
                try await Task.sleep(for: .seconds(3))
                self.lastUpdate = .now
            } catch {
                // discard errors
            }
        }
    }
    

    IMPORTANT In this app I’m using Task.sleep(for:) to simulate an update task that takes 3 seconds to complete. This supports cancellation, a fact that’ll come in handy below.

  4. I added a appModel property to the content view:

    struct ContentView: View {
        let appModel: AppModel
        …
    }
    
  5. This generated an error compiling the preview. That’s easy enough to fix, but I didn’t need to fix it for this app because I can only test it by running it on a real device. So, just for the sake of speed, I deleted the preview.

  6. I updated ContentView to display the last updated date and add a button that forces an update:

    struct ContentView: View {
        …
        var body: some View {
            VStack {
                HStack {
                    Text("Last Updated")
                    Text(appModel.lastUpdate.map { $0.description } ?? "never")
                }
                Button("Update") {
                    Task {
                        await appModel.updateNow()
                    }
                }
            }
            …
        }
    }
    
  7. I instantiated the app model in my App implementation, and passed it the ContentView initialiser:

    struct QAppRefreshApp: App {
        let appModel: AppModel = AppModel()
        var body: some Scene {
            WindowGroup {
                ContentView(appModel: appModel)
            }
        }
    }
    
  8. I ran the app on an iOS 18.5 device, just to make sure it works. Specifically, tapping Update delays for 3 seconds and then updates the Last Updated label.


With the basics in place, I added support for the app refresh task:

  1. I ran through the steps in Enable and schedule background tasks to set up the app refresh background mode and task identifier. My identifier is com.example.QAppRefresh.refresh.

  2. I added a start() method to my model that registers the handler:

    final class AppModel {
    
        // IMPORTANT: This must match the `Info.plist` value.
        
        let refreshTaskID = "com.example.QAppRefresh.refresh"
    
        func start() {
            BGTaskScheduler.shared.register(
                forTaskWithIdentifier: refreshTaskID,
                using: .main
            ) { task in
                self.handleAppRefresh()
            }
        }
    
        private func handleAppRefresh(appRefreshTask: BGAppRefreshTask) {
            // nothing yet
        }
    
        …
    }
    
  3. And called that in the app startup sequence:

    struct QAppRefreshApp: App {
        var appModel: AppModel = {
            let result = AppModel()
            result.start()
            return result
        }()
        …
    }
    
  4. I extended AppModel to schedule an app refresh:

    final class AppModel {
    
        private var isAppRefreshSchedule: Bool = false
    
        func scheduleAppRefresh() {
            if self.isAppRefreshSchedule { return }
            self.isAppRefreshSchedule = true
    
            do {
                let request = BGAppRefreshTaskRequest(
                    identifier: refreshTaskID
                )
                request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
                try BGTaskScheduler.shared.submit(request)
            } catch {
                // discard errors
            }
        }
    
        …
    }
    
  5. And added a Schedule button that calls the scheduleAppRefresh() method:

    struct ContentView: View {
        …
        var body: some View {
            VStack {
                …
                …
                Button("Schedule") {
                    appModel.scheduleAppRefresh()
                }
            }
            …
        }
    }
    
  6. I filled out the implementation of handleAppRefresh(…):

    final class AppModel {
    
        private func handleAppRefresh(appRefreshTask: BGAppRefreshTask) {
            self.isAppRefreshScheduled = false
            let swiftTask = Task {
                let success = await self.updateNow()
                appRefreshTask.setTaskCompleted(success: success)
                appRefreshTask.expirationHandler = nil
            }
            appRefreshTask.expirationHandler = {
                swiftTask.cancel()
            }
        }
        
        …
    }
    
  7. As I mentioned upthread, the only good way to debug this stuff is with logging, so I added a log handle:

    final class AppModel {
    
        let log = Logger(subsystem: "com.example.QAppRefresh", category: "refresh")
    
        …
    }
    
  8. And then added log points on all the relevant code paths. For example, this is what my scheduleAppRefresh() method actually looks like:

    final class AppModel {
        
        …
        
        func scheduleAppRefresh() {
            if self.isAppRefreshScheduled {
                self.log.debug("will not schedule, already scheduled")
                return
            }
            self.isAppRefreshScheduled = true
    
            do {
                self.log.debug("will schedule, task: \(self.refreshTaskID, privacy: .public)")
                … non-logging code elided …
                self.log.debug("did schedule")
            } catch {
                self.log.debug("did not schedule, error: \(error)")
            }
        }
    
        …
    }
    

    But that’s just an example. I added logging to all the code paths of all the methods that run during the refresh.


And with that, it’s time to test app refresh:

  1. I ran the app on my device.

  2. I tapped the Schedule button. It prints:

    will schedule, task: com.example.QAppRefresh.refresh
    did schedule
    
  3. I used the technique in Starting and Terminating Tasks During Development to simulate a refresh:

    (lldb) e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.example.QAppRefresh.refresh"]
    (lldb) continue
    
  4. I looked in the log and saw this:

    will run, task: com.example.QAppRefresh.refresh
    did run
    will update
    …
    did update
    

    IMPORTANT If you see No task request with identifier com.example.QAppRefresh.refresh has been scheduled, check Settings > General > Background App Refresh. Ask me how I know! |-:

With that working, I tested expiration:

  1. To make things easier, I increase the delay in updateNow() to 30 seconds.

  2. I repeated steps 1 through 3 of the previous list. This time the log shows:

    will run, task: com.example.QAppRefresh.refresh
    did run
    will update
    

    because the update is taking a long time.

  3. I used the technique in Starting and Terminating Tasks During Development to expire the task:

    (lldb) e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"com.example.QAppRefresh.refresh"]
    (lldb) continue
    
  4. I looked in the log again:

    did expire, task: com.example.QAppRefresh.refresh
    did not update, error: Swift.CancellationError()
    

    The app refresh task expired and that cancelled my update task.


Finally, a word about logging. In this example I kept Xcode attached to my app and simulated app refresh and expiration events. That’s fine for this test, but to debug this in real world conditions you have to run you app outside of Xcode. At that point the only way to work out what happened is with logging. I do my logging using the system log. See Your Friend the System Log for lots of advice about that.

In the above I logged using the debug(…) method. That’s fine for this example, but in the real world debug logging is not recorded unless some program like Console is looking for it. And even then it’s not persisted by default. In an app with complex background functionality, there’s an important trade-off:

  • If you log too little, you can’t debug your issues.
  • If you log too much, the system starts discarding your log entries.

Striking the correct balance requires some experimentation.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

I fear you’re getting distracted by the Operation side of this. Operation isn’t required by this API. Its presence in the docs is an artefact of history, in that those docs were written before Swift concurrency was a thing. If you were doing this today, you’d ignore Operation and just use Swift concurrency.

Similarly, the docs are within the UIKit, but that’s also not required by this API, and if you were doing it today you’d use SwiftUI.

So, here’s how I created an app that manages an app refresh task using Swift concurrency and SwiftUI:

  1. Using Xcode 16.4, I created a new project from the iOS > App template, choosing SwiftUI for my interface.

  2. I switched the Swift Language Version build setting to Swift 6. If you’re gonna do Swift concurrency, you might as well have the compiler enforce the rules!

  3. I created an AppModel type to hold the state of my app:

    import Foundation
    import Observation
    
    @Observable
    @MainActor
    final class AppModel {
    
        private(set) var lastUpdate: Date? = nil
    
        private var isUpdating: Bool = false
        
        func updateNow() async {
            do {
                if self.isUpdating { return }
                self.isUpdating = true
                defer { self.isUpdating = false }
    
                try await Task.sleep(for: .seconds(3))
                self.lastUpdate = .now
            } catch {
                // discard errors
            }
        }
    }
    

    IMPORTANT In this app I’m using Task.sleep(for:) to simulate an update task that takes 3 seconds to complete. This supports cancellation, a fact that’ll come in handy below.

  4. I added a appModel property to the content view:

    struct ContentView: View {
        let appModel: AppModel
        …
    }
    
  5. This generated an error compiling the preview. That’s easy enough to fix, but I didn’t need to fix it for this app because I can only test it by running it on a real device. So, just for the sake of speed, I deleted the preview.

  6. I updated ContentView to display the last updated date and add a button that forces an update:

    struct ContentView: View {
        …
        var body: some View {
            VStack {
                HStack {
                    Text("Last Updated")
                    Text(appModel.lastUpdate.map { $0.description } ?? "never")
                }
                Button("Update") {
                    Task {
                        await appModel.updateNow()
                    }
                }
            }
            …
        }
    }
    
  7. I instantiated the app model in my App implementation, and passed it the ContentView initialiser:

    struct QAppRefreshApp: App {
        let appModel: AppModel = AppModel()
        var body: some Scene {
            WindowGroup {
                ContentView(appModel: appModel)
            }
        }
    }
    
  8. I ran the app on an iOS 18.5 device, just to make sure it works. Specifically, tapping Update delays for 3 seconds and then updates the Last Updated label.


With the basics in place, I added support for the app refresh task:

  1. I ran through the steps in Enable and schedule background tasks to set up the app refresh background mode and task identifier. My identifier is com.example.QAppRefresh.refresh.

  2. I added a start() method to my model that registers the handler:

    final class AppModel {
    
        // IMPORTANT: This must match the `Info.plist` value.
        
        let refreshTaskID = "com.example.QAppRefresh.refresh"
    
        func start() {
            BGTaskScheduler.shared.register(
                forTaskWithIdentifier: refreshTaskID,
                using: .main
            ) { task in
                self.handleAppRefresh()
            }
        }
    
        private func handleAppRefresh(appRefreshTask: BGAppRefreshTask) {
            // nothing yet
        }
    
        …
    }
    
  3. And called that in the app startup sequence:

    struct QAppRefreshApp: App {
        var appModel: AppModel = {
            let result = AppModel()
            result.start()
            return result
        }()
        …
    }
    
  4. I extended AppModel to schedule an app refresh:

    final class AppModel {
    
        private var isAppRefreshSchedule: Bool = false
    
        func scheduleAppRefresh() {
            if self.isAppRefreshSchedule { return }
            self.isAppRefreshSchedule = true
    
            do {
                let request = BGAppRefreshTaskRequest(
                    identifier: refreshTaskID
                )
                request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
                try BGTaskScheduler.shared.submit(request)
            } catch {
                // discard errors
            }
        }
    
        …
    }
    
  5. And added a Schedule button that calls the scheduleAppRefresh() method:

    struct ContentView: View {
        …
        var body: some View {
            VStack {
                …
                …
                Button("Schedule") {
                    appModel.scheduleAppRefresh()
                }
            }
            …
        }
    }
    
  6. I filled out the implementation of handleAppRefresh(…):

    final class AppModel {
    
        private func handleAppRefresh(appRefreshTask: BGAppRefreshTask) {
            self.isAppRefreshScheduled = false
            let swiftTask = Task {
                let success = await self.updateNow()
                appRefreshTask.setTaskCompleted(success: success)
                appRefreshTask.expirationHandler = nil
            }
            appRefreshTask.expirationHandler = {
                swiftTask.cancel()
            }
        }
        
        …
    }
    
  7. As I mentioned upthread, the only good way to debug this stuff is with logging, so I added a log handle:

    final class AppModel {
    
        let log = Logger(subsystem: "com.example.QAppRefresh", category: "refresh")
    
        …
    }
    
  8. And then added log points on all the relevant code paths. For example, this is what my scheduleAppRefresh() method actually looks like:

    final class AppModel {
        
        …
        
        func scheduleAppRefresh() {
            if self.isAppRefreshScheduled {
                self.log.debug("will not schedule, already scheduled")
                return
            }
            self.isAppRefreshScheduled = true
    
            do {
                self.log.debug("will schedule, task: \(self.refreshTaskID, privacy: .public)")
                … non-logging code elided …
                self.log.debug("did schedule")
            } catch {
                self.log.debug("did not schedule, error: \(error)")
            }
        }
    
        …
    }
    

    But that’s just an example. I added logging to all the code paths of all the methods that run during the refresh.


And with that, it’s time to test app refresh:

  1. I ran the app on my device.

  2. I tapped the Schedule button. It prints:

    will schedule, task: com.example.QAppRefresh.refresh
    did schedule
    
  3. I used the technique in Starting and Terminating Tasks During Development to simulate a refresh:

    (lldb) e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.example.QAppRefresh.refresh"]
    (lldb) continue
    
  4. I looked in the log and saw this:

    will run, task: com.example.QAppRefresh.refresh
    did run
    will update
    …
    did update
    

    IMPORTANT If you see No task request with identifier com.example.QAppRefresh.refresh has been scheduled, check Settings > General > Background App Refresh. Ask me how I know! |-:

With that working, I tested expiration:

  1. To make things easier, I increase the delay in updateNow() to 30 seconds.

  2. I repeated steps 1 through 3 of the previous list. This time the log shows:

    will run, task: com.example.QAppRefresh.refresh
    did run
    will update
    

    because the update is taking a long time.

  3. I used the technique in Starting and Terminating Tasks During Development to expire the task:

    (lldb) e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"com.example.QAppRefresh.refresh"]
    (lldb) continue
    
  4. I looked in the log again:

    did expire, task: com.example.QAppRefresh.refresh
    did not update, error: Swift.CancellationError()
    

    The app refresh task expired and that cancelled my update task.


Finally, a word about logging. In this example I kept Xcode attached to my app and simulated app refresh and expiration events. That’s fine for this test, but to debug this in real world conditions you have to run you app outside of Xcode. At that point the only way to work out what happened is with logging. I do my logging using the system log. See Your Friend the System Log for lots of advice about that.

In the above I logged using the debug(…) method. That’s fine for this example, but in the real world debug logging is not recorded unless some program like Console is looking for it. And even then it’s not persisted by default. In an app with complex background functionality, there’s an important trade-off:

  • If you log too little, you can’t debug your issues.
  • If you log too much, the system starts discarding your log entries.

Striking the correct balance requires some experimentation.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Thank you for the explanation. I am getting two errors:

Unless I pass task to handleAppRefresh as shown below I get an error as nothing is being passed to it:

self.handleAppRefresh(appRefreshTask: task as! BGAppRefreshTask)

And an error with this line as well:

appRefreshTask.setTaskCompleted(success: success)

"Cannot convert value of type '()' to expected argument type 'Bool'

Background App Refresh
 
 
Q