iOS has always had the concept of the “background user task system”: this is how the phone’s operating system grants applications time to run in the background and finish what they were doing. For example, if you were to background a messaging app while it was still processing a message the app can only finish the network calls if the background user task system gives it permission to continue running.

Unfortunately, in some cases this process can cause the app to crash and ruin the user’s experience.

In this post, we’ll cover the following:

  • What causes expired task crashes?
  • Why is solving expired task crashes important?
  • How can developers solve expired task crashes?

What Causes Expired Task Crashes?

First, what happens when an app is forced to suspend and is moved into the background? If the user presses the home button and the app has no running tasks to complete, it suspends immediately. This is the “default” on iOS — immediate background suspension.

Normally, developers prevent this through requesting extra background time by explicitly calling the beginBackgroundTaskWithExpirationHandler or its variants. Doing so grants your app 30 seconds under normal power conditions to finalize any work before the app will be forced to suspend.

This changes the result, and when the app is forced to suspend, the system allocates expiration handlers to expiring modules that gives them permission to finish up their work. After the 30 second period, all the expiration handlers are called back by the system, and the app is given one additional second to finish up any remaining work. If any tasks are still running after this grace period, the application is terminated by the system instead of suspended, resulting in an expired task crash.

Why Is Solving Expired Task Crashes Important?

Although these crashes occur in the background, your user isn’t entirely unaware. Expired task crashes wipe the cache of whatever the user was doing in that app (such as filling out a form) before the app was terminated and can cause data to be lost. When this happens, the next time your user launches the app they are back on the app’s starting screen and not where they left off.

For many apps this isn’t just a bad user experience but can translate to a loss in sales. For example, imagine if your user was in the middle of adding items to their shopping cart in your app, then suspended your app to respond to a text message. They switch back and the app has crashed, needing them to refill their shopping cart again. But this time around they can’t remember what they had previously put in, resulting in them buying only the essentials and causing you to lose out on sales. Or they might no longer trust your app with their credit card information and instead choose to uninstall it.

In the worst case, these crashes can also lead to data corruption. If your task is terminated in the middle of writing data to the disk, that data may now be corrupt and lead to further crashes on subsequent launches. This corruption can build up in your app on user devices without your knowledge, causing the user experience to deteriorate over time.

All of these issues happen because of expired task crashes that could have been caught ahead of time and resolved.

How Can We Solve Expired Task Crashes?

Usually you don’t even know about them — these expired task crashes generate crash reports that are considered private by Apple and are not usually given to developers. Only tools like Embrace with advanced watchdog tracking capabilities can tell you these are happening. Worse still, even if you are aware of the issue, the problem is still difficult to track because Apple hides the name of the task or module from you.

All is not lost. You can use a special testing harness to hook into the background user task system, and through that testing harness log all the relevant start and end events. Using this log of events, you can then figure out exactly what tasks are running for too long after expiration handlers have been called.

Note: This harness should only be used for debugging purposes as it may itself cause crashes if shipped in a production app.

We have included the code for the testing harness in the code block below. You can paste this into any Objective-C @implementation block and it will work. Run your app while this code is included and watch the console.app output from the device.  You can filter the logs to [EMB] to see only task-related logging.

The code works by swizzling the three methods involved in the background user task system and adding pass-through logging to those calls. The logging will capture all tasks created in the application — even by third-party SDKs or Apple’s own internal frameworks — and allow you to log all of the handlers being passed out by the background user task system, including when those handlers are called back and the result. The logging is sent through a serial queue to ensure that the time-order of the events is preserved for easier analysis.

If you would like to see it in action, here’s a github repo with the sample code in action.

Once your app has suspended or terminated, go over the logging and find the task that expired. You’ll see a name or ID that will guide you to the module that created the task that failed to return its expiration handler in time. Fixing that module will fix the expired task crash!

Happy Debugging!

If you just want the testing harness code, it is pasted at the end of this blog post!

How Embrace Helps Mobile Teams

Embrace is an observability and developer analytics platform built for mobile teams. We are a one-stop shop for your mobile app’s needs, including error debugging and monitoring performance and feature releases.

Want to see how Embrace can help your team grow your non-game app with best-in-class tooling and world-class support? Request a customized demo and see how we help teams set and exceed the KPIs that matter for their business!


#import <objc/runtime.h>

IMP originalSelector_beginWithoutName;
IMP originalSelector_beginWithName;
IMP originalSelector_endTask;
dispatch_queue_t _logging_queue;

+ (void)load {
    _logging_queue = dispatch_queue_create("embrace.usertask.logging_queue", DISPATCH_QUEUE_SERIAL);;
    originalSelector_beginWithoutName = class_getMethodImplementation([UIApplication class], @selector(beginBackgroundTaskWithExpirationHandler:));
    method_exchangeImplementations(
                                   class_getInstanceMethod([UIApplication class], @selector(beginBackgroundTaskWithExpirationHandler:)),
                                   class_getInstanceMethod(self, @selector(beginBackgroundTaskWithExpirationHandler:))
                                   );
    originalSelector_beginWithName = class_getMethodImplementation([UIApplication class], @selector(beginBackgroundTaskWithName:expirationHandler:));
    method_exchangeImplementations(
                                   class_getInstanceMethod([UIApplication class], @selector(beginBackgroundTaskWithName:expirationHandler:)),
                                   class_getInstanceMethod(self, @selector(beginBackgroundTaskWithName:expirationHandler:))
                                   );
    originalSelector_endTask = class_getMethodImplementation([UIApplication class], @selector(endBackgroundTask:));
    method_exchangeImplementations(class_getInstanceMethod([UIApplication class], @selector(endBackgroundTask:)),
                                   class_getInstanceMethod(self, @selector(endBackgroundTask:)));
}
- (UIBackgroundTaskIdentifier)beginBackgroundTaskWithExpirationHandler:(void(^ __nullable)(void))handler {
    UIBackgroundTaskIdentifier identifier = ((UIBackgroundTaskIdentifier (*)(id, SEL, void (^ __nullable)(void)))originalSelector_beginWithoutName)(self, _cmd, handler);
    __block NSArray<NSString *> *threads = [NSThread callStackSymbols];
    dispatch_async(_logging_queue, ^{
        NSLog(@"[EMB] beginBackgroundTaskWithExpirationHandler called from %@", threads);
        NSLog(@"[EMB] handler is null? %d", (handler == nil));
        NSLog(@"[EMB] assigned identifier: %tu", identifier);
    });
    return identifier;
}
- (UIBackgroundTaskIdentifier)beginBackgroundTaskWithName:(nullable NSString *)taskName expirationHandler:(void(^ __nullable)(void))handler {
    UIBackgroundTaskIdentifier identifier = ((UIBackgroundTaskIdentifier (*)(id, SEL, NSString*, void (^ __nullable)(void)))originalSelector_beginWithName)(self, _cmd, taskName, handler);
    __block NSArray<NSString *> *threads = [NSThread callStackSymbols];
    dispatch_async(_logging_queue, ^{
        NSLog(@"[EMB] beginBackgroundTaskWithName %@: called from %@", taskName, threads);
        NSLog(@"[EMB] handler is null? %d", (handler == nil));
        NSLog(@"[EMB] assigned identifier: %tu", identifier);
    });
    return identifier;
}
- (void)endBackgroundTask:(UIBackgroundTaskIdentifier)identifier {
    __block NSArray<NSString *> *threads = [NSThread callStackSymbols];
    dispatch_async(_logging_queue, ^{
        NSLog(@"[EMB] endBackgroundTask identifier: %tu, called from: %@", identifier, threads);
    });
    ((void (*)(id, SEL, UIBackgroundTaskIdentifier))originalSelector_endTask)(self, _cmd, identifier);
}