Optimizing iOS App Performance: Reducing Crashes by 35% and Improving Startup Time

Real-world techniques that helped us dramatically improve stability and speed for a delivery app serving 100K+ daily active users.

The Performance Challenge

Performance isn't a feature you build once - it's a discipline. When your app serves 100K+ daily active users across varying network conditions and device generations, every millisecond of startup time and every crash directly impacts user retention and revenue.

Over the past year, we achieved two significant milestones: a 35% reduction in app crashes and a 30% improvement in startup time. Here's exactly how we did it.

Part 1: Crash Reduction

Setting Up Proactive Monitoring

Before fixing crashes, you need visibility. We set up a multi-layered monitoring approach using Crashlytics, Sentry, and Datadog to capture not just crash reports but the full context leading up to each crash.

The key was categorizing crashes by impact. Not all crashes are equal - a crash during checkout is far more damaging than one in a rarely-used settings screen. We prioritized by:

  • Frequency - How many users are affected
  • Business impact - Where in the user journey the crash occurs
  • Reproducibility - Can we consistently trigger it

The Top Crash Culprits

After analyzing our crash data, three categories accounted for nearly 70% of all crashes:

1. Force Unwrapping Optionals

This was our biggest offender. Legacy code was littered with ! operators on values that could legitimately be nil, especially when dealing with API responses.

// Before: Crash waiting to happen
let userName = response.user!.name!
let orderId = order.id!

// After: Safe unwrapping with meaningful fallbacks
guard let user = response.user,
      let name = user.name else {
    Logger.warning("Missing user data in response")
    return
}

// Or using nil-coalescing where appropriate
let displayName = response.user?.name ?? "Guest"

We created a lint rule to flag any new force unwraps in PRs and gradually eliminated existing ones module by module.

2. Memory-Related Crashes

Memory leaks were silently building up pressure until the OS terminated the app. The most common pattern was retain cycles in closures.

// Before: Retain cycle - self is strongly captured
orderService.fetchOrders { result in
    self.orders = result
    self.tableView.reloadData()
}

// After: Weak capture to prevent retain cycle
orderService.fetchOrders { [weak self] result in
    guard let self else { return }
    self.orders = result
    self.tableView.reloadData()
}

We used Instruments' Leaks and Allocations tools to identify the worst offenders, then systematically fixed them. Adding memory monitoring in our debug builds helped catch new leaks early.

3. Threading Issues

UI updates happening on background threads and concurrent access to shared mutable state were causing intermittent crashes that were hard to reproduce.

// Ensuring UI updates happen on the main thread
func updateUI(with data: OrderData) {
    DispatchQueue.main.async { [weak self] in
        self?.titleLabel.text = data.title
        self?.statusView.configure(with: data.status)
    }
}

// Using actors for thread-safe state (Swift 5.5+)
actor OrderCache {
    private var cache: [String: Order] = [:]

    func store(_ order: Order) {
        cache[order.id] = order
    }

    func get(_ id: String) -> Order? {
        cache[id]
    }
}

Part 2: Startup Time Optimization

App launch time is one of the first impressions users get. Research consistently shows that users abandon apps that take too long to load. Our goal was to get to interactive content as fast as possible.

Measuring Before Optimizing

We instrumented our launch sequence to measure each phase: pre-main time, app delegate initialization, first screen render, and time to interactive content.

final class LaunchProfiler {
    static let shared = LaunchProfiler()
    private var timestamps: [String: CFAbsoluteTime] = [:]

    func mark(_ milestone: String) {
        timestamps[milestone] = CFAbsoluteTimeGetCurrent()
    }

    func report() {
        guard let start = timestamps["app_start"],
              let interactive = timestamps["interactive"] else { return }
        let total = interactive - start
        Logger.info("Launch to interactive: \(total)s")
    }
}

Lazy Loading

The biggest win came from deferring non-essential initialization. Not everything needs to be ready at launch. We identified services that could be initialized on first use rather than at startup.

// Before: Everything initialized eagerly at launch
class AppDelegate {
    let analytics = AnalyticsService()
    let locationManager = LocationManager()
    let chatService = ChatService()
    let notificationHandler = NotificationHandler()
    // ... 10+ more services
}

// After: Lazy initialization - load only when needed
class AppDelegate {
    lazy var chatService = ChatService()
    lazy var notificationHandler = NotificationHandler()

    func applicationDidFinishLaunching() {
        // Only initialize critical-path services
        AnalyticsService.initialize()
        LocationManager.shared.startIfAuthorized()
    }
}

Image Optimization

Large images in the initial screens were adding significant time. We implemented progressive loading and ensured images were properly sized for the device.

Reducing Dynamic Library Load Time

Each dynamic framework adds to pre-main time. We audited our dependencies and merged several small frameworks into a single module, reducing the number of dynamic libraries the system needed to load at launch.

Results and Monitoring

After implementing these changes over several release cycles:

  • Crash-free rate improved from ~96% to ~98.5% - That 2.5% difference represents thousands of users who now have a stable experience.
  • Startup time reduced by 30% - Users reach interactive content noticeably faster.
  • Memory footprint reduced - Lower memory usage means fewer OOM terminations, especially on older devices.

Performance optimization is not a one-time project. Build monitoring into your workflow, set performance budgets, and treat regressions as seriously as you would broken features.

Open Source: LaunchProfiler

The launch profiling approach discussed above has been extracted into an open-source library called LaunchProfiler. It provides a milestone-based API to measure startup performance, classify launch times with configurable thresholds (Good/Acceptable/Slow), and generate formatted console reports. Drop it into your project via SPM and start tracking launch regressions from day one.

Key Takeaways

  1. Measure first, optimize second. Without baseline measurements, you can't prove improvements.
  2. Fix crashes by category, not individually. Eliminating patterns (like force unwraps) fixes whole classes of crashes at once.
  3. Lazy load everything that isn't critical path. The fastest code is code that doesn't run at startup.
  4. Make performance visible. Add monitoring dashboards that the whole team can see. When performance is visible, the team naturally protects it.
  5. Test on real devices. Simulators don't reflect real-world performance. Always validate on the oldest supported device.
Back to all posts