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