iOS Silent Notification Problem — How they mess up your app's lifecycle and Amplitude session events

  • 21 September 2021
  • 6 replies
  • 94 views

After so many years in iOS development, I realized that I have been bootstrapping new projects wrong from the beginning. As many of the samples you can find online (including 3'rd party SDK integration samples), I too was following this convention to launch my apps;
 

var window: UIWindow?

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Initalize data-structures, prepare for notifications, check launchOptions for routing if available
window = UIWindow(frame: UIScreen.main.bounds)
window?.tintColor = R.color.purple()
window?.backgroundColor = R.color.cloudBlue()
window?.rootViewController = UIViewController()
window?.makeKeyAndVisible()
return true
}

What can go wrong with this? you can find similar samples online where you ditch storyboards and go hero with layout constraints, right? For me, a lot of things did go wrong. Especially if you’re putting content-available in your notifications. AKA: Silent notifications for background fetching.

Apple’s developer documentation defines silent notifications as;

To deliver a background notification, the system wakes your app in the background. On iOS it then calls your app delegate’s application(_:didReceiveRemoteNotification:fetchCompletionHandler:) method. On watchOS, it calls your extension delegate’s didReceiveRemoteNotification(_:fetchCompletionHandler:) method. Your app has 30 seconds to perform any tasks and call the provided completion handler

 

An elegant explanation, but one might easily overlook the detail the system wakes your app in the background. This means that if your app is in an unattached (killed) state your app will be woken up upon receiving a silent notification, and it will call the following delegate methods of AppDelegate;

  1. willFinishLaunchingWithOptions
  2. didFinishLaunchingWithOptions

Let’s try this on debugger via setting “Launch” type from “Automatically” to “Wait for the executable to be launched” and set a breakpoint to first line of `didFinishLaunchingWithOptions`
 

Debugging didFinishLaunchingWithOptions on a silent notification launch

That’s the point where everything goes wrong in my case. I initialize my coordinator and a ViewController responsible for fetching API resources and also sending analytic events and attributes. I highly depend on silent notifications to push minor updates, especially for fetching assets. But that creates a problem in calculating daily active user charts, because at that point analytics tools already logged their first events, especially session events.

 


Peaks in this DAU graph exactly matches the silent notifications timestamps

Accepting the reality that these are not really active users, but faulty app initialization took some courage 😢

 

Solution

 

Simmer down for a second if you have the same problem, embrace the fault, and take a look for Apple’s developer documentation on “Responding to the Launch of Your App

On “didFinishLaunchingWithOptions”, articles state that you should initialize your data structures, verify required resources, prepare notifications, check for launch options for routing, and perform any one-time setups such as initializing 3'rd party SDKs. Also warns you not to run long tasks here. But doesn’t mention anything about the UI.

Let us roll back to the main article and check out the overall lifecycle. There is a great link to “Preparing Your UI to Run in the Foreground”. It explains when you should prepare your UI for both SceneDelegate and AppDelegate approaches. So Apple suggests we prepare the UI in “applicationDidBecomeActive” method. This method is called when your application transitions from killed or background state to foreground state. So I ended up changing how I launch my app to this;

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Initalize data-structures, prepare for notifications, check launchOptions for routing if available
window = UIWindow(frame: UIScreen.main.bounds)
window?.tintColor = R.color.purple()
window?.backgroundColor = R.color.cloudBlueStart()
return true
}

func applicationDidBecomeActive(_ application: UIApplication) {
if window?.rootViewController == nil {
window?.rootViewController = UIViewController()
window?.makeKeyAndVisible()
}
}

Silent notifications no more trigger my coordinator like a regular launch, and doing this also consumes fewer system resources where you really need a background fetch. Hope you find it helpful, any comments or questions are welcome


6 replies

Userlevel 4
Badge +2

Interesting investigation, thanks for sharing!

I just want to note that Amplitude also has tools to deal with this kind of situation: under Govern → Events, you can define each event as Active or Inactive. Those marked Inactive do not cause the user to be counted as an active user. We had a somewhat similar situation in our app, although related to something we were doing, instead of the iOS initializations, and this was how we were able to get our data under control.

@MikkoKarvonen Thanks for the alternative. In my case most of the events were “Session Start” and “Session End” events, sent at almost at the same timestamp or one second later, but there were also edge cases where background fetch takes more time to complete and client sends more flow related events and attributes thus lead me to look for a deterministic solution.

@ilkercam How did you get rid of the automatic “Session Start/End” events? Is it enough just calling makeKeyAndVisible() in applicationDidBecomeActive() rather than application(_didFinishLaunchingWithOptions:) method? Do you still initialize Amplitude SDK in application(_didFinishLaunchingWithOptions:) method or later on in applicationDidBecomeActive() method?
Anyway thank for this very interesting topic. I am amazed this topic is not covered in Amplitude documentation…

Hi @philippec, thanks for reading

Keep in mind that “_didFinishLaunchingWithOptions” is only called when you app is being transitioned from suspended state to foreground or background (silent notifications case). So you still have to prepare your app for a launch (init SDK’s, prepare data structures).

For instance: A device receives a silent notification when the app is in suspended state, thus calling for “didFinishLaunchingWithOptions” first and then “didReceiveRemoteNotification”. Your code handled the silent notification and finished running and transitioned to background state (not back to suspended state). Let’s assume after some time user decides to launch your app; Since the device received a silent notification 5 minutes ago it was already prepared for a foreground transition, so the “didFinishLaunchingWithOptions” method won’t be called again.

So the answer is “Yes” you should prepare amplitude and any other tool for “didFinishLaunchingWithOptions”. But then you might ask; Don’t Amplitude already sets a flag for session start on “initializeApiKey”? That question also came to my mind, and I found that: It doesn’t unless you explicitly set a “startSession” flag

Details on this PR: https://github.com/amplitude/Amplitude-iOS/pull/26

Hello @ilkercam, thanks for your detailed reply.
There is still two things I do not understand:

1) How do you avoid the “Session Start/End” events when a background update push is handled?

2) Should we set the trackingSessionEvents flag after invoking initializeApiKey? Currently my initialization sequence is as follows, and I get the spurious  “Session Start/End” events;
        Amplitude.instance().trackingSessionEvents = true
        Amplitude.instance().initializeApiKey(APIKEY_AMPLITUDE)

@philippec Checking the latest code base on Amplitude iOS SDK (here); Amplitude already does the appropriate check to start/resume a session or not. Of course there are some cases where some still wanna log stuff on silent notification and you have two choices in here:

  1. Send as a regular which will trigger a session start
  2. Send as out of session event
     

See the problem here? Since you initiate the ViewController/Coordinator on “didFinishLaunchingWithOptions”, you may have some events to send on viewDidLoad, and if this initiation is from a silent notification; SDK considers this as the first option, thus triggering a session start/resume.

How I avoid session events on background state is basically; I don’t sent any events traced back to “didFinishLaunchingWithOptions”. It’s a little bit tricky but what Amplitude emphasise is; You have the freedom to sent events even on background state, but If you don’t mark them as out-of-session events it will trigger a session start/resume.

So the idea on is; Don’t trace any event back to didFinishLaunchingWithOptions which might be initalized during a silent notification, the rest is already carefully handled by the SDK.

2.I used the same initalization order as you, on "didFinishLaunchingWithOptions” as well

Reply