Tracking down memory leaks in your NavigationStack

Tracking down memory leaks in your NavigationStack

Trailing Closures, with views using data from Property Wrappers when passed to NavigationLink may cause system hangs.

My primary iOS app (BallFields) currently supports its screen navigation through NavigationView and NavigationLink. I had planned to bump the minimum deployment target to 16.0 as part of the next release, and in doing so was transitioning away from the deprecated NavigationView and NavigationLink(destination: ) construct to NavigationStack/NavigationLink(value:) replacement. After studying the documentation and available examples, I anticipated this task to be straightforward and an uncomplicated series of edits.

Three and a half weeks later...

and after hours of frustrating starts and stops I was finally able to commit a robust version.

This article outlines the steps I had to go through to make it work. During the time I spent investigating this issue, it became clear I was not the only one experiencing them and that no one had found an answer to an illusory and opaque problem. I hope only that this post helps others too.

The Basic Architecture of my App

My App provides parents whose children play youth Baseball the opportunity to understand the conditions of the facility they are going to (what the parking is currently like, the toilets, food, shade etc) and players who are playing on those fields information about the playing surfaces (what is the mound like? The infield? The outfield etc). And contribute real-time reports, ratings, commentary and Hidden Gems (think of that great sandwich place just down the road from the field) to the community.

The database of fields, playing surfaces and information about them took over a year to curate and is persisted in Cloud Firestore, which is a cloud base NoSQL database with a wonderful developer experience.

The Navigation within my app is relatively simple. A main TabView contains a series of five NavigationView views that expose different parts of the application functionality.

Should Have been Straightforward

The code replacement was relatively simple. I replaced the NavigationView calls with NavigationStack calls, NavigationLink(destination:) with NavigationLink(value:) and added .navigationDestination sections:

// This code
NavigationView {
    ScoutingView()
}

// Became this code
NavigationStack(path: $scoutingNavPath) {
    ScoutingView()
}

// This code
struct TeamView: View {
    @FirestoreQuery(collectionPath: "teams") var teams: [Team]
    var body: some View {
        VStack {
            List(teams) {team in
                NavigationLink(destination: TeamDetailView(teamID: destination.id ?? "badID", teamName: destination.name ?? "none")){
                    Text("\(team.name ?? "Bad name")")
                }
            }
        }        
        .navigationTitle("Team List")
    }
}

// Became this code
struct TeamView: View {
    @FirestoreQuery(collectionPath: "teams") var teams: [Team]
    var body: some View {
        VStack {
            List(teams) {team in
                NavigationLink(value: team){
                    Text("\(team.name ?? "Bad name")")
                }
            }
        }        
        .navigationDestination(for: Team.self){destination in
            TeamDetailView(teamID: destination.id ?? "badID", teamName: destination.name ?? "none")
        }
        .navigationTitle("Team List")
    }
}

For four out of the five NavigationStack sections, there was no issue. The code was performant, navigated the correct way and showed no evidence of regressions or memory leaks.

However, one of the five sections would just hang. It was repeatable and reproducible irrespective of which way you navigated through that section. On either a device or in the simulator, the app would lock up, utilization would go to 100% and memory usage would continuously climb until the app was terminated. The app was non-responsive.

I won't recount all of the debugging I did however, two points became clear:

  1. the same navigation pathway, to the same views, using NavigationView instead of NavigationStack would work flawlessly.

  2. NavigationStack to the same View(s) presented as a sheet, would work flawlessly.

At first, I suspected using @FirestoreQuery for fetching the data was causing the problem. @FirestoreQuery is a property wrapper that works similar to Apple's @FetchRequest property wrapper, but allows you to easily fetch data from Cloud Firestore. I erroneously focused on that because removing the property wrapper and replacing that code with a listener on an observable object would remove the hangs.

I was wrong.

After profiling with the time profiler and view profiler instrument templates, it was clear that when the link display value was passed as a trailing closure (like the next snippet), where live data was being returned via the property wrapper, and when that data was referred to in views within the link description - in this case a Text view, the views were being constantly redrawn, invalidated and redrawn. View .body invocations would continue to climb during the hang.

    @FirestoreQuery(collectionPath: "teams") var teams: [Team]
    var body: some View {
        VStack {
            List(teams) {team in
                NavigationLink(value: team){
                    Text("\(team.name ?? "Bad name")")
                }
            }
        }      
    }

The inlined app.main() process was stuck in a loop with 99% utilization and the process references to those which served the property wrapper were piling up, consuming memory. The heaviest stack trace for the view refresh came from the protocol witness for ObservableObject.objectWillChange.getter in FirestoreQueryObservable<A> and when you dug further into the stack trace, it was the closure which was the Text View with the data in it, which was triggering the invocation.

If I changed the originating NavigationStack view to a NavigationView view, this behavior didn't happen - but that defeated the original purpose of removing the deprecated function.

When you read Apple's developer documentation, it refers to either using a label in a trailing closure or using a convenience initializer. Changing the NavigationLink to only use the convenience initializer (which removes your ability to create any complex views) broke the view .body update/refresh cycle with the property initializer. That code looks like this, by comparison:

VStack {
    List(viewModel.myTeams) {team in
        NavigationLink(team.name ?? "Bad Name", value: team)
    }
}

When you profile this in instruments, there are no memory leaks and no hangs. The application behaves as expected. I have yet to modify these views to use the label: format, but I do not need to do that.

In getting to the bottom of this, I learned some things and I will approach similar situations differently in the future:

  1. Overconfidence. I underestimated the potential for problems in changing a simple but fundamental piece of the application. This caused me to change more things at once than I should have and extended the solution process.

  2. Haste. For what was a significant new piece of technology (for Apple) I should have done more diligence on other developers' experience, even from Xcode beta releases. I was too easily swayed by the prevalence of blog articles that described how simple and intuitive it was. Those articles are all simplified examples, that isolate one change (Navigation) and don't account for complex view interactions with complex, asynchronous data sources.

  3. Poor Discipline. When something went wrong, my diagnosis was haphazard and unstructured. I should have started with the right tools first - Xcode Instruments - particularly when it's related to view .body invocations.

  4. I didn't seek help. I'm a new, solo developer. It's a part-time hobby for me. There are a lot of really, really experienced people out there willing to help. I'm not just talking about throwing it against the wall on Substack. There are resources everywhere that believe in community and are willing to provide ideas. There is no shame in seeking help.

  5. Blaseness. SwiftUI Views, in the presence of asynchronous data, are amazing. But they are complex and much goes on under the hood to make that amazingness work. I will make profiles of view behavior before I make changes like this next time, to make sure I am thinking through the possibilities of such a change.

These are things I will do differently next time.

My single purpose in writing this is to provide ideas for someone else who may find the same or a similar problem. If you're that person, I hope it saves you some time or provides a direction of investigation you didn't otherwise have.


BallFields - Available in The App Store

Version 1.03 of my app is available in the app store. Version 1.04, with additional functionality, will be submitted in the coming weeks. The app relies on a network effect to be useful (no point being the only one contributing ratings or information about the fields). To date, I have done no marketing - but I plan to do so for the Spring and Summer Youth Baseball and Softball season in the US this year. I am going to be offering promotional codes to allow for six months of use before any subscription starts. I am in the process of developing an Android version right now and I have already built and deployed a web app for the database of fields and team maintenance.

One of the key advantages I found in building it was the Firebase Firestore platform. Authentication, server functions, security and performance were all readily available and have made the client side of it, no matter what the platform, so much easier.

The database behind the app was hand-curated over a year and is ever-expanding. Currently, over 5000 playing surfaces in the US are documented in detail. This short video highlights a few of the key features of the app.