Common Offline Mode Bugs and How to Catch Them
Common Offline Mode Bugs and How to Catch Them
Common Offline Mode Bugs and How to Catch Them
When users lose connectivity, an app’s behavior can diverge sharply from its online flow. Missing checks, stale caches, or ill‑timed retries turn a temporary inconvenience into data loss, crashes, or security exposure. This guide walks through the most recurrent offline‑mode defects, explains why they appear, shows how they manifest to real people, and gives concrete steps to reproduce, detect, fix, and prevent each one‑by‑one. A test matrix, a symptom‑fix table, and a release checklist are included so you can integrate the practices into your CI pipeline today.
---
1. Understanding Offline Mode Behavior and Why Bugs Hide
1.1 What “offline mode” really means
In most modern apps, offline mode is not a separate build flag; it is the runtime condition where the network layer reports NETWORK_NONE or experiences sustained timeouts. The app should still render UI, serve locally cached data, queue user actions, and surface clear feedback that a connection is unavailable.
1.2 Typical user scenarios that trigger offline conditions
- Entering an elevator or subway tunnel.
- Switching between Wi‑Fi and cellular with a brief dead zone.
- Using a device in airplane mode to test battery life.
- Running the app on a low‑end device where the OS throttles background networking.
1.3 Why scripted tests often miss these bugs
Traditional automated suites run against a deterministic network mock that either always returns 200 or always throws an error. They rarely simulate the *intermediate* state where a request times out after several retries, or where a socket is half‑open. Moreover, scripted flows follow a single happy path; they do not explore the myriad ways a real user might tap a button while the spinner is still visible, or navigate away before a cache write finishes. Persona‑driven exploration—where a virtual user behaves like a curious newcomer, an impatient power user, or an elderly user with tremors—creates the edge cases that expose offline defects.
---
2. Bug Pattern #1: Missing Network State Checks Leading to Silent Failures
2.1 Symptoms
The user taps “Save”, sees a success toast, but later discovers the data never reached the server. No error dialog appears; the app behaves as if the operation succeeded.
2.2 Root cause
The view‑model or controller assumes that a call to the repository will either return data or throw an exception. When the network layer returns a placeholder “offline” response (often a cached empty object) without raising an error, the UI proceeds with the happy‑path branch.
2.3 Reproduction steps (manual)
- Enable airplane mode or disable Wi‑Fi/cellular.
- Navigate to a screen that performs a mutating request (e.g., create a note).
- Fill the form and submit.
- Observe a success indicator.
- Re‑enable connectivity and verify whether the note appears on the server.
2.4 Automated detection
- Instrument the network layer to return a custom
OfflineResponseobject instead of throwing. - Assert that after the call, a UI element showing an error or offline badge is visible.
- Example (Espresso + Kotlin):
@Test fun saveNote_showsOfflineError_whenNoNetwork() {
// Given
IdlingRegistry.getInstance().register(EspressoIdlingResource.networkIdleIdlingResource)
MockWebServer.enqueue(OfflineResponse()) // custom mock
// When
onView(withId(R.id.save_button)).perform(click())
// Then
onView(withText(R.string.offline_error)).check(matches(isDisplayed()))
}
2.5 Fix and prevention
- Expose a explicit
NetworkStateobservable (e.g.,LiveData) from the repository. - In the UI layer, subscribe to this state and disable mutating controls when
state == NOT_CONNECTED. - Show an inline banner or snack bar that persists until connectivity returns.
- Add a unit test that verifies the repository never returns a success payload when the simulated network is offline.
---
3. Bug Pattern #2: UI Elements Remain Enabled When They Should Be Disabled
3.1 Symptoms
Buttons, links, or menu items stay tappable while the app is offline, leading users to invoke actions that have no effect or produce confusing feedback.
3.2 Root cause
UI state is often tied only to the validity of form fields (e.g., isFormComplete) and ignores the connectivity flag. When the network drops, the enable/disable logic is not re‑evaluated.
3.3 Reproduction steps (manual)
- Go offline.
- Open a screen with a “Submit” button that should be disabled without connectivity.
- Verify the button is still enabled and can be pressed.
- Observe that no network request is sent and no error is shown.
3.4 Automated detection
- Use a UI test framework that can query the
enabledproperty of a view. - Example (Playwright for web):
test('submit button disabled offline', async ({ page }) => {
await page.route('**/api/**', route => route.abort()); // simulate offline
await page.goto('/profile');
const btn = await page.$('#submit-btn');
expect(await btn.isEnabled()).toBe(false);
});
3.5 Fix and prevention
- Create a single source of truth for UI enablement: a
ViewModelproperty likeval canSubmit = formValid and networkState.isConnected. - Bind UI elements directly to this property (e.g., Android Data Binding, Vue
v-bind:disabled). - Write a contract test that asserts the property flips to
falsewhen the network state changes toDISCONNECTED.
---
4. Bug Pattern #3: Cached Data Stale or Corrupt Shown as Fresh
4.1 Symptoms
After regaining connectivity, the app displays outdated information (e.g., a friend’s old status) or shows garbled text because a partially written cache file was read.
4.2 Root cause
Cache write operations are not atomic, or the app reads from cache without validating a timestamp or version checksum. When a network interruption occurs mid‑write, the file contains a mix of old and new bytes.
4.3 Reproduction steps (manual)
- Start online and load a list of items (e.g., news feed).
- Disconnect the network.
- Trigger an action that updates the cache locally (e.g., pull‑to‑refresh that writes a new page).
- Force‑close the app to simulate an abrupt kill during the write.
- Reopen the app while still offline; observe stale or corrupted entries.
4.4 Automated detection
- Use a file‑system mock that can simulate a torn write (return partial data on the second read).
- Assert that the UI either shows a placeholder or loads the previous valid version.
- Example (JUnit + Mockito):
@Test
void showsStableCacheWhenWriteIsTorn() {
// given
when(cache.read()).thenReturn(corruptedBytes).thenReturn(validBytes);
viewModel.loadData();
// then
verify(view).showError(R.string.cache_corrupted);
verify(view).displayData(validBytes);
}
4.5 Fix and prevention
- Write cache to a temporary file, then rename atomically (
move(temp, target)). - Store a monotonic version number or timestamp alongside the payload; on read, discard if the version is older than the currently displayed one.
- Provide a fallback UI (skeleton screens) while the cache is validated.
---
5. Bug Pattern #4: Failed Retry Logic Causing Infinite Loops or Crashes
5.1 Symptoms
The app shows a perpetual spinner, drains battery, or eventually throws a StackOverflowError after repeatedly retrying a failed request.
5.2 Root cause
Retry policies lack a maximum attempt count or exponential back‑off ceiling. When the network is permanently unavailable, the recursion or loop never terminates.
5.3 Reproduction steps (manual)
- Enable airplane mode.
- Initiate an action that triggers a network request (e.g., login).
- Observe the spinner never disappearing.
- After a minute, check logcat for repeated “Retrying…” messages.
5.4 Automated detection
- Mock the network layer to always return a timeout.
- Use a counting idling resource to ensure the retry loop finishes within a bounded time.
- Example (AndroidJUnitRunner):
@Test
void retryLimitIsRespected() {
// given
mockWebServer.enqueue(new MockResponse().setSocketPolicy(SOCKET_POLICY_TIMEOUT));
when(retryPolicy.maxAttempts()).thenReturn(3);
// when
viewModel.attemptLogin();
// then
verify(networkClient, times(3)).sendRequest(any());
assertTrue(viewModel.isLoginFailed());
}
5.5 Fix and prevention
- Implement a retry policy with configurable
maxAttemptsandbackoffMs = base * 2^(attempt-1), capped at a reasonable ceiling (e.g., 30 seconds). - Expose a
retryExhaustedLiveData that the UI can map to an error message. - Add a unit test that asserts the number of calls never exceeds
maxAttempts.
---
6. Bug Pattern #5: Improper Handling of Intermittent Connectivity (Flaky Network)
6.1 Symptoms
Actions succeed sporadically; users see partial updates (e.g., a message sent but not displayed) or receive duplicate notifications when the connection flickers.
6.2 Root cause
The app treats any non‑error response as final, ignoring the possibility that a request succeeded but the response was lost, leading to duplicate submissions when the retry mechanism fires after a timeout.
6.3 Reproduction steps (manual)
- Start a network throttling tool (e.g.,
tcon Linux or Network Link Conditioner) set to 30% packet loss. - Perform a repeatable action (send a chat message).
- Observe that sometimes the message appears twice in the UI.
- Check server logs for duplicate entries.
6.4 Automated detection
- Use a mock server that can delay responses randomly and occasionally drop the connection after sending headers.
- Assert that the UI state idempotently processes the same payload only once.
- Example (JavaScript with MSW):
export const handlers = [
rest.post('/api/message', (req, res, ctx) => {
if (Math.random() < 0.3) {
return res(ctx.status(504)); // simulate timeout
}
return res(ctx.status(200), ctx.json({ id: crypto.randomUUID() }));
})
];
6.5 Fix and prevention
- Assign a unique client‑generated ID to each mutable operation and include it in the request payload.
- On the server, make the endpoint idempotent (ignore duplicates based on that ID).
- On the client, collapse pending retries for the same ID into a single attempt.
- Test with a flaky network simulator and verify that the UI never shows more than one copy of the same logical action.
---
7. Bug Pattern #6: Accessibility Breakdowns in Offline Mode
7.1 Symptoms
Screen readers announce incorrect states (e.g., “button enabled” when it should be disabled), focus gets trapped behind a modal that never dismisses, or touch targets become too small because the layout falls back to a low‑resolution asset.
7.2 Root cause
Accessibility properties are often set once in onCreate or viewDidLoad and never updated when the app switches to offline mode. Layouts that rely on network‑dependent dimensions (e.g., images fetched from CDN) may use placeholder sizes that violate touch‑target guidelines.
7.3 Reproduction steps (manual)
- Enable TalkBack (Android) or VoiceOver (iOS).
- Go offline.
- Navigate to a screen with a dynamically loaded banner.
- Swipe to hear the announcement; note if the button’s state is misreported.
- Use the accessibility inspector to verify contrast and hit‑box size.
7.4 Automated detection
- Use accessibility testing tools (axe-core, Accessibility Scanner) in an offline test harness.
- Example (axe with Jest):
test('offline page passes a11y', async () => {
await page.goto('/offline');
await page.evaluate(() => { navigator.connection.effectiveType = 'none'; });
const results = await axe(page);
expect(results.violations).toHaveLength(0);
});
7.5 Fix and prevention
- Bind
contentDescription(Android) oraccessibilityLabel(iOS) to the same view‑model property that drives visual enablement. - Ensure placeholder assets meet WCAG AA contrast and minimum 48dp touch size.
- Run accessibility scans as part of your CI pipeline for both online and offline build variants.
---
8. Bug Pattern #7: Security Gaps When Offline
8.1 Symptoms
While offline, sensitive data (e.g., authentication tokens) may be written to insecure storage, or replay attacks succeed because the app accepts cached credentials without verifying freshness.
8.2 Root cause
Developers sometimes disable certificate pinning or skip server‑side nonce checks when they detect no network, assuming the device is “safe”. Attackers with physical access can extract the cached token and reuse it once the device reconnects.
8.3 Reproduction steps (manual)
- Enable airplane mode.
- Log into the app; note where the token is stored (e.g., SharedPreferences, AsyncStorage).
- Use a file explorer to copy the token.
- Disable airplane mode, reinstall the app on a clean device, and paste the token into storage.
- Observe that the app grants access without prompting for credentials.
8.4 Automated detection
- Write a security test that attempts to read the token from the app’s private directory while the app is offline and asserts that the value is encrypted.
- Example (Android):
@Test
void tokenIsEncryptedAtRest() {
// given
loginUser();
switchToAirplaneMode();
// when
File tokenFile = new File(getContext().getFilesDir(), "token.enc");
String content = new String(Files.readAllBytes(tokenFile.toPath()));
// then
assertFalse(content.contains("eyJ")); // not a raw JWT
}
8.5 Fix and prevention
- Always encrypt persisted secrets with a key derived from hardware-backed storage (Android Keystore, iOS Keychain).
- Require a server‑issued nonce or timestamp for any offline‑granted session and reject it after a short window (e.g., 5 minutes).
- Log and alert when an offline token is used after reconnection, triggering re‑authentication.
---
9. Bug Pattern #8: Background Sync Misbehavior
9.1 Symptoms
After regaining connectivity, the app either creates duplicate records (e.g., two identical expense entries) or fails to upload queued actions, leaving the user with stale local data.
9.2 Root cause
The sync queue lacks deduplication based on operation IDs, or the upload process does not clear successfully processed items, causing them to be retried indefinitely.
9.3 Reproduction steps (manual)
- Go offline.
- Perform the same action three times quickly (e.g., “add to cart”).
- Reconnect and wait for the sync to finish.
- Verify the server contains exactly one record.
9.4 Automated detection
- Use a mock backend that records each incoming request with a timestamp and operation ID.
- At test end, assert that the count of unique IDs equals the number of distinct user actions.
- Example (Python with Flask):
received = set()
@app.route('/sync', methods=['POST'])
def sync():
data = request.json
received.add(data['op_id'])
return jsonify({'status': 'ok'})
9.5 Fix and prevention
- Attach a UUID to every queued operation before persisting it.
- On upload, send the list of IDs; the server responds with which IDs were persisted.
- Client removes only those IDs from the queue, leaving any that failed for another retry.
- Test with a flaky network simulator to ensure no duplicates appear after intermittent losses.
---
10. Bug Pattern #9: Persona‑Specific Offline Failures
10.1 Symptoms
Different user groups encounter distinct problems: an elderly user may double‑tap a button causing two rapid offline requests; a power user using keyboard shortcuts may trigger a hidden debug menu that assumes online access; a novice may not notice a subtle toast and repeatedly attempt the same action.
10.2 Root cause
Persona‑driven behavior (tap frequency, input method, tolerance for subtle cues) interacts with offline‑state logic in ways that generic test scripts never simulate.
10.3 Reproduction steps (manual)
- Elderly/tremor persona: Use a touch‑input emulator that adds jitter (e.g.,
adb shell input swipewith random variance) and observe unintended double submissions. - Power‑user persona: Enable keyboard navigation, press
Enteron a form field while offline, and verify that no hidden shortcut triggers a network call. - Novice persona: Hide the offline banner and measure how many times a test participant retries before noticing the failure.
10.4 Automated detection
- Integrate a persona‑driven test harness that can vary input timing, input modality, and attentiveness.
- Example using SUSA’s CLI (see section 11):
susatest run --apk app.apk \
--persona elderly --persona power_user \
--offline --duration 5m
10.5 Fix and prevention
- Debounce rapid taps: ignore subsequent taps within 300 ms after the first unless the previous action has completed.
- Guard shortcuts with a runtime check: if
networkState == DISCONNECTED, suppress the shortcut or show a modal explaining the limitation. - Provide persistent, non‑toasty feedback (e.g., a banner that stays until dismissed) for novice users who may miss fleeting cues.
- Run persona‑specific exploratory sessions as part of your pre‑release checklist.
---
11. How Autonomous Exploration (SUSA) Surfaces These Bugs
SUSA is an autonomous QA agent that, given an APK or a web URL, explores the application without pre‑written scripts. It builds a behavioral model of each screen, then drives the app through a matrix of personas—curious, impatient, novice, adversarial, elderly, accessibility‑focused, power user, and more—while constantly varying network conditions.
11.1 What the agent does differently
- State‑aware crawling: Instead of following a static URL map, SUSA records the actual UI hierarchy after each interaction, allowing it to discover screens that are only reachable after a failed request or a retry.
- Persona injection: Each persona has a defined distribution of tap‑speed, tolerance for ambiguous UI, and likelihood to use gestures or keyboard shortcuts. This reproduces the human‑specific patterns described in section 10.
- Network chaos injection: The agent can toggle between online, offline, throttled, and lossy links mid‑session, creating the intermittent connectivity scenarios that trigger bugs #4‑#6.
11.2 Example: catching a stale‑cache bug
During a run with the “impatient” persona, SUSA repeatedly pulled‑to‑refresh a feed while the network was throttled to 50 kbps. After the third refresh, it turned the connection off, closed the app, and relaunched it. The agent’s oracle compared the displayed items against the known good snapshot taken earlier and flagged a mismatch—exactly the stale‑cache symptom described in bug #3.
11.3 Integrating SUSA into CI
You can invoke the agent from your pipeline:
# Install once
pip install susatest-agent
# Run a 10‑minute exploratory session on every PR
susatest test --url https://staging.example.com \
--persona curious --persona impatient \
--network offline,throttled,lossy \
--max-time 10m \
--output junit.xml
The JUnit report can be consumed by your CI system to fail the build if any new crash, ANR, or accessibility violation appears. Because SUSA learns from prior runs, each execution becomes smarter, gradually reducing the false‑positive rate while still surfacing regressions that scripted tests miss.
---
12. Test Matrix: Manual vs Automated Approaches for Each Bug Pattern
| Bug Pattern | Manual Test Steps | Automated Test Approach | Suggested Tooling / Frameworks |
|---|---|---|---|
| #1 Missing network checks | Disable connectivity, trigger mutating action, verify error UI | Mock network layer to return offline placeholder, assert error visibility | Espresso / XCTest, MockWebServer / OHHTTPStubs |
| #2 UI elements stay enabled | Go offline, attempt to press action button, observe if action fires | Query enabled property of view after setting network state to DISCONNECTED | Playwright, Espresso, UIAutomator |
| #3 Stale/corrupt cache | Disconnect, trigger cache write, kill app mid‑write, reopen offline, inspect UI | Simulate torn write via file‑system mock, validate UI shows placeholder or old valid data | JUnit + Mockito, Robolectric, XCTest |
| #4 Infinite retry loop | Airplane mode, start action, watch spinner, check logs for repeated retries | Mock perpetual timeout, count retries with idling resource, assert limit | AndroidJUnitRunner, XCTest, Cypress (with network stub) |
| #5 Flaky network handling | Use throttler (30% loss), repeat action, check for duplicates or missing UI updates | Randomly delay/abort responses, assert idempotent handling via operation IDs | MSW (Mock Service Worker), WireMock, Network Link Conditioner |
| #6 Accessibility offline | Enable TalkBack/VoiceOver, go offline, navigate, listen for misannouncements, check contrast | Run accessibility audit in offline mode (set navigator.connection.effectiveType='none') | axe-core, Accessibility Scanner, Xcode Accessibility Inspector |
| #7 Security gaps offline | Log in offline, extract token from storage, reinstall on clean device, replay token | Read token from app’s private dir while offline, assert encryption / nonce present | Frida, Objection, custom security test harness |
| #8 Background sync misbehavior | Offline, repeat same action N times, reconnect, verify server dedupes | Mock backend tracking operation IDs, assert unique count equals N | Flask/Express mock, Postman collection, Pact |
| #9 Persona‑specific faults | Emulate tremor, power‑user shortcuts, novice attention, observe failures | Drive input with varied timing/modality, assert no duplicate or hidden‑online calls | SUSA CLI, Gatling for load, Selenium WebDriver with custom actions |
The matrix shows that each defect can be caught with a combination of a simple manual sanity check and a repeatable automated guardrail. Automating the check protects against regressions; the manual step remains valuable for exploratory sessions and for validating the fidelity of your test environment.
---
13. Release Checklist: Offline Mode Verification
Before tagging a release, run through this list. Mark each item as PASS or FAIL; any FAIL blocks the release until resolved.
- [ ] Network state observable is exposed and UI disables mutating controls when
DISCONNECTED. - [ ] All mutating actions show a persistent offline banner or snack bar, not a fleeting toast.
- [ ] Cache writes are atomic (temp‑file‑then‑rename) and include a version/timestamp guard.
- [ ] Retry policy has a bounded attempt count and exponential back‑off with a max delay.
- [ ] Flaky network simulation (≤30 % loss, 200 ms jitter) does not produce duplicate committed actions.
- [ ] Accessibility scans pass for both online and offline builds (WCAG AA).
- [ ] Persisted secrets are encrypted with hardware‑backed keys; offline tokens contain a nonce and expire within 5 min.
- [ ] Background sync queue deduplicates via operation IDs; server acknowledges only new IDs.
- [ ] Persona‑driven exploratory run (curious, impatient, elderly, power user, accessibility) finishes with zero new crashes, ANRs, or accessibility violations.
- [ ] Automated test suite for each bug pattern (≥1 per pattern) runs successfully on CI.
---
14. Closing Takeaways
Offline mode is not a “nice‑to‑have” fallback; it is a first‑class interaction state that users encounter daily. The most damaging bugs arise when developers assume the network is either perfectly reliable or completely absent, ignoring the messy middle where timeouts, partial writes, and fluctuating signal dominate. By treating offline behavior as a set of explicit contracts—network state flags, atomic caches, bounded retries, idempotent operations, and accessible feedback—you turn a source of fragility into a predictable, testable contract.
Combine those contracts with a disciplined test matrix: lightweight manual checks for exploratory validation, and automated guards that run on every commit. Leverage persona‑driven, autonomous exploration (tools like SUSA) to uncover the edge cases that only real users with varied habits produce. When your release checklist guarantees that each of the nine patterns above is satisfied, you ship with confidence that a lost signal will never silently corrupt data, crash the app, or expose a secret.
Invest the effort now, and your users will thank you the next time they step into an elevator and the app simply tells them, “You’re offline—your changes are safe and will sync when you’re back.”
Test Your App Autonomously
Upload your APK or URL. SUSA explores like 10 real users — finds bugs, accessibility violations, and security issues. No scripts.
Try SUSA Free