BGContinuedProcessingTask register block not called, submit does not throw an error

I implemented BGContinuedProcessingTask in my app and it seems to be working well for everyone except one user (so far) who has reached out to report nothing happens when they tap the Start Processing button. They have an iPhone 12 Pro Max running iOS 26.1. Restarting iPhone does not fix it.

When they turn off the background processing feature in the app, it works. In that case my code directly calls the function to start processing instead of waiting for it to be invoked in the register block (or submit catch block).

Is this a bug that's possible to occur, maybe device specific? Or have I done something wrong in the implementation?

func startProcessingTapped(_ sender: UIButton) {
    if isBackgroundProcessingEnabled {
        startBackgroundContinuedProcessing()
    } else {
        startProcessing(backgroundTask: nil)
    }
}

func startBackgroundContinuedProcessing() {
    BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: .main) { @Sendable [weak self] task in
        guard self != nil else { return }
        startProcessing(backgroundTask: task as? BGContinuedProcessingTask)
    }
    
    let request = BGContinuedProcessingTaskRequest(identifier: taskIdentifier, title: title, subtitle: subtitle)
    request.strategy = .fail
    if BGTaskScheduler.supportedResources.contains(.gpu) {
        request.requiredResources = .gpu
    }
    
    do {
        try BGTaskScheduler.shared.submit(request)
    } catch {
        startProcessing(backgroundTask: nil)
    }
}
 
func startProcessing(backgroundTask: BGContinuedProcessingTask?) {
    // FIXME: Never called for this user when isBackgroundProcessingEnabled is true
}

I submitted a bug report FB21052216

Everyone except one user (so far) who has reached out to report nothing happens when they tap the Start Processing button.

First off, how do you "know" that? And what other devices have you tested on? That particular device does NOT have a dynamic island, so starting a task doesn't necessarily have any "visible" impact. The task DOES start, but you won't see any system UI unless the app is backgrounded or there are other tasks active.

Next, moving to the bug here:

I submitted a bug report FB21052216

If your user is willing to help, what we really need is a sysdiagnose. The basic process is:

The only warning here is to not reboot the device until the sysdiagnose is captured, since the reboot will lose log data.

Jumping back to your earlier post:

Or have I done something wrong in the implementation?

I don't see any obvious mistake in your implementation, but there were two things I would double-check:

  1. Remember that taskIdentifier needs to be unique to each task. If you try to run the same ID "again", the second submit won't work.

  2. Similarly, if you're running multiple tasks, make sure that startProcessing() accounts for that and can track "all" of them.

Is this a bug that's possible to occur, maybe device-specific?

It's unlikely to be “device-specific" (meaning, iPhone 12s don't work), but it's certainly possible that there's an issue (or bug) with their particular device configuration. One thing I would check here is "Settings.app-> General-> Background App Refresh". That setting is tied to the BackgroundTask framework, and it's possible that disabling it would also disable BGContinuedProcessingTask.

Having said that:

When they turn off the background processing feature in the app, it works. In that case, my code directly calls the function to start processing instead of waiting for it to be invoked in the register block (or submit catch block).

...keep in mind that your app doesn't actually have to wait for the request to fire before it starts its work. As I wrote extensively about on this thread, nothing requires your app to tie its work to the task handler and, in fact, there are many cases where doing so is probably a mistake. If your app is going to do the work "no matter what", then waiting for the handler to fire is just adding additional complexity, delays, and testing load. I'd start the work "immediately" and update BGContinuedProcessingTask if/when it starts. That ensures there's only one logic path you need to test and that your app fails "nicely".

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

Thanks Kevin!

how do you "know" that? And what other devices have you tested on? That particular device does NOT have a dynamic island, so starting a task doesn't necessarily have any "visible" impact. The task DOES start, but you won't see any system UI unless the app is backgrounded or there are other tasks active.

The user shared a screen recording where tapping the start processing button did not start processing (which I have not replicated on my devices). Reviewing the code, I'm quite positive the only way that's possible is if the register launchHandler is not invoked nor the catch block entered from submitting the request because when one of those occurs, I present in-app UI that shows progress in addition to updating the background task progress (if non-nil) in the implementation of the startProcessing() func which didn't get called because in-app UI did not appear in the screen recording.

If your user is willing to help, what we really need is a sysdiagnose.

I asked them but they said the issue is no longer occurring 😮 I inquired if anything had changed, any device settings, low power mode enabled state or anything like that, but nothing they can think of. I suspect no one else is going to reach out to report the issue because I quickly put a workaround in place to call startProcessing(backgroundTask: nil) if it is not called within 1.5s of submitting the request. 😅

Remember that taskIdentifier needs to be unique to each task. If you try to run the same ID "again", the second submit won't work.

Yes they are unique.

Similarly, if you're running multiple tasks, make sure that startProcessing() accounts for that and can track "all" of them.

I don't support running multiple tasks simultaneously, after one completes the user could start another one if desired.

it's certainly possible that there's an issue (or bug) with their particular device configuration. One thing I would check here is "Settings.app-> General-> Background App Refresh". That setting is tied to the BackgroundTask framework, and it's possible that disabling it would also disable BGContinuedProcessingTask.

Testing this on my devices, when I turn off Background App Refresh, the register launchHandler is still called immediately. If there were an issue with the device configuration, wouldn't the submit request throw an error since the strategy is set to fail if it can't be started immediately? Or are there scenarios where it's expected behavior for an error to not be thrown and yet the launchHandler won't be called?

your app doesn't actually have to wait for the request to fire before it starts its work ... If your app is going to do the work "no matter what", then waiting for the handler to fire is just adding additional complexity, delays, and testing load. I'd start the work "immediately" and update BGContinuedProcessingTask if/when it starts.

For my simple use case it seems much more straightforward to perform the work once the launchHandler is invoked (or if the submit request fails perform the work without a BGContinuedProcessingTask). I can directly pass a BGContinuedProcessingTask or nil to the function that does the work, as opposed to needing to persist the BGContinuedProcessingTask when it starts and clean it up upon completion/cancelation/failure. This also avoids a seemingly complicated scenario: what do you do if the register launchHandler is invoked after its work had already completed/was canceled/failed, unclear what you'd do with that BGContinuedProcessingTask, shouldn't persist it, there's not a way to "cancel" it. State management for that seems very complicated. Tis much more straightforward to set the strategy to fail and begin processing once it starts or fails to start, since one of those is supposed to happen immediately when strategy is .fail, and that's a totally valid approach for using this API, is was what I understood.

I present an in-app UI that shows progress in addition to updating the background task.

Perfect, that makes sense then. I just wanted to make sure this wasn't a case of overlooking an edge case.

Reordering things a bit for clarity:

If there were an issue with the device configuration, wouldn't the submit request throw an error since the strategy is set to fail if it can't be started immediately?

Theoretically, yes, but that's tricky to be sure about. Architecturally, what actually happens is a two-stage process where you:

  1. Submit your task into the duet*

  2. duet then send that task back to your app to actually run.

For everything except BGContinuedProcessingTask, those two stages are "inherently" separate— that is, you'd basically "never" see a refresh or a processing task fire "immediately" after submission, since the point of both tasks is to run work "later".

*The daemon that manages the BackgroundTask framework

Moving to here:

Or are there scenarios where it's expected behavior for an error to not be thrown and yet the launchHandler won't be called?

The term "expected" here is tricky. In terms of the API "contract", the expectation is that a request set as "fail" should either start "immediately" or return an error.

However, in terms of what will actually happen in ALL cases... I don't know. Looking through our code, I'm fairly confident that stage #1 ("submit") will always either "succeed" (meaning, duet accepts the job) or "fail" (meaning, it returns an error), but the internals of stage #2 are much harder to follow.

I talked to the engineer lead about this as well, and his take was largely the same as mine— this should definitely not be happening, but the code involved is complicated enough that it's hard to be sure that there isn't a bug "somewhere".

That leads to here:

I asked them, but they said the issue is no longer occurring 😮 I inquired if anything had changed, any device settings, low power mode enabled state, or anything like that, but nothing they can think of. I suspect no one else is going to reach out to report the issue because I quickly put a workaround in place to call startProcessing(backgroundTask: nil) if it is not called within 1.5s of submitting the request. 😅

Unfortunately, my guess as to what happened is something like this:

  1. duet accepted the job because it did think it was/could run the job.

  2. Some internal state/issue meant it couldn't run the job NOW, so it delayed starting it.

  3. Whatever caused #2 should have been short/temporary, so it didn't fail the job.

  4. Whatever happened at #3 didn't clear, so the job never started.

Of course, that's just one possibility/guess here. Without a sysdiagnose to see what actually happened, this is just speculation.

One other thought that just caught my eye is in your register call here:

...[weak self] ...

What object is this? And is there ANY possibility that this object might deallocate?

For my simple use case, it seems much more straightforward to perform the work once the launchHandler is invoked (or if the submit request fails, perform the work without a BGContinuedProcessingTask).

OK. All of this is ultimately your own judgment call. My main comment here is that you're creating a situation where entangling the two processes is causing complexity to proliferate. You started with 2 code paths and are now technically at 4:

  1. No BGContinuedProcessingTask (iOS 18/disabled).

  2. BGContinuedProcessingTask works correctly (iOS 26 "expected" behavior).

  3. BGContinuedProcessingTask doesn't start within 1.5s (your workaround for the failure we're talking about) and doesn't start at all.

  4. BGContinuedProcessingTask starts AFTER 1.5s (so #3 has occurred).

That's a lot to test and think through. The alternative approach is to separate your work from BGContinuedProcessingTask, so that you now have two "unrelated" code paths that are "weakly" connected:

  1. Performing your actual work.

  2. Your BGContinuedProcessingTask management, which works by "monitoring" #1 but isn't actually integrated with it.

If you set things up right, #2 is simply hooking into existing functionality of #1 that's already being used by other parts of your app— the progress object is that same progress object #1 already uses, expiration is just save/cancel/etc.

This also avoids a seemingly complicated scenario: what do you do if the register launchHandler is invoked after its work had already completed/was canceled/failed, unclear what you'd do with that BGContinuedProcessingTask, shouldn't persist it, there's not a way to "cancel" it.

This one’s actually an easy one. When the work is "done", then you call setTaskCompleted(success:). That's true regardless of how/why the job "finished". Also, in the case of BGContinuedProcessingTask, I think you'll basically always send "true". You pass "false" when you need the system to run to your task "again", but that doesn't apply to BGContinuedProcessingTask.

However, that does lead me to one other "code level":

  • Your snippet doesn't include setting an expiration handler. Do you set one somewhere else?

  • Do you call setTaskCompleted(success:) and do you always pass in "true"?

  • Are you reusing the same task identifier?

Speculating off the top of my head, if you're using the same task ID AND the task was marked as "failed" (either because you marked it or because you didn't set an expiration handler), then it's possible that could create the situation you saw. The "fail" mark might keep the task "live" in Duet, but we wouldn't fire the task again (because we don't refire BGContinuedProcessingTasks). That would absolutely be "a bug", but it would also require some API mistakes on your part.

If it's possible this is happening in your app, I'd appreciate you testing this particular case and letting me know what you find.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

In terms of the API "contract", the expectation is that a request set as "fail" should either start "immediately" or return an error.

👍

my guess as to what happened is something like ... 4. Whatever happened at #3 didn't clear, so the job never started.

A couple thoughts here: maybe the job would have started if the user waited additional time, it didn't start within a few seconds in the recording, I don't know if it never would have started. Seems likely though because they didn't report it start processing randomly at some point in the future. If there is some system bug, something funky with the daemon or otherwise, I'm surprised restarting the iPhone did not change the behavior, but waiting some time and trying it again got it to work. (Note I'm taking the user's word on that.)

Without a sysdiagnose to see what actually happened, this is just speculation.

I'll see what I can do to get one captured. Perhaps instead of starting automatically after 1.5s I'll ask them to contact support and provide a button to continue without background processing.

What object is this? And is there ANY possibility that this object might deallocate?

It's the "main" view controller in the app that registers the block and performs the work. It can deallocate in uncommon circumstances (eg closing the app and triggering a Home Screen quick action will recreate the root view controller).

If you set things up right, #2 is simply hooking into existing functionality of #1 that's already being used by other parts of your app— the progress object is that same progress object #1 already uses, expiration is just save/cancel/etc.

I'm struggling to follow this. It would be incredibly helpful to have a sample project with an example implementation showing the different approaches this API supports :)

When the work is "done", then you call setTaskCompleted(success:).

The complicated scenario I have in mind with the "start work first then request background continued processing task" approach is if the work were to complete before the register launchHandler got called. When the work gets "done" there is no background task persisted because it hasn't started yet and thus can't call that, then later it'd start for the work that is already done.

in the case of BGContinuedProcessingTask, I think you'll basically always send "true". You pass "false" when you need the system to run to your task "again", but that doesn't apply to BGContinuedProcessingTask.

I think that's not correct. You're supposed to send false when your work encounters an error and that will show the user it failed in the Live Activity, and the system won't run it again, is what I understand.

Your snippet doesn't include setting an expiration handler. Do you set one somewhere else?

I do in the real app yep, set inside the startProcessing function.

Do you call setTaskCompleted(success:) and do you always pass in "true"?

I do inside startProcessing after the task completes, passing true if successful and false if an error occurred.

Are you reusing the same task identifier?

No, it has a dynamic suffix using the wildcard notation that's unique down to the milliseconds* at the time the user tapped the button to start processing.

*I did originally use seconds (which was in place when this user encountered the problem). When the task didn't start, some users rage clicked the start button resulting in the register block getting called again with the same number of seconds, which crashed the app because it's a fatal exception to register the same identifier twice in the same session. Switched to ms with the update that put in the 1.5s manual workaround fix. I've encountered a couple surprises shipping this! 😆 Note I've received 25 of these crash reports, so I know this issue has happened (more than) that many times, because when startProcessing is invoked my in-app progress UI prevents tapping the button again, which means my in-app UI did not appear before the user was able to trigger it again, hence the launchHandler wasn't invoked nor was an error thrown immediately. Sadly none of those users reached out to support or I'd ask them to capture a sysdiagnose.

A couple of thoughts here: maybe the job would have started if the user waited additional time; it didn't start within a few seconds in the recording. I don't know if it never would have started.

No, it should have started "immediately" (<1s, probably a lot less) or failed. If it were to start substantially "later", then that would just be a different-flavored bug.

I'm struggling to follow this. It would be incredibly helpful to have a sample project with an example implementation showing the different approaches this API supports :)

Let me try explaining this a slightly different way. Don't think of "BGContinuedProcessingTask" as "a thing that does work“; think of it as a part of your app that presents UI "through" (using the Progress object) and that you might receive some events "from" (because of expiration and/or cancellation). Similarly, you have an engine that "does work" and which provides progress, supports cancellation, etc. You then build those two components such that they minimize how "actively" they rely on each other. So your engine will happily send progress to the task but doesn't really care if that BGContinuedProcessingTask doesn't actually exist at any given moment. Similarly, the task can trigger "cancel", but that goes through exactly the same code path normal use cancellation would.

Finally here:

The complicated scenario I have in mind with the "start work first then request background continued processing task" approach is if the work were to complete before the register launchHandler got called.

First off, this shouldn't REALLY happen. Generally speaking, any work you're tying to BGContinuedProcessingTask should take at LEAST 20-30s to complete; otherwise, you'd just use UIApplication.beginBackgroundTask(...) so you can always finish the work. However, IF it were to happen, it can just immediately complete the task.

That leads to here:

You're supposed to send false when your work encounters an error, and that will show the user it failed in the Live Activity.

You're right, that's a case where "false" might be appropriate. However, I think the word "supposed" is a mistake here. The system does NOT actually care what you do here, so the correct approach is to do what looks/feels/works best for your app. Putting that another way, the reason you return "false" isn't "something went wrong", it's "something went wrong AND I want the system to use its UI to show that". Those aren't necessarily the same thing.

and the system won't run it again, is what I understand.

That is correct.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

First off, this shouldn't REALLY happen. Generally speaking, any work you're tying to BGContinuedProcessingTask should take at LEAST 20-30s to complete; otherwise, you'd just use UIApplication.beginBackgroundTask(...) so you can always finish the work. However, IF it were to happen, it can just immediately complete the task.

My app's use case could take as short as 1 second or as long as thousands of seconds. It's a photo editing app where the user could select for example 1 photo or 1,000 photos to process. Even with 1 photo it could take for example 20 seconds to download the full quality photo from iCloud before it can be edited, so it's unpredictable how long it will take even though the number of photos is known upfront.

I'll work to get a sysdiagnose to add to the bug report, after that I'll try to rework the approach to start processing and then submit the request. Thanks for all your help Kevin!

Hey @DTS Engineer! I got a sysdiagnose and the logs are interesting.

The user tapped the button to start processing and the launch handler was not invoked nor was an error thrown after waiting 2 seconds. Relevant logs I see:

default	2025-12-13 15:54:32.261450 +0000	AppName	Daemon connection established with BGTaskScheduler client
default	2025-12-13 15:54:32.261461 +0000	dasd	Queried for device resource capabilities
default	2025-12-13 15:54:32.261511 +0000	dasd	Device Background GPU signals: supportsGraphicsSet: YES, hasSwap: NO
default	2025-12-13 15:54:32.261553 +0000	AppName	submitTaskRequest: <BGContinuedProcessingTaskRequest: com.jordanhipwell.datestamp.stamp_task.787334072261, (title: Preparing to Stamp…, subtitle: 1 Photo, resources: Default, submissionStrategy: Fail)>
default	2025-12-13 15:54:32.261606 +0000	AppName	Submitting task request activity: <private>
error	2025-12-13 15:54:32.262042 +0000	dasd	bgContinuedProcessing-com.jordanhipwell.datestamp.stamp_task.787334072261: Foregrounded apps (<private>) don't include expected identifier: <private>

This is from my Info.plist:

<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
    <string>$(PRODUCT_BUNDLE_IDENTIFIER).stamp_task.*</string>
</array>

And PRODUCT_BUNDLE_IDENTIFIER in the project is com.jordanhipwell.datestamp.

I've uploaded the full sysdiagnose to FB21052216.

BGContinuedProcessingTask register block not called, submit does not throw an error
 
 
Q