Users can push a very large number of view controllers onto a UINavigationController. What does that cost, and how do you optimise it?

7.13.3 · L5 · Interview

Reported at: Google

Predict first

What do the stack, the loaded views and the trimmed stack look like?

import UIKit

final class Page: UIViewController {
    let id: Int
    init(_ id: Int) { self.id = id; super.init(nibName: nil, bundle: nil) }
    required init?(coder: NSCoder) { fatalError() }
    deinit { print("deinit page \(id)") }
}
func spin() { RunLoop.current.run(until: Date().addingTimeInterval(0.3)) }

let nav = UINavigationController(rootViewController: Page(0))
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480))
window.rootViewController = nav
window.makeKeyAndVisible()
spin()
for i in 1...4 { nav.pushViewController(Page(i), animated: false); spin() }   // let UIKit finish each push
print("stack:", nav.viewControllers.map { ($0 as! Page).id })
print("views loaded:", nav.viewControllers.map { $0.isViewLoaded })
nav.setViewControllers([nav.viewControllers[0], nav.viewControllers[4]], animated: false)   // keep the root and the visible screen
spin(); spin()
print("after trim:", nav.viewControllers.map { ($0 as! Page).id })
print("done")
  • stack: [0, 1, 2, 3, 4] / views loaded: all true / after trim: [0, 4]
  • stack: [4] / views loaded: only the last true
  • stack: [0, 1, 2, 3, 4] / after trim: [0, 1, 2, 3, 4]

Mental model. A stack of open books on a desk: every book you leave open takes space. You can close the ones you are not reading and keep a note of the page, or stop at a fixed number of books.

A navigation controller keeps every pushed controller alive in viewControllers until it is popped (Q244), together with whatever each one retains: its model, its images and, once shown, its loaded view. In the run below, after five screens have been pushed and displayed, isViewLoaded is true for all five, so memory grows with the depth of the stack and each controller’s weight. Modern UIKit does not unload the views of buried controllers for you after a memory warning, so a long “drill-down” flow (a feed to a profile to a feed to a profile…) can eventually be killed for memory.

Options, roughly from cheapest to biggest. Cap or trim the stack: when the depth passes a limit, replace the middle with setViewControllers(_:animated:) keeping the root and the visible screen (the check prints [0, 1, 2, 3, 4] becoming [0, 4]), or pop back to an existing screen instead of pushing a duplicate. Make buried screens light: release heavy resources (decoded images, players, large caches) when a screen disappears and rebuild them on appear, and keep state in a model object, not in the view. Model the stack as data: keep a list of routes and create controllers only for the top few, which a coordinator or router makes straightforward (Q354). Do not rely on deinit timing: in a headless run here the trimmed controllers were not released immediately, so confirm real releases with the Memory Graph Debugger and Instruments. Reported at Google (anonymous, unverified).

Every pushed controller stays in the stack with its view loaded once shown; setViewControllers can drop the middle.

Common traps

  • Pushing a new instance of the same screen every time. Return to the existing one in the stack when that is the intent.
  • Assuming UIKit frees the buried views. It does not; the controllers and their views stay until popped.

They might follow up with

  • How do you keep the back button working after trimming? The back button follows the remaining stack, so the screen before the visible one becomes its target; plan the trimmed stack deliberately.
  • How would you measure it? Instruments (Allocations and Leaks) or the Memory Graph Debugger while pushing screens repeatedly.

Related questions

  • 7.4.1 How does UINavigationController manage its stack, and where do the bar items and back button come from?
  • 15.2.1 What is a coordinator or router, and how does it compare with SwiftUI’s NavigationStack for deciding who owns…
  • 7.13.2 Write a function that prints a UIView’s subview tree with indentation, like recursiveDescription. Recursive…

← Write a function that prints a UIView’s subview tree with…