How We Reduced an App's Crash Rate from 2% to 0.1%
How We Reduced an App's Crash Rate from 2% to 0.1%
The product manager pulled up the App Store reviews on her laptop and turned the screen toward me. Two stars. "Crashes every time I check my balance." One star. "App freezes then closes." Two stars. "Used to work fine, now unusable." One star, just three words: "Fix your app."
The app was a retail banking platform serving roughly 400,000 monthly active users. It had launched two years earlier to solid reviews, 4.5 stars, praised for its clean design and fast performance. But over the past six months, the rating had dropped to 3.1 and was still falling. The crash rate, which they'd never actively monitored, turned out to be hovering around 2%. That means roughly 8,000 user sessions per month were ending in a crash. For a banking app, where trust is everything, this was an existential problem.
They brought me in to fix it. What followed was a three-month engagement that took the crash rate from 2% to 0.1%, and taught me a few things about the nature of stability in production apps.
Starting in the Dark
The first problem was that we couldn't see what was happening. The app had a crash reporting SDK installed, but it was two major versions behind and hadn't been configured correctly. It was capturing crashes, but the symbolication was broken, so every crash report showed raw memory addresses instead of readable function names and line numbers. Looking at the dashboard was like trying to read a book in a language you don't speak. You could see that something was there, but you couldn't extract meaning from it.
Before we could fix anything, we had to fix our ability to see. We updated the crash reporting SDK, configured symbolication properly with the correct dSYM upload step in the CI pipeline, and added breadcrumb logging so we could trace the sequence of user actions leading up to each crash. We also added performance monitoring to track memory usage, CPU load, and network response times in real time.
This took about a week. It felt frustrating to spend the first week on instrumentation when the app was actively crashing for thousands of users. But it was the right call. You can't fix what you can't see, and guessing at crash causes in a codebase with two years of accumulated complexity would have been a waste of time.
Once the new instrumentation was live, we waited three days to collect enough data to see real patterns. What we found was surprising.
The Three Root Causes
I expected to find a handful of obvious bugs, null pointer dereferences, force-unwrapped optionals, the usual suspects. Instead, the crashes fell into three categories, each with a different root cause and a different fix.
Thread Safety Issues with Core Data
The single largest source of crashes, accounting for about 45% of the total, was Core Data. The app used Core Data as its local persistence layer, caching account data, transaction history, and user preferences. The original architecture was straightforward: a single managed object context used across the app.
The problem was that this context was being accessed from multiple threads simultaneously. When the app fetched new data from the API, it wrote to Core Data on a background thread. Meanwhile, the UI was reading from the same context on the main thread. Core Data's managed object contexts are not thread-safe. Concurrent access doesn't always crash, which is exactly what makes it dangerous. It crashes intermittently, under specific timing conditions, which is why it wasn't caught during development or QA. The crashes manifested as EXC_BAD_ACCESS errors deep in Core Data's internal methods, which made them look mysterious until we understood the pattern.
The fix required restructuring the Core Data stack. We implemented a proper parent-child context architecture, with a private background context for writes and a main queue context for UI reads. We added merge notifications so the UI context stayed current with background changes. And we added thread assertions in debug builds, a dispatchPrecondition check that would crash immediately and clearly during development if any code accessed a context from the wrong thread.
This was the most time-consuming fix, about three weeks of careful refactoring and testing. Core Data threading bugs are notoriously difficult because the fix touches every part of the app that reads or writes data.
Memory Pressure on Older Devices
The second category, about 30% of crashes, was more subtle. These were SIGKILL terminations from the operating system, which happen when the system kills your app for using too much memory. These don't show up as traditional crashes in all reporting tools, which is part of why they'd gone unnoticed.
The app's memory usage was fine on current devices, an iPhone 15 with 6GB of RAM had no trouble. But a significant portion of the user base was on older devices, iPhone SE second generation and iPhone XR models with 3GB of RAM. On those devices, loading a long transaction history, which involved parsing a large JSON response, creating model objects, and rendering a long table view, would push memory usage past the threshold where iOS would terminate the app.
The fix was a combination of approaches. We implemented pagination for transaction history, loading fifty transactions at a time instead of the full history. We switched from creating full model objects upfront to a lazy loading approach where we parsed only the data needed for the visible cells. And we added a memory warning handler that would release cached images and non-essential data when the system signaled memory pressure.
We also added device-tier awareness to our app performance monitoring, segmenting crash rates by device model and iOS version. This visibility was invaluable. It showed us that 80% of our memory-related crashes were happening on just three device models, which helped us prioritize and test our fixes effectively.
A Third-Party SDK Leaking File Handles
The third category was the strangest. About 15% of crashes were occurring in a third-party analytics SDK that we hadn't touched in over a year. The stack traces pointed to file I/O operations inside the SDK, and the crashes were clustered among users who had the app installed for a long time and used it frequently.
After some investigation, including reaching out to the SDK vendor and doing our own analysis of the SDK's behavior, we discovered that the SDK was opening file handles for its local event cache but not always closing them on certain error paths. Over time, the app would accumulate open file handles until it hit the per-process file descriptor limit, at which point any file operation, including ones in our own code and in Core Data, would fail catastrophically.
This was a good reminder that your app's stability depends on every piece of code running in your process, including code you didn't write. The fix was updating to a newer version of the SDK where this bug had been fixed, but we also added monitoring for file descriptor usage so we'd catch similar issues in the future. We built a simple diagnostic that logged the open file descriptor count periodically and alerted if it exceeded a threshold.
The remaining 10% of crashes were a mix of smaller issues: a few force-unwrapped optionals that could theoretically be nil in edge cases, a race condition in the app's deep linking handler, and some crashes in system frameworks triggered by specific combinations of iOS version and device state. We fixed each one individually.
The Monitoring Setup That Keeps It Stable
Getting to 0.1% was satisfying. Staying there is the harder problem. Crash rates tend to creep back up over time as new features are added, new SDKs are integrated, and new developers join the team who aren't aware of the historical context. Without active monitoring, you don't notice the regression until the App Store reviews start dropping again.
We set up a multi-layered monitoring approach that the team has maintained since our engagement ended.
For teams working in fintech development, this kind of monitoring infrastructure isn't optional. Financial apps operate in an environment where a crash doesn't just annoy a user. It erodes trust in the institution behind the app. A social media app can get away with occasional instability. A banking app cannot.
What I Learned
This engagement reinforced something I'd known intellectually but hadn't felt as viscerally before: the most damaging bugs aren't the ones that are hard to fix. They're the ones that are hard to see.
Every one of our root causes was, in retrospect, a well-known category of bug. Thread safety with Core Data is documented extensively. Memory management on constrained devices is a solved problem. Third-party SDK risks are discussed in every conference talk about app architecture. But knowing that these categories exist and finding the specific instances in a two-year-old codebase under production load are very different things.
The investment in instrumentation, the boring first week where we just set up crash reporting and monitoring, was the highest-leverage work we did. Without clear visibility into what was actually happening on user devices, we would have been guessing. And in a codebase of that size, guessing wastes weeks.
The other lesson was about the relationship between stability and features. Before we started, the team had been shipping a new feature every two weeks. During our engagement, we paused feature work entirely for six weeks. The product manager was nervous about this, but the leadership team supported it because they understood the math. A 2% crash rate was costing them users faster than new features were acquiring them. Fixing stability wasn't taking time away from growth. It was a prerequisite for growth.
Stability isn't a feature. It's the foundation everything else is built on. An app that doesn't crash isn't remarkable. Nobody leaves a five-star review saying "this app didn't crash today." But an app that does crash will drive users away faster than any feature can bring them back. The unglamorous work of thread safety, memory management, proper instrumentation, and ongoing monitoring is what separates apps that scale from apps that collapse under their own growth.
Nikhil Nangia
Founder & Seasoned iOS Expert
Seasoned iOS expert with 9+ years of experience building fintech, regulated, and consumer mobile products. Nikhil specializes in Swift, app architecture, and technical due diligence for pre-acquisition reviews.
Related Articles
Hire Senior Engineers Without VC Funding
The fully-loaded first-year cost of a $195k senior engineer hits $280k–$320k. Here's how bootstrapped founders make smarter hiring decisions in 2024.
PCI DSS Payment Scope: Google Pay + Venmo
Adding Google Pay or Venmo looks like a conversion win. But 56.6% of orgs fail PCI compliance at interim audit. Here's what your team isn't budgeting for.
Social Engineering Mobile Security: Stop Phone Attacks
82% of breaches involve a human element. Your app's security stack means nothing if an attacker calls your dev team. Here's what to do now.
