Your Crash-Free Rate Is 99.5% and Users Still Say the App Is Broken
Crash-free sessions measures the one failure class users forgive. The ones they churn over — the queue that wedges, the race that loses a write, the row that quietly stops matching — never raise an exception at all.

Someone on your team pulls up the crash dashboard to settle an argument. 99.5% crash-free sessions, trending flat. Then someone else reads out the most recent one-star review, which says the app doesn't work, offers no detail, and can't be reproduced by anyone in the room.
Both of those are true at the same time, and the reason is structural rather than mysterious. Crash-free rate measures the one failure class your users are most likely to forgive. A crash is loud. It's obvious. It's recoverable — the user reopens the app and usually gets on with their day. And critically, the app itself files the report: an exception propagates, the SDK catches it, and it lands on your dashboard without anyone having to notice or care.
Now think about the failure that costs you a customer. The message that was scheduled and never sent. The shift two people both believe they've been assigned. The reminder that was snoozed and simply never came back. None of those raise an exception. Nothing catches them, because from the runtime's point of view nothing went wrong — the code executed exactly as written, and what it was written to do was the mistake. So they appear on no dashboard, generate no alert, and reach you only in the most lossy format that exists: a person who has already decided to leave, writing two sentences about it.
That's the actual economics of it. Crashes cost you goodwill. Silent failures cost you the customer, and they take your ability to diagnose them with you.
Here's what three of them looked like from the inside.
The queue that stopped and told nobody
Start with the worst one I've dealt with, because it's the purest form of the problem.
I lead a message-automation app — the kind where you compose a WhatsApp or Telegram message now and the app delivers it at a time you picked. The whole product is one promise: the thing you scheduled goes out. If it doesn't, there is no partial credit.
The send queue was wedging. Not crashing — wedging. A send would begin, mark itself in-progress, and then, for its own reasons, never finish. The record sat there in-progress forever, and because the queue's rule for picking up the next item was reasonably "don't start something while something is already running," one stuck record was enough to stop delivery for everything behind it. Indefinitely. Silently. The app kept running perfectly the whole time.
Sitting alongside that were two more failures of the same species. A retry path that could spin — retrying, failing, retrying, burning battery and going nowhere, with no ceiling that ever converted "we've tried enough" into a durable, visible failure. And a category of false failures: duplicate attempts and late-firing alarms that reported errors for sends that had actually gone out fine, which is its own kind of damage — a user who is told a message failed and sends it again manually has now sent it twice, and trusts your reporting slightly less than before.
Notice what all three have in common. Every one of them is a stuck state, not an error state, and stuck is not something any runtime reports. Exceptions are for things that go wrong. Nothing goes wrong when a record sits in-progress for eleven days.
So the fixes weren't fixes to the sending logic. They were fixes to the queue's ability to notice its own condition: a watchdog that detects stale in-progress state and recovers from it, a terminating condition on the retry loop, and suppression of the duplicate and late-alarm cases so a false failure never reaches a user as a real one. A queue without a liveness check is a queue that can stop forever without anyone finding out — including the queue. If your app has any pipeline where work is claimed and then completed later, that's the first thing I'd go look at, and I'd go look tonight.
The write that two people disagree about
The second one shows up wherever more than one person can act on the same thing.
I work on a healthcare workforce marketplace where institutions post shifts and medical professionals accept them. Offers get accepted. Offers get declined. Sometimes both of those happen to the same offer at nearly the same instant, from different devices, on networks with different latencies — and once that's possible, the app's belief about who holds a shift can diverge from the truth.
There is no crash in that story. There is no error anywhere. Two requests each did exactly what they were told; the code simply never contemplated them arriving together. What comes out the other side is a roster that's wrong, and the way you learn about it is a phone call from a hospital, not a stack trace.
Hardening the concurrent accept/decline path against those races was ordinary engineering work — the kind that's invisible in a changelog and expensive in a support queue. What made it worth prioritising is the thing I'd want any product owner to internalise: concurrency defects surface as business incidents, not engineering incidents. They arrive as a billing dispute, a double-booking, a duplicated charge, a support ticket that reads like a user error. Your engineering dashboards will be green through all of it, and by the time the pattern is visible to you it has been visible to your customers for months.
The state your query didn't know existed
The third one is the smallest and, per line of code, the most expensive.
A user of a reminder app snoozes a reminder. The most ordinary action in the product. The due time passes while it's snoozed. And the reminder never comes back — not in Overdue, not in Today. It's just gone, and the only person who knows is the person who trusted it.
The cause was one clause. A follow-up-reminder query filtered on status = 'PENDING'. Perfectly sensible, except the app also had a snoozed state, and a snoozed item isn't PENDING — so once its due time slid past, it matched no list at all. The state machine had grown a state; the query hadn't been told. One condition in one query, and the product silently broke its only promise. I've written up the audit process that caught it, including the two sibling bugs that turned up once we started looking properly.
What I want to pull out here is the shape, because it generalises far past reminders. Every app accumulates states — draft, pending, snoozed, archived, paused, expired, pending-review — and every state you add silently invalidates an assumption in every query written before it. The failure mode isn't an error. It's an absence: a row that stops matching. Nothing on earth alerts you to a row that didn't appear. Absence is the hardest failure class to detect and the easiest one to ship.
The middle case: the app that's frozen but alive
There's one failure class that sits between loud and silent, and it's worth knowing because most teams under-count it.
When the main thread is blocked long enough, Android shows the user a system dialog and, on the tooling side, records an ANR — Application Not Responding. To the user that's indistinguishable from a crash, and arguably worse: a crash is over in a second, while a frozen app makes you sit there wondering whether to wait. But an ANR is not an exception, so depending on what you're looking at, it may not be in the crash-free number your team quotes at each other.
Owning stability for the Daily Fantasy and later Core Fantasy modules of a large fantasy sports app, the work that moved the needle release-over-release was treating crashes and ANRs as one budget rather than two — reading Sentry and Embrace together, cross-referencing against what QA was actually filing, and pushing both rates down as a single number. The refactor that made it tractable was structural: lifting business logic out of the screens and into a real domain layer, so the code doing slow work was somewhere you could see it and test it rather than smeared through view controllers.
The generalisable bit: before you quote a stability number, know precisely which failures it counts. "Crash-free sessions" from one SDK, ANR rate from platform vitals, and non-crashing logic failures from nothing at all are three different measurements, and only the first one usually makes it into a status update.
So instrument the promise, not the exception
If none of these failures raise exceptions, the fix isn't better exception reporting. It's deciding that your app should report on the thing it actually promises, and treating that report as a first-class product feature.
Three things do most of the work.
Record typed outcomes for anything that matters. In my scheduler, a failed send doesn't produce a generic error — it records which stage of the pipeline died and why. That's the difference between "sometimes it doesn't work" and a ranked list you can act on: this failure class, on this platform version, this many times. It's also what makes the difference between a real field incident and your own test devices polluting the data, which is a whole discipline of its own.
Measure the success path, not just the failure path. Count sends attempted and sends confirmed delivered. Count checkouts started and checkouts completed. A wedged queue produces no errors — but it produces a ratio that falls off a cliff, and a ratio is something you can alert on. This is the single highest-value instrument for silent failure, and almost nobody has it, because dashboards are built around exceptions rather than around promises.
Make failure visible to the user, deliberately. In a scheduler the catastrophic outcome isn't a failed send; it's a failed send the user never learns about. A user who sees "this didn't go out, here's why, here's the fix" is annoyed. A user who finds out from the person who never got the message is gone. Failing loudly costs you something. Failing silently costs you the relationship.
Four questions for your next stability review
- What does our crash-free number actually count? Name the SDK, and say out loud whether ANRs are in or out.
- What's our completion ratio on the core action? Not errors — attempts versus confirmations. If nobody can answer this, that gap is where your silent failures live.
- Which of our workflows can get stuck rather than fail? Anything that claims work and completes it later. Every one of those needs a liveness check, because stuck reports itself to nobody.
- What does a user experience when something goes wrong that we didn't anticipate? A clear failure with a route forward, or silence? Only one of those gives you a second chance.
A green dashboard next to bad reviews isn't a contradiction to be explained away. It's a measurement gap, and it's usually the most valuable thing anyone will tell you about your app that quarter.
If your reviews and your metrics have been disagreeing for a while and nobody can close the gap, that's the kind of thing I get called in for — finding the failures that never raised an exception, proving them with something reproducible, and leaving behind instrumentation that tells you next time instead of leaving it to a stranger with a one-star review.
Ramiz Raja is a senior mobile developer and technical lead with 12+ years shipping and rescuing production apps on Android, iOS, and Flutter. More at codebyramiz.com.