January 12, 2026
Why Mobile Apps Break After Resume — State, Background, and Duplicate API Bugs
The Mobile Bugs That Only Appear After Launch — Part 2
By Simra Husain
4 min read
In an earlier article, I wrote about why some mobile bugs only appear after launch — once retries, interruptions, and real user behavior enter the picture. That piece covers the broader production patterns that let these bugs survive QA and code review: The Mobile Bugs That Only Appear After Launch
Here, I take one of those bugs and breaks it down in detail: state corruption and duplicate work triggered by app resume and backgrounding.
This is one of the most expensive post-launch failures teams deal with because it rarely crashes, rarely reproduces in QA, and often disappears after an app restart.
What follows is not theory or framework advice — just the exact patterns that cause this bug in real apps, and the fixes that actually hold up in production.
The production symptom teams struggle to explain
These bugs usually surface as vague reports:
- "The screen showed success, but the backend didn't update."
- "If the user backgrounds the app mid-action and comes back, the UI is wrong."
- "Sometimes we see duplicate API calls or duplicated orders."
- "Restarting the app fixes it."
Nothing looks obviously broken in logs. And yet, something clearly is.
Why this bug survives QA and code review
Most mobile code is written with an implicit assumption:
This flow completes once, in one session, without interruption.
On a developer's phone, that assumption often holds:
- fast network
- warm cache
- no backgrounding mid-request
- no retries overlapping with lifecycle events
In production, none of that is guaranteed.
Users:
- background the app mid-request
- return minutes or hours later
- switch apps repeatedly
- hit flaky networks
If your code assumes completion instead of re-entry, state corruption is inevitable.
The most common broken pattern
This pattern exists in almost every mobile codebase at some point.
class OrderScreenState extends State<OrderScreen>
with WidgetsBindingObserver {
Order? order;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_loadOrder();
}
Future<void> _loadOrder() async {
final result = await api.fetchOrder();
setState(() {
order = result;
});
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_loadOrder();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
}class OrderScreenState extends State<OrderScreen>
with WidgetsBindingObserver {
Order? order;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_loadOrder();
}
Future<void> _loadOrder() async {
final result = await api.fetchOrder();
setState(() {
order = result;
});
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_loadOrder();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
}At a glance, this looks reasonable.
It often works in QA.
And it is fundamentally unsafe.
What actually goes wrong in production
Here's the failure timeline teams miss:
- User starts loading data.
- App goes to background.
- Network request continues.
- App resumes.
_loadOrder()runs again.- Two requests are now in flight.
- The older response finishes last.
- Stale data overwrites newer state.
No crash. Just wrong data.
This is why these bugs are so hard to debug.
Why mounted checks don't fix this
You'll often see advice like:
if (!mounted) return;if (!mounted) return;That only protects against updating a disposed widget.
It does nothing to protect against:
- out-of-order responses
- duplicate in-flight work
- stale state overwriting valid state
The bug still exists.
Fix #1: Guard async work with a request version
This is the simplest reliable fix.
int _requestVersion = 0;
Future<void> _loadOrder() async {
final int version = ++_requestVersion;
final result = await api.fetchOrder();
if (!mounted || version != _requestVersion) {
return; // stale response
}
setState(() {
order = result;
});
}int _requestVersion = 0;
Future<void> _loadOrder() async {
final int version = ++_requestVersion;
final result = await api.fetchOrder();
if (!mounted || version != _requestVersion) {
return; // stale response
}
setState(() {
order = result;
});
}What this guarantees:
- Only the latest request can mutate state.
- Resume-triggered requests can't overwrite newer data.
- Slow networks and retries are handled safely.
This pattern alone eliminates a huge class of resume bugs.
Fix #2: Cancel in-flight work explicitly
If your networking layer supports cancellation, cancel previous work before starting new work.
CancelToken? _cancelToken;
Future<void> _loadOrder() async {
_cancelToken?.cancel();
_cancelToken = CancelToken();
try {
final response = await dio.get(
'/order',
cancelToken: _cancelToken,
);
if (!mounted) return;
setState(() {
order = Order.fromJson(response.data);
});
} on DioError catch (e) {
if (CancelToken.isCancel(e)) {
return;
}
rethrow;
} finally {
_cancelToken = null;
}
}CancelToken? _cancelToken;
Future<void> _loadOrder() async {
_cancelToken?.cancel();
_cancelToken = CancelToken();
try {
final response = await dio.get(
'/order',
cancelToken: _cancelToken,
);
if (!mounted) return;
setState(() {
order = Order.fromJson(response.data);
});
} on DioError catch (e) {
if (CancelToken.isCancel(e)) {
return;
}
rethrow;
} finally {
_cancelToken = null;
}
}This ensures:
- backgrounded work doesn't complete silently
- resumed requests don't compete with old ones
- stale responses never touch UI state
Fix #3: Stop letting UI own state mutations
The deeper issue isn't lifecycle.
It's ownership.
UI widgets should request work, not own the result.
class OrderController {
final Api api;
int _version = 0;
final ValueNotifier<Order?> order = ValueNotifier(null);
OrderController(this.api);
Future<void> refresh() async {
final v = ++_version;
final result = await api.fetchOrder();
if (v != _version) return;
order.value = result;
}
void dispose() {
order.dispose();
}
}class OrderController {
final Api api;
int _version = 0;
final ValueNotifier<Order?> order = ValueNotifier(null);
OrderController(this.api);
Future<void> refresh() async {
final v = ++_version;
final result = await api.fetchOrder();
if (v != _version) return;
order.value = result;
}
void dispose() {
order.dispose();
}
}UI becomes an observer:
late final OrderController controller;
@override
void initState() {
super.initState();
controller = OrderController(api);
controller.refresh();
}late final OrderController controller;
@override
void initState() {
super.initState();
controller = OrderController(api);
controller.refresh();
}Lifecycle events now trigger requests, not mutations.
That single shift removes entire categories of bugs.
Resume should not mean "reload everything"
Another common mistake is treating resume as a full restart.
Instead, ask:
- What state is still valid?
- What actually needs refreshing?
- What should be discarded?
void onResume() {
if (order == null || order!.isStale) {
controller.refresh();
}
}void onResume() {
if (order == null || order!.isStale) {
controller.refresh();
}
}Blind reloads create races. Intentional refreshes prevent them.
Why this bug keeps coming back in teams
Because it's boring.
It doesn't:
- crash
- show red screens
- fail deterministically
- reproduce easily
It only appears with time, interruptions, and real users.
Senior teams don't fix this with frameworks or patterns. They fix it by designing for re-entry, not completion.
A production checklist (save this)
Before shipping any screen that mutates state:
- Can this async work run more than once?
- Can older responses overwrite newer state?
- Who owns the final mutation?
- What happens if the app resumes mid-request?
- Is resume intentional or blind?
If any answer is unclear, don't ship yet.
That hesitation saves weeks later.
What's next
This bug exists because code is often written assuming completion instead of re-entry.
Once you design for resume, retries, and overlapping async work, an entire class of "ghost bugs" disappears — the ones that never crash, never log clearly, and quietly corrupt state.
This article focused on one example: state corruption and duplicate work caused by app resume and backgrounding****.
In the next part, I'll break down a closely related failure that comes from the same assumptions: duplicate submissions caused by retries and optimistic UI.
Same approach. One bug. Real production code. Fixes that survive real usage.