How I Cut Runtime Crashes ~67% in a Flutter App
At iMumz I work on a pregnancy app used by 100K+ mothers. Over a stretch of stability work I cut runtime crashes by roughly 67% and lifted onboarding completion by about 43%. Those two numbers are related, and the relationship is the actual lesson of this post: the crashes were not evenly distributed across the app. They were concentrated in the flow where a user has invested the least and will forgive the least.
This is not a Crashlytics setup tutorial — the docs cover firebase_crashlytics fine. This is what I did before and after the fixing, because the fixing was the easy part.
Instrument before you fix
The instinct when the crash rate is bad is to open the top crash and start fixing. I would push back on that, and here is the argument.
A raw Crashlytics list is sorted by event count. Event count tells you what happens most, not what costs most. The top item on our list at one point was a noisy exception on a background refresh that users literally never saw — it self-recovered, the screen re-rendered, nobody churned. Meanwhile a crash a fraction of the size was killing the app in the middle of onboarding, before the user had any reason to come back. One of those was worth a sprint and one was worth a shrug, and the sort order had them backwards.
So the first work was not fixing. It was making the reports capable of answering "who did this hurt, and where were they." Concretely:
- Breadcrumbs on the critical paths.
FirebaseCrashlytics.instance.log()at each meaningful step of onboarding, appointment booking, and payment. A crash report with the last six user actions attached is a bug report. Without them it is a stack trace and a guess. - Custom keys for state.
setCustomKeyfor the things that change behaviour — onboarding step, subscription state, whether the user came through a deep link. This is what turns "crashes sometimes" into "crashes only for users in week 32+ with no subscription," which is a fixable statement. - User identifiers, carefully.
setUserIdentifierwith an internal ID, never anything personal. On a health-adjacent app this is not optional — the crash pipeline is a data pipeline and it gets the same review as any other. The healthcare Flutter guide goes deeper on that constraint. - Non-fatals for handled failures.
recordErrorwithfatal: falseon the caught exceptions you currently swallow. Half of our worst user experiences were not crashes at all — they were an empty state where content should have been, caused by an error someone had politely caught and logged to the console.
That last one is the highest-leverage item on the list and it is the one teams skip. A crash is honest. A silently swallowed exception is a bug that hides.
Reading Crashlytics properly
The metric to run the work against is crash-free sessions, not crash count. They fail in opposite directions and both failures are common.
| Metric | What it answers | How it misleads | Use it for |
|---|---|---|---|
| Crash count | How many crash events happened | Scales with traffic — a good release during a growth week looks worse than a bad release during a quiet one | Triage volume within a single build, nothing else |
| Crash-free users | Share of users who never crashed | One user crashing 50 times reads the same as one user crashing once; heavy users dominate the denominator | Headline release health |
| Crash-free sessions | Share of sessions with no crash | Can look fine while one short critical flow is on fire, because most sessions never enter it | The number to move — but segment it |
| Velocity alerts | A new issue spiking in a fresh release | Silent on long-standing crashes that have always been there | Catching regressions in the first hours of a rollout |
| Issue count | How many distinct problems exist | Deduplication is imperfect — one root cause can present as several issues across Flutter versions | Estimating fix effort, not user pain |
The row that matters is the third one. A 99.2% crash-free session rate sounds healthy and can coexist with an onboarding flow that fails for one user in twelve — because onboarding is a small share of total sessions, and the users it kills never come back to generate the sessions that would show up in the denominator. The metric survivor-biases against exactly the users you are losing. Segmenting crash-free sessions by flow and by app version is what made our onboarding problem visible at all, and it is why the ~43% onboarding completion lift and the ~67% crash reduction landed together rather than as two separate projects.
The version adoption trap
One practical note that cost me a day early on: after a release, crash-free rate almost always looks worse for a few hours, because the users who update within the first hour are your most engaged users and they generate more sessions per head. Wait for adoption to spread before you judge a build. Panicking on hour-one numbers and rolling back a good release is a real, avoidable mistake.
The four classes of Flutter crash
Nearly everything we fixed fell into one of four buckets, and each has its own signature and its own fix.
1. Platform channel errors
These arrive as PlatformException or MissingPluginException and they are the most under-diagnosed class, because the Dart stack trace stops dead at the channel boundary — it tells you a native call failed but nothing about why.
Three things make this class tractable. Catch PlatformException at the wrapper and map it to a domain error, so the app degrades instead of dying. Log the native diagnostics from the native side, since the Dart report cannot see them. And handle MissingPluginException explicitly — it is what you get when a plugin did not register on one platform, and it is a startup crash on the platform you tested least. If you write channels yourself, the discipline that prevents most of this lives in my platform channels post: reply exactly once, use stable error codes, never let a Future hang.
2. Null and late initialization errors
Sound null safety killed the classic null dereference, so this class mutated rather than died. What you see now is LateInitializationError, and it is almost always a lifecycle assumption rather than a null bug: a late field initialized in an async method that the widget outran, or a value populated in a callback that raced the first build.
Two rules retired most of ours. Do not use late for anything populated asynchronously — use a nullable field and handle the loading state honestly, because late is an assertion that initialization already happened and async is precisely the case where it might not have. And guard every setState after an await with a mounted check. That second one sounds trivial. It was a meaningful share of the total.
3. Image and memory crashes
The class that only exists in production, because nobody hits it on a test device.
Flutter decodes images to uncompressed bitmaps in memory. A 3000x2000 JPEG that weighs 400KB on disk is roughly 24MB decoded. Put a few of those in a scrolling list on a 2GB Android phone and the OS kills the process — and the kill shows up as an OOM with no Dart stack trace, which is why it stays unfixed for months.
The fixes are unglamorous: set cacheWidth and cacheHeight so Flutter decodes at display size rather than source size, serve appropriately sized images from the CDN rather than shipping originals to phones, bound the image cache explicitly, and test on the cheapest device your analytics say your users actually hold. For an app whose users skew toward mid-range Android, that last one is not a nice-to-have — it is where the crashes live. The device-class reality behind this is the same one I covered in the Flutter vs React Native comparison.
4. Async gap errors
The subtlest class and the one that survives code review. Between an await and the line after it, arbitrary things can happen: the user backs out, the widget disposes, the provider tears down. Then you touch context, or call setState, or write to a closed controller, and it throws.
The signatures are recognizable once you know them — "setState() called after dispose()", "Looking up a deactivated widget's ancestor", a StreamController closed error. The prevention is the same three habits: mounted checks after every await, cancel every subscription in dispose(), and capture BuildContext-derived objects (navigators, messengers) before the await rather than reaching for context after it. The use_build_context_synchronously lint catches a lot of this for free and is worth turning into an error rather than a warning — a warning is something people scroll past.
The loop that actually moved the number
The work was not a heroic bug hunt. It was a boring cycle repeated:
- Segment, do not sort. Slice crash-free sessions by flow, app version, and device class. The story is always in a segment, never in the global number.
- Rank by cost, not count. A crash in onboarding costs a user forever. A crash on a settings screen costs an eye-roll. Fix in that order, regardless of what the event counts say.
- Fix the class, not the instance. When we found a
mountedbug, we did not just fix that screen — we linted for the pattern and fixed the family. One fix per crash is a treadmill; one fix per class is progress. - Verify on the metric you chose. Ship, wait for adoption, check segmented crash-free sessions. If it did not move, you fixed something real that was not the problem — which happens, and is fine, as long as you notice.
- Guard the gains. Velocity alerts on new issues, and a stability check in the release process. 250+ PR reviews taught me that regressions are cheaper to catch in review than in a rollback.
A note on honesty about the number: ~67% is a real measurement of a real reduction, and it is also not a benchmark you should hold your own app to. It reflects an app that had specific, findable problems. An app with a cleaner starting point cannot cut 67% because it does not have 67% to cut. The transferable part is not the percentage. It is that the concentration of crashes in the onboarding path is what made the reduction and the completion lift the same project.
If you are starting this tomorrow
In order, on day one: turn on recordError with fatal: false for every exception you currently catch and swallow, add breadcrumbs to your single most valuable flow, set custom keys for the two or three state values that change app behaviour, promote use_build_context_synchronously to an error, and set cacheWidth on every network image in a list. Then wait a week and look at segmented crash-free sessions before you fix anything.
You will find that the list you would have started fixing on day zero is not the list that matters. That has been true every time I have done this.
The Takeaway
Cutting crashes ~67% in a Flutter app serving 100K+ users was not a fixing problem, it was a measurement problem. Track crash-free sessions segmented by flow rather than crash count, because count sorts by frequency and users churn on cost. Record your swallowed exceptions as non-fatals — the worst experiences are often not crashes at all. Then work the four Flutter crash classes: platform channel errors, late-init and lifecycle races, image memory on cheap devices, and async gaps after an await. The crashes concentrate where users are least invested, which is why fixing them lifted onboarding completion ~43% at the same time.
More on the production engineering behind that app is in the iMumz case study, and I am a mobile engineer open to full-time roles — say hello.
Frequently asked questions
How did you cut Flutter crashes by 67%?
By instrumenting before fixing. Breadcrumbs and custom keys on the critical flows, recording swallowed exceptions as non-fatals, then ranking by user cost rather than event count — the crashes were concentrated in onboarding, which is also why onboarding completion rose about 43%.
Should I track crash count or crash-free sessions?
Crash-free sessions, segmented by flow and app version. Crash count scales with traffic, and a global crash-free rate can look healthy while one short critical flow is on fire — the users it kills never come back to generate the sessions that would show it.
What are the main classes of Flutter crash?
Platform channel errors (PlatformException, MissingPluginException), late-initialization and lifecycle races, image and memory OOMs on low-end devices, and async gap errors after an await. Nearly everything I fixed fell into one of those four.
Why do Flutter apps crash with out-of-memory on images?
Flutter decodes images to uncompressed bitmaps — a 400KB 3000x2000 JPEG is roughly 24MB in memory. Set cacheWidth and cacheHeight so decoding happens at display size, and serve appropriately sized images from your CDN.
How do I stop setState called after dispose errors?
Guard every setState after an await with a mounted check, cancel subscriptions in dispose(), and capture context-derived objects before the await rather than reaching for context afterwards. Promote the use_build_context_synchronously lint to an error.
Bottom line
Cutting crashes ~67% was a measurement problem, not a fixing problem — crash count sorts by frequency, but users churn on cost, and the crashes concentrate exactly where they are least invested.
More from the field.
Like the way I think? Let's talk.
I'm a mobile engineer — Flutter and React Native for cross-platform, native Android in Kotlin where the platform demands it, Next.js on the web — open to full-time roles. If your team is hiring, the résumé is the fastest way in.