Why Mobile Apps Fail (And It's Rarely the Code)
After shipping 15+ mobile apps, the failures I've seen came from slow networks, pushy onboarding and backends that buckled at launch — not from bad code.

I’ve shipped more than fifteen mobile apps — mostly Flutter, some native Android. The ones that failed didn’t fail because of bad code.
Every time, it was something further out:
- An app that worked perfectly — until someone opened it on 3G and got a blank screen with no explanation.
- An onboarding flow that asked for six permissions before showing a single useful thing.
- A backend that was fine at 10 users and fell over at 500, right when the launch push landed.
Clean architecture doesn’t save you from any of that. The failure points sit between the code and the user, and they’re mostly invisible until real people show up.
This post is the longer version of that thought: what those failures look like, why code review and unit tests don’t catch them, and what I now check before every launch.
Failure 1: The blank screen on a slow network
On my office Wi-Fi, the app loaded in under a second. On a 3G connection, the first API call crawled along and eventually timed out. The UI showed… nothing. No spinner, no error, no retry button. Just an empty ListView, because the list was empty and nobody had designed for “empty because it’s still loading” versus “empty because it failed”.
From the user’s point of view, the app was broken. From the code’s point of view, everything worked exactly as written.
What I do now:
- Every screen that loads data has four states, not two: loading, success, empty, and error. I model them explicitly so the compiler forces me to handle each one:
sealed class ScreenState<T> {}
class Loading<T> extends ScreenState<T> {}
class Loaded<T> extends ScreenState<T> {
Loaded(this.data);
final T data;
}
class Empty<T> extends ScreenState<T> {}
class Failed<T> extends ScreenState<T> {
Failed(this.message);
final String message;
}
- Every network call gets a timeout and a human-readable error with a retry action. “Something went wrong” is not a message; “Couldn’t reach the server — check your connection and try again” is.
- I test on a throttled connection before every release. Android emulators and the iOS Network Link Conditioner both make this easy. Ten minutes on “3G” or “Very Bad Network” tells you more than a week on Wi-Fi.
- Cache the last good response. Showing slightly stale data with a “last updated 5 minutes ago” label beats a blank screen every time. (I wrote more about this in Building Offline-First Flutter Apps.)
Failure 2: The permission wall
One app I worked on asked for location, notifications, camera, contacts, photos and Bluetooth — on the first launch, in a row, before the user had seen a single screen of actual content.
Most users tapped “Don’t Allow” on at least half of them. Some uninstalled on the spot. And on iOS, once a user denies a permission, you can’t ask again with the system dialog — you have to send them to Settings, which almost nobody does.
The code for requesting permissions was fine. The timing was the bug.
What I do now:
- Ask in context. Request the camera when the user taps “Scan”, not at startup. The user understands why you want it, so they say yes.
- Pre-prompt before the system dialog. A simple screen of your own that explains why (“We use your location to show ranges near you”) before triggering the OS prompt. If the user says “not now” on your screen, you haven’t burned the one-shot system dialog.
- Design the denied path. If the user says no to location, the app should still work — let them type a city instead. A feature that dead-ends on a denied permission is a feature that’s broken for a big chunk of your users.
- Show value first. Let people browse before signing up. Let them see what notifications would look like before asking to send them.
Failure 3: Fine at 10 users, down at 500
This one hurts the most, because it happens on the day that matters: launch day. The marketing push goes out, a few hundred people install at once, and the backend that handled the beta testers without breaking a sweat starts returning 500s.
In the cases I’ve seen, the causes were boring:
- A database query without an index that was fast on 200 rows and slow on 200,000.
- An endpoint that loaded a user’s entire history on every app open.
- Images served full-size from the origin instead of resized through a CDN.
- A free-tier database with a connection limit nobody had read about.
- Every client retrying failed requests immediately, turning a small outage into a self-inflicted flood.
None of these show up in code review. They show up under load.
What I do now:
- Load test the three hottest endpoints before launch — usually login, the home feed, and whatever the launch campaign points people at. Even a simple script firing a few hundred concurrent requests catches most problems.
- Paginate everything that can grow. If a list comes from the server, it has a page size.
- Retry with exponential backoff and jitter on the client, so a struggling server gets breathing room instead of a stampede.
- Know your limits. Read the quotas for your database, auth provider and hosting tier. Write them down. Set alerts at 70%.
- Have a kill switch. A remote config flag that can turn off a heavy feature without shipping an app update has saved me more than once.
Why good code doesn’t catch this
Code review checks whether the code does what the author intended. Unit tests check whether functions return the right values. Neither checks whether the intention was right for a person on a train with one bar of signal, or for five hundred people tapping “Sign up” in the same minute.
That’s not an argument against clean code — I care a lot about architecture, and I’ve written about how I structure Flutter projects. It’s an argument that architecture is necessary, not sufficient. The code is the part of the product you control most. The failures live in the parts you control least: the network, the user’s patience, and real-world traffic.
My pre-launch checklist
Before any app I work on goes to the stores, I run through this:
- Every data screen handles loading, empty, error and success.
- The app has been used for at least an hour on a throttled network.
- Airplane mode mid-action doesn’t crash anything or lose user input.
- No permission is requested before the user has seen why it’s needed.
- Every permission has a working “denied” path.
- The hottest endpoints have been load tested at 10× expected launch traffic.
- All lists from the server are paginated.
- Client retries use backoff.
- Crash reporting and basic analytics are live before launch, not after.
- There’s a remote way to disable the riskiest feature.
None of this is glamorous. None of it will impress anyone in a code review. But it’s the difference between an app that works in the demo and an app that works for the people who actually download it.
If you’re getting ready to launch a mobile app and want a second pair of eyes on the parts between the code and the user, get in touch.
Comments
Questions, corrections or your own experience — leave a comment below (GitHub sign-in).