BGContinuedProcessingTask launchHandler invocation

I'm trying to understand how the API works to perform a function that can continue running if the user closes the app. For a very simple example, consider a function that increments a number on screen every second, counting from 1 to 100, reaching completion at 100. The user can stay in the app for 100s watching it work to completion, or the user can close the app say after 2s and do other things while watching it work to completion in the Live Activity.

To do this when the user taps a Start Counting button, you'd

1 Call BGTaskScheduler.shared.register(forTaskWithIdentifier:using:launchHandler:).

Question 1: Do I understand correctly, all of the logic to perform this counting operation would exist entirely in the launchHandler block (noting you could call another function you define passing it the task to be able to update its progress)? I am confused because the documentation states "The system runs the block of code for the launch handler when it launches the app in the background." but the app is already open in the foreground. This made me think this block is not going to be invoked until the user closes the app to inform you it's okay to continue processing in the background, but how would you know where to pick up. I want to confirm my thinking was wrong, that all the logic should be in this block from start to completion of the operation, and it's fine even if the app stays in the foreground the whole time.

2 Then you'd create a BGContinuedProcessingTaskRequest and set request.strategy = .fail for this example because you need it to start immediately per the user's explicit tap on the Start Counting button.

3 Call BGTaskScheduler.shared.submit(request).

Question 2: If the submit function throws an error, should you handle it by just performing the counting operation logic (call your function without passing a task)? I understand this can happen if for some reason the system couldn't immediately run it, like if there's already too many pending task requests. Seems you should not show an error message to the user, should still perform the request and just not support background continued processing for it (and perhaps consider showing a light warning "this operation can't be continued in the background so keep the app open"). Or should you still queue it up even though the user wants to start counting now? That leads to my next question

Question 3: In what scenario would you not want the operation to start immediately (the queue behavior which is the default), given the app is already in the foreground and the user requested some operation? I'm struggling to think of an example, like a button titled Compress Photos Whenever You Can, and it may start immediately or maybe it won't? While waiting for the launchHandler to be invoked, should the UI just show 0% progress or "Pending" until the system can get to this task in the queue? Struggling to understand the use cases here, why make the user wait to start processing when they might not even intend to close the app during the operation?

Thanks for any insights! As an aside, a sample project with a couple use cases would have been incredibly helpful to understand how the API is expected to be used.

Answered by DTS Engineer in 860094022

I've answered your specific questions below, but I also talked about these issues on this thread, which you might find useful.

Question 1: Do I understand correctly, all of the logic to perform this counting operation would exist entirely in the launchHandler block...

No, that's not required and, in many cases, it's very likely that very little or even "none" of the work actually happened inside the "task" itself. The pattern used by all of our different task types is that the launchHandler is there to "tell" you the task has started and provide you the BGTask object you use to manage that work. No task type actually requires the work to occur inside the task.

However, what does make BGContinuedProcessingTask different is related to this issue:

Question 2: If the submit function throws an error, should you handle it by just performing the counting operation logic (call your function without passing a task)?

All of the other task types generally "start" work inside the launchHandler because all of those other task types involve performing work in the background, so the launchHandler is telling you "now is the time you should do this background work". That ISN'T true of BGContinuedProcessingTask- your app is already in the foreground and, as we discuss in the documentation and the WWDC presentation, BGContinuedProcessingTask is specifically intended to be used for "critical work" the user has explicitly requested your app "do". Part of that definition clearly implies that deferring/"not doing" the work isn't acceptable, because if that WAS acceptable then you probably shouldn't use BGContinuedProcessingTask!

I think the best way to understand this is to think of this in terms of how an app running on iOS 18 would work and then extend that "flow" to iOS 26. So, an iOS 18 app might have an interaction that worked something like this:

  1. User presses button to start some "longer running work".
  2. App starts long running work.
  3. App enters background.
  4. App saves state to preserve the work it's done (so it can continue later) and suspends.

In many cases, that same flow on iOS 26 should basically work the same. Basically, something like this:

  1. User presses button to start some "longer running work".
  2. App starts long running work.
  3. App starts BGContinuedProcessingTask
  4. App enters background.
  5. BGContinuedProcessingTask expires (or failed to start at all).
  6. App saves state to preserve the work it's done (so it can continue later) and suspends.

In other words, on iOS 26 what happens is either:

  • BGContinuedProcessingTask provides an improved user experience by allowing the work to finish or further progress compared to iOS 18.

OR

  • The app behaves exactly as it did on iOS 18.

Question 3: In what scenario would you not want the operation to start immediately (the queue behavior which is the default), given the app is already in the foreground and the user requested some operation?

Getting the options clear here, the two strategies are "fail" (the default) and "queue". Fail means the task either starts immediately or fails, while queue means that task will wait to start at some future point.

The first thing to understand here is that request failure shouldn't be common. We don't guarantee that the foreground app will "always" be able to start a task; however, in practice, I think it should/will be able to, and, more to the point, if there is some situation where the foreground app is "regularly" unable to start tasks, then that's an issue we'd want to investigate. That means that, in practice, the main reason an app task submission might fail is that the app itself had already started "enough" takes to fill the queue.

With that context, returning to here:

In what scenario would you not want the operation to start immediately?

Off the top of my head, I can think of two different scenarios:

(1) The app tends to generate lots of relatively "short" tasks (say 1s-5s), and the scheduling tends to happen in "bursts". Using "queue" means you can use BGContinuedProcessingTask as your direct work management queue without having to worry about the scheduling of other work types. Yes, that means that sometimes work might take slightly longer to start executing than it otherwise “could," but for very short run work, that often doesn't really matter.

(2) At the other extreme, the app has a significant number of very long-running tasks ("minutes" or even "hours") which are all going to run in parallel and don't really have a predictable endpoint. As a concrete example of this, imagine an iPad acting as the controller/monitor for a collection of external hardware. Instead of trying to manage all of those different work "sources" through a single task, the other option is that they each have their own task identifiers which they independently manage. So, each of them submits their task identifier when they start working and they cancel that task identifier if they happen to finish their work without the continued task having started. Finally, if the task does start then they take over reporting progress to the system but "jumping" their progress state if/when their particular launchHandler fires. This lets you ensure your app stays awake "across" all of those tasks, without having to artificially submit new tasks or creating some complicated mechanism to represent "all" of the work.

On that last point, I think it's worth noting that there doesn't necessarily need to be a "hard" binding between a particular task identifier and a particular operation your app is doing. Another scenario here is if your app is managing a "pool" (say 20 operations) which are happening in parallel but where it's difficult to predict how fast progress will occur until after the job is well underway. One way to do that would be something like the following:

  1. Submit 20 tasks, one for each operation.

  2. Arbitrarily pick operations to provide initial progress to however many tasks actually start.

  3. (Optional) Once "enough" progress has occurred to predict progress, swap the operations chosen in #2 with the fastest operations.

  4. When a task completes and a new task launches, that new task is bound to whatever operation is closest to completion.

The larger point I'd make here is that the "point" of this API isn't really to provide the systems with absolutely accurate information about specific work your app is doing. It's to give the USER good information about what your app is doing on their behalf.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

Accepted Answer

I've answered your specific questions below, but I also talked about these issues on this thread, which you might find useful.

Question 1: Do I understand correctly, all of the logic to perform this counting operation would exist entirely in the launchHandler block...

No, that's not required and, in many cases, it's very likely that very little or even "none" of the work actually happened inside the "task" itself. The pattern used by all of our different task types is that the launchHandler is there to "tell" you the task has started and provide you the BGTask object you use to manage that work. No task type actually requires the work to occur inside the task.

However, what does make BGContinuedProcessingTask different is related to this issue:

Question 2: If the submit function throws an error, should you handle it by just performing the counting operation logic (call your function without passing a task)?

All of the other task types generally "start" work inside the launchHandler because all of those other task types involve performing work in the background, so the launchHandler is telling you "now is the time you should do this background work". That ISN'T true of BGContinuedProcessingTask- your app is already in the foreground and, as we discuss in the documentation and the WWDC presentation, BGContinuedProcessingTask is specifically intended to be used for "critical work" the user has explicitly requested your app "do". Part of that definition clearly implies that deferring/"not doing" the work isn't acceptable, because if that WAS acceptable then you probably shouldn't use BGContinuedProcessingTask!

I think the best way to understand this is to think of this in terms of how an app running on iOS 18 would work and then extend that "flow" to iOS 26. So, an iOS 18 app might have an interaction that worked something like this:

  1. User presses button to start some "longer running work".
  2. App starts long running work.
  3. App enters background.
  4. App saves state to preserve the work it's done (so it can continue later) and suspends.

In many cases, that same flow on iOS 26 should basically work the same. Basically, something like this:

  1. User presses button to start some "longer running work".
  2. App starts long running work.
  3. App starts BGContinuedProcessingTask
  4. App enters background.
  5. BGContinuedProcessingTask expires (or failed to start at all).
  6. App saves state to preserve the work it's done (so it can continue later) and suspends.

In other words, on iOS 26 what happens is either:

  • BGContinuedProcessingTask provides an improved user experience by allowing the work to finish or further progress compared to iOS 18.

OR

  • The app behaves exactly as it did on iOS 18.

Question 3: In what scenario would you not want the operation to start immediately (the queue behavior which is the default), given the app is already in the foreground and the user requested some operation?

Getting the options clear here, the two strategies are "fail" (the default) and "queue". Fail means the task either starts immediately or fails, while queue means that task will wait to start at some future point.

The first thing to understand here is that request failure shouldn't be common. We don't guarantee that the foreground app will "always" be able to start a task; however, in practice, I think it should/will be able to, and, more to the point, if there is some situation where the foreground app is "regularly" unable to start tasks, then that's an issue we'd want to investigate. That means that, in practice, the main reason an app task submission might fail is that the app itself had already started "enough" takes to fill the queue.

With that context, returning to here:

In what scenario would you not want the operation to start immediately?

Off the top of my head, I can think of two different scenarios:

(1) The app tends to generate lots of relatively "short" tasks (say 1s-5s), and the scheduling tends to happen in "bursts". Using "queue" means you can use BGContinuedProcessingTask as your direct work management queue without having to worry about the scheduling of other work types. Yes, that means that sometimes work might take slightly longer to start executing than it otherwise “could," but for very short run work, that often doesn't really matter.

(2) At the other extreme, the app has a significant number of very long-running tasks ("minutes" or even "hours") which are all going to run in parallel and don't really have a predictable endpoint. As a concrete example of this, imagine an iPad acting as the controller/monitor for a collection of external hardware. Instead of trying to manage all of those different work "sources" through a single task, the other option is that they each have their own task identifiers which they independently manage. So, each of them submits their task identifier when they start working and they cancel that task identifier if they happen to finish their work without the continued task having started. Finally, if the task does start then they take over reporting progress to the system but "jumping" their progress state if/when their particular launchHandler fires. This lets you ensure your app stays awake "across" all of those tasks, without having to artificially submit new tasks or creating some complicated mechanism to represent "all" of the work.

On that last point, I think it's worth noting that there doesn't necessarily need to be a "hard" binding between a particular task identifier and a particular operation your app is doing. Another scenario here is if your app is managing a "pool" (say 20 operations) which are happening in parallel but where it's difficult to predict how fast progress will occur until after the job is well underway. One way to do that would be something like the following:

  1. Submit 20 tasks, one for each operation.

  2. Arbitrarily pick operations to provide initial progress to however many tasks actually start.

  3. (Optional) Once "enough" progress has occurred to predict progress, swap the operations chosen in #2 with the fastest operations.

  4. When a task completes and a new task launches, that new task is bound to whatever operation is closest to completion.

The larger point I'd make here is that the "point" of this API isn't really to provide the systems with absolutely accurate information about specific work your app is doing. It's to give the USER good information about what your app is doing on their behalf.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

the launchHandler is there to "tell" you the task has started and provide you the BGTask object you use to manage that work. No task type actually requires the work to occur inside the task.

Thanks Kevin! So I need to think about this a little differently then. Instead of performing the work once you get a BGContinuedProcessingTask, just begin performing the work (the exact same way you do in iOS 18), if a BGContinuedProcessingTask starts persist it and update it as the work completes, and reset state when the work finishes or gets canceled.

Basically, something like this: 1-6

I put together code to demonstrate this approach. Did I understand correctly, this should be all good with this simple counting demo?

class ViewController: UIViewController {
    private let taskIdentifier = "\(Bundle.main.bundleIdentifier!).count_task"
    private let countToNumber: Int64 = 100
    
    private var currentBackgroundContinuedProcessingTask: BGContinuedProcessingTask?
    private var shouldStopCounting = false

    @IBOutlet var progressLabel: UILabel!
    
    @IBAction func startCountingTapped(_ sender: UIButton) {
        performCountWork()
        
        // Register to support background continued processing
        // Ensure this is only registered once otherwise app crashes with exception: Launch handler for task with identifier has already been registered
        if !(UIApplication.shared.delegate as! AppDelegate).didRegisterCountBackgroundContinuedTask {
            (UIApplication.shared.delegate as! AppDelegate).didRegisterCountBackgroundContinuedTask = true
            
            BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: .main) { task in
                guard let task = task as? BGContinuedProcessingTask else { return }
                
                // The background continued processing task has started, use this task to manage the ongoing work
                self.currentBackgroundContinuedProcessingTask = task
                
                task.expirationHandler = {
                    self.shouldStopCounting = true
                }
            }
        }
        
        // Request a background continued processing task start
        let request = BGContinuedProcessingTaskRequest(
            identifier: taskIdentifier,
            title: "Demo Task",
            subtitle: progressText(completedUnitCount: 0, totalUnitCount: countToNumber)
        )
        request.strategy = .fail // Start the task immediately and fail if it cannot
        
        do {
            try BGTaskScheduler.shared.submit(request)
        } catch {
            // This should approximately never happen because the app doesn't submit any more tasks to exceed the system limit
            // But no big deal if it does not start, the user will just have to keep the app open to make progress
            print("Failed to submit task: \(error.localizedDescription)")
        }
    }
    
    @IBAction func stopCountingTapped(_ sender: UIButton) {
        shouldStopCounting = true
    }
    
    private func performCountWork() {
        Task {
            var countedToNumber: Int64 = 0
            
            progressLabel.text = progressText(completedUnitCount: countedToNumber, totalUnitCount: countToNumber)
            
            currentBackgroundContinuedProcessingTask?.progress.completedUnitCount = countedToNumber
            currentBackgroundContinuedProcessingTask?.progress.totalUnitCount = Int64(countToNumber)
            
            while !shouldStopCounting && countedToNumber < countToNumber {
                try! await Task.sleep(nanoseconds: 1_000_000_000)
                
                countedToNumber += 1
                
                progressLabel.text = progressText(completedUnitCount: countedToNumber, totalUnitCount: countToNumber)
                
                if let task = currentBackgroundContinuedProcessingTask {
                    task.progress.completedUnitCount = countedToNumber
                    task.updateTitle(task.title, subtitle: progressText(completedUnitCount: countedToNumber, totalUnitCount: countToNumber))
                }
            }
            
            progressLabel.text = shouldStopCounting ? "Canceled" : "Success!"
            
            currentBackgroundContinuedProcessingTask?.setTaskCompleted(success: !shouldStopCounting)
            currentBackgroundContinuedProcessingTask = nil
            shouldStopCounting = false
        }
    }
    
    private func progressText(completedUnitCount: Int64, totalUnitCount: Int64) -> String {
        return "\(completedUnitCount) / \(totalUnitCount)"
    }
}

This seems to work, minus some behavior I did not expect:

  • The Live Activity appears upon Start Counting while the app is in the foreground, I expected it to not appear until the user backgrounds the app. I'm using iPad Pro 26.0 (23A341). But on iPhone Air with 26.1 (23B5044l) the Live Activity does not appear until I close the app. Perhaps a bug that got resolved?
  • The circular progress bar is spinning indeterminately in the Live Activity, not filling the circle as a percentage of completion (on both devices).

the two strategies are "fail" (the default) and "queue"

BGContinuedProcessingTaskRequest.strategy docs state queue is the default 😅

Thanks Kevin! So I need to think about this a little differently then. Instead of performing the work once you get a BGContinuedProcessingTask, just begin performing the work (the exact same way you do in iOS 18), if a BGContinuedProcessingTask starts persist it and update it as the work completes, and reset state when the work finishes or gets canceled.

I actually think both of those are perfectly reasonable approaches. The "right" answer here really depends on the what you're actually trying to do what what "fits" within your app's architecture.

I put together code to demonstrate this approach. Did I understand correctly, this should be all good with this simple counting demo?

Sure, I don't have any issue with that general approach. There are details I could argue about in a "real" application (notably, you're manipulating UIKit from a background thread), but that's not really relevant here.

This seems to work, minus some behavior I did not expect: The Live Activity appears upon Start Counting while the app is in the foreground, I expected it to not appear until the user backgrounds the app. I'm using iPad Pro 26.0 (23A341). But on iPhone Air with 26.1 (23B5044l) the Live Activity does not appear until I close the app. Perhaps a bug that got resolved?

I believe this is an intentional design choice between iPad OS and iOS, driven by the fact that the larger screen means that presenting the UI is less disruptive and iPad OS has a richer set of options for multitasking between apps.

The circular progress bar is spinning indeterminately in the Live Activity, not filling the circle as a percentage of completion (on both devices).

I'm not sure what's going on, but I think this is a bug in your app, probably caused by this:

try! await Task.sleep(nanoseconds: 1_000_000_000)

Unless I've misread something, that means you're trying to update the UI and progress display 100x per second and the entire process completed in 1s. I'd have to look at progress implementation, but it's possible/likely that it has some logic in it that's keeping it indeterminate because you're not on screen very long and the value is constantly changing.

BGContinuedProcessingTaskRequest.strategy docs state queue is the default 😅

Oops!

__
Kevin Elliott
DTS Engineering, CoreOS/Hardware

I'm not sure what's going on, but I think this is a bug in your app, probably caused by this: [line with Task.sleep] Unless I've misread something, that means you're trying to update the UI and progress display 100x per second and the entire process completed in 1s

The UILabel text and task's progress is updated once every 1 billion nanoseconds (1 second). This code sleeps for 1s to simulate a long running process that updates progress once per second and takes 100 seconds to complete. If you place a print statement after line 70, it is executed 100 times total after 1 minute and 40 seconds.

I'd have to look at progress implementation, but it's possible/likely that it has some logic in it that's keeping it indeterminate because you're not on screen very long and the value is constantly changing.

The indeterminate progress bar is visible (from the get-go and throughout the operation) even when the app stays in the foreground the whole time on iPad.

There are details I could argue about in a "real" application

I would be interested to hear! I do plan to put this in a real app with a slightly different long-running operation ha

(notably you're manipulating UIKit from a background thread)

I don't believe that's the case, setting breakpoints before the while block, in it, and after indicates it's on com.apple.main-thread.

The UILabel text and task's progress is updated once every 1 billion nanoseconds (1 second).

I'm afraid Friday was a very busy day, and yes, I did misread that.

(notably you're manipulating UIKit from a background thread)

I don't believe that's the case. Setting breakpoints before the while block, in it, and after indicates it's on com.apple.main-thread.

Hmm... I guess Task is keeping you on the main thread based on your entry point. I'm not a huge fan of that implicit behavior, but you're right that this should work.

Moving to the original problem:

The indeterminate progress bar is visible (from the get-go and throughout the operation) even when the app stays in the foreground the whole time on iPad.

I would try:

  • Set totalUnitCount before you set completedUnitCount. I'm not sure why this would matter, but both operations kick off a KVO cycle, and setting completedUnitCount first means you're sending "0/0-> indeterminate" while totalUnitCount first means you’re sending "0/100-> a valid start point".

  • Directly setting progress "ahead" (say to "5") in your launchHandler, just to validate that it "works" in the most basic sense.

  • Moving "performCountWork" into the launchHandler. This is not required, but I want to find out if that fixes it.

  • Add a "long" delay (say 5s+) between the time you submit the background task and when you start your performCountWork Task.

The point of the last two tests is to delay that start of your task long enough that self.currentBackgroundContinuedProcessingTask is "set" before you actually enter your Task. Related to that point, I would basically use "self.currentBackgroundContinuedProcessingTask" instead of "currentBackgroundContinuedProcessingTask" in every context.

Finally, I'd take a look at "progress" in the debugger and see how it's actually set/configured. What values does it return if you retrieve the values after you set them? And do they stay "stuck" that way?

If you haven't sorted it out after that, then please file a bug, attach your test project to the bug, and then post the bug number back here.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

Thanks Kevin! Moving performCountWork into the launchHandler (instead of before register) fixed it, simply because the call to set the totalUnitCount did not happen because currentBackgroundContinuedProcessingTask is nil at that time. Makes sense why it was indeterminate, it was never given a total! 😄

performCountWork still doesn't have to be called in the launchHandler, as long as I have the data to be able to set the total unit count when the task gets created at any time during the already in-flight operation, that approach works too. If I did that though, that brings up a question, what if the operation completes before the launchHandler is invoked (if I were to use the queue strategy)? A task would start for work that's already completed and thus wouldn't get progress updates and setTaskCompleted wouldn't be invoked.

It seems much more straightforward to use the fail strategy and perform the work in the launchHandler (and if it the submit request fails perform the work without a BGContinuedProcessingTask as we do on iOS 18), at least for my simple use case.

The only odd thing I'm seeing now is it animates indeterminately until completedUnitCount is set to a new value. In this example, that happens after just 1 second, so the switch from indeterminate to determinate looks janky. I set total to 100 and completed to 0 when the task starts, but it animates indeterminately until completedUnitCount is set to 1. If I set completed to 1 from the beginning, it animates indeterminately until completed is set to 2 (even if it gets set to 1 again). Any ideas to prevent the indeterminate state always show determinate progress?

The only odd thing I'm seeing now is that it animates indeterminately until completedUnitCount is set to a new value.

I believe this was basically an "aesthetic" design choice intended to minimize the fairly common issue where initial setup delays mean that progress stalls briefly at "just starting", THEN starts moving at a steadier clip (as your app actually starts doing work).

Any ideas to prevent the indeterminate state from always showing determinate progress?

I'm not afraid of applying a bit of brute force, so I'd probably set it to "0" then set it to "1". You can also set totalUnitCount to "101" if you don't like how that "loses" a number. I'll also note that the "correct" value for totalUnitCount is often better determined by how well you can divide up your progress and what results in "smooth" looking progress, NOT "the work your actually doing".

This is very much a situation where everything is made up and the points don't matter.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

Thanks for all your help Kevin! Really appreciate it, this will be a nice upgrade for my app.

BGContinuedProcessingTask launchHandler invocation
 
 
Q