Flutter on a bad connection: building apps that survive real mobile networks
· 6 min read
Most Flutter apps are written on office fibre and tested on an emulator that has never dropped a packet. Then they ship to a phone on a bus between Kathmandu and Dharan, with two bars of 4G that quietly becomes 3G in a tunnel. Almost every bug report that starts with "it just spins forever" comes from that gap, and as a Flutter developer in Nepal it's the gap you spend most of your debugging life in.
The useful thing is that bad networks fail in a small number of recognisable ways. Once you can name them, most of the fixes are unremarkable.
The hard case isn't offline — it's "sort of connected"
Fully offline is easy. There's no socket, the error arrives immediately, and you show a message. What actually breaks apps is a connection that exists, accepts your request, and then delivers nothing: a TCP handshake that completes and a response that never arrives, or arrives one byte at a time.
This is why connectivity checks mislead people. connectivity_plus reports the interface type — WiFi, mobile, none — and its own documentation is blunt that this doesn't guarantee internet access, and that you shouldn't use it to decide whether a request will work. A captive portal at a café reports WiFi. A tower you're handing off from reports mobile.
Use connectivity events as a hint — good for "the network just came back, retry now" or for a banner — and never as a precondition. If you want an actual reachability signal you need something that performs a real request, like internet_connection_checker_plus. But the request you were going to make is itself the best reachability test you have.
Set your timeouts, because nobody set them for you
The default timeout in Dart is worse than most people assume. HttpClient.connectionTimeout is null by default, which means the OS default applies — far longer than any human will sit and watch a spinner. If you never set a timeout, you didn't pick a fast failure; you picked whatever Android felt like.
With dio you get three knobs, and the third one is routinely misread:
connectTimeout— establishing the connection. Should be short. A connection that hasn't opened in a few seconds on a mobile network usually isn't going to.sendTimeout— uploading the request body. Matters for image uploads, barely for a JSON POST.receiveTimeout— not a total budget for the response. It applies to the wait before the first bytes and then between data events. A response trickling in slowly enough to keep resetting that clock can hang around much longer than the number you wrote.
If you need a hard ceiling on the whole operation, add one explicitly. But be aware that wrapping a call in Future.timeout only gives you a TimeoutException — the work underneath carries on, and the server may still process the request. To actually abandon it, cancel it: Dio's CancelToken terminates the requests bound to it, and CancelToken.isCancel(e) lets you tell a cancellation apart from a real failure so you don't show an error for a screen the user already left.
A timeout is a product decision disguised as a config value. "How long is this user willing to wait before we tell them the truth?" is not a question the networking library can answer for you.
Optimistic updates need an honest rollback
Optimistic UI is the right call on a slow network — waiting for a round trip before showing a tap makes the app feel broken. The mistake is modelling it as two states, local and synced, when there are three: what the user intended, what's in flight, and what the server has confirmed.
When the write fails, roll back visibly. A silent revert is the worst outcome available: the user saw the item added, looked away, and now it's gone with no explanation, so they either do it twice or stop trusting the app. Undo the state, say what happened, and offer the retry as a deliberate action.
Then there's the genuinely ambiguous case, and it's the common one on a flaky link: the request timed out, so you don't know whether it succeeded. You cannot resolve that on the client. What you can do is make repeating it harmless — send a client-generated idempotency key with every mutation so the backend can recognise a duplicate and return the original result instead of creating a second order. Do that and an ambiguous failure becomes a retry instead of an incident.
Retries that don't make things worse
The instinct on a failed request is to try again immediately. On a congested or weak connection that's actively harmful — you're adding load to the thing that's already struggling, and if every client in a region does it after an outage you've built a small self-inflicted DDoS.
The rules are boring and they work. Back off exponentially. Add jitter, so clients don't resynchronise into waves. Respect Retry-After when the server sends it — a 429 is an instruction, not an error to route around. Cap total attempts, and once you've hit the cap, stop and tell the user rather than looping forever behind a spinner.
And know what your retry library is actually doing. RetryClient in package:http/retry.dart defaults to three retries and keeps a copy of the request data so it can resend — which is worth knowing before you stream a large upload through it. dio_smart_retry defaults to three retries at 1, 3 and 5 seconds and retries a fixed list of statuses including 408, 429, 500, 502, 503 and 504.
Both are reasonable. Neither knows whether your POST is safe to repeat — they retry on status and error type, not on whether the operation has side effects. A blanket retry interceptor over an endpoint that charges a card is a bug you've installed on purpose. Decide idempotency per endpoint, not per client.
One more distinction worth drawing: a retry the user is waiting on and a retry they aren't are different problems. The first belongs in your HTTP layer with a tight cap. The second — an upload that should land eventually — belongs in a persisted queue handed to the OS. Android's WorkManager will re-run a failed job on a backoff policy for you; on iOS, BGTaskScheduler runs when it chooses and you schedule the next attempt yourself. Don't assume the Flutter wrapper hides that asymmetry.
Offline-first is usually overkill. Offline-aware isn't.
"Offline-first" gets used as a synonym for "handles bad networks", but it's a much bigger commitment: a local database as the source of truth, a sync engine, a conflict-resolution policy, and schema migrations on thousands of devices you can't inspect. That's a subsystem with its own bug class, and it earns its keep only when the app's core loop is genuinely local.
For a lot of products it isn't. Quick commerce is the clearest example — at Fasto the promise is delivery in ten minutes, and stock levels and order status are only meaningful as live server state. There is no useful offline version of "is this in stock right now". Building a sync engine there would add complexity to reach an answer you can't honestly give.
Offline-aware is the cheaper ninety percent:
- Cache last-known-good reads and render them with their age visible. Stale data labelled stale is useful. Stale data pretending to be fresh is a lie.
- Queue only the writes that matter — the handful where losing the user's input would be unacceptable. Persist those; let the rest fail loudly and be retried by hand.
- Never lose typed input. Draft state survives a process death; a network error shouldn't empty a form.
- Make degraded mode a real state in your state management, not a boolean checked in three widgets.
When you do need real persistence, SQL-backed options like sqflite and drift are the conservative choice. Check the maintenance health of whatever store you pick before you commit to it — for something holding user data across app upgrades, an active maintainer matters more than benchmarks.
Test the failures, not just the happy path
None of this holds up unless you provoke it. Throttle the connection and keep it throttled while you use the app. Turn on airplane mode mid-request, not before it. Kill the process while a write is in flight and check what the user sees on relaunch. Point the app at an endpoint that accepts the connection and never responds — that one finds missing timeouts faster than anything else.
Do that on a mid-range Android device rather than a flagship or a simulator, because that device mix is the one most apps in Nepal actually run on. The apps I've shipped that held up were not the ones with the cleverest architecture; they were the ones where the bad paths had been walked deliberately before a user found them.
More on how I approach this in Flutter and mobile development, on the API side that has to make these retries safe, and in why Django apps get slow as they grow — because a slow endpoint and a bad network produce the same spinner.