Most developers think about offline mode last - when the app is already done, designs are signed off, and the PM is breathing down your neck about the deadline. The result? Users see a white screen, a frozen spinner, or worse - silently lost data. This article is about building honest UX for no-network states: from the psychology of anxiety to exponential backoff code, from visual error language to conflict resolution strategies. All of it applies to any mobile or web app that operates under unstable connectivity - which is most of them.
The Psychology of Offline States
There's a pattern I've seen in usability tests over and over: a user taps "Send," nothing happens, they tap again - and again. Ten seconds later they either close the app or start getting angry. That's not a network problem. That's an uncertainty problem.
Uncertainty breeds anxiety. Anxiety breeds avoidance. The user doesn't know whether their message went through, whether the document saved, whether the transaction completed. The brain interprets the system's silence as a threat: "Something went wrong, and I have no idea what." That's exactly why well-designed offline UX isn't a technical problem - it's an anxiety management problem.
There are three states worth distinguishing: the system is working normally, the system is slow or unstable, the system is fully offline. Each one needs its own visual response. Conflate them and you confuse the user even further. If you show the same indicator for a slow connection as for no connection at all, the user has no idea whether to keep waiting or give up.
One more thing that often gets overlooked: people tolerate waiting much better when they understand why and have a rough sense of how long. This is well-established in queue research and loading UX. Offline is no different. "No internet" is already better than a blank screen. "No internet - your data is saved locally and will sync when the connection is restored" - that's trust.
Visual Language for Offline States
Rule one: don't hide things. I've seen interfaces that simply disabled buttons when there was no network and offered zero explanation. The user stares at a grayed-out button and thinks: is this a feature? A bug? Why now?
The offline state needs to be explicit. A banner at the top of the screen in a neutral color - not red; red signals an error, not an environmental condition - a status bar indicator, a subtle UI change - all of these work. The core rule: UI elements that are unavailable because of missing connectivity should be visible but clearly disabled, with an explanation. Not hidden - disabled, with a tooltip or inline message.
A solid practice for mobile apps is showing connection state in the toolbar or navigation bar. On iOS you can observe NWPathMonitor and update the UI reactively:
import Network
final class NetworkMonitor: ObservableObject {
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "NetworkMonitor")
@Published var isConnected: Bool = true
init() {
monitor.pathUpdateHandler = { [weak self] path in
DispatchQueue.main.async {
self?.isConnected = path.status == .satisfied
}
}
monitor.start(queue: queue)
}
}
In SwiftUI this plugs in trivially via @EnvironmentObject, letting any screen react to connectivity changes without tight coupling.
On the iconography front: I prefer the SF Symbol wifi.slash on iOS - users read it instantly. On the web, the same idea applies with SVG icons, but don't overdo animation. A pulsing offline icon gets annoying within thirty seconds. A static one just informs.
Queueing Actions for Later Sync
This is where the real engineering begins. When a user does something offline - likes a post, creates a task, edits a document - that action needs to be stored somewhere and executed later. A simple in-memory array won't survive an app restart. You need a persistent store: Core Data, SQLite, or at minimum UserDefaults for simple cases.
But persistence is only half the problem. The other half is idempotency.
Idempotency means that executing the same operation multiple times produces the same result. This is critical for a sync queue, because we never actually know whether the request reached the server before the connection dropped. If an operation isn't idempotent, retrying it might create duplicates or corrupt data.
In practice, this is solved two ways. First - assign a client-generated UUID to each operation and send it in the Idempotency-Key header. The server stores this key and on a duplicate request returns the same response without re-executing the operation. Second - design operations as PATCH with specific fields rather than POST with a full object, or use event sourcing on the server side.
Conflicts are their own story. If a user edited a document offline while another user changed the same document on the server, you have a conflict. The resolution strategies break down like this.
Last Write Wins is the simplest approach and often the wrong one. One side's changes get thrown away. Fine for non-critical data like settings, not fine for much else.
Merge - for structured data like text or lists, you can apply a three-way merge: base, client version, server version. This is exactly how Git works. CRDT (Conflict-free Replicated Data Types) is the more powerful variant, used by Figma and Linear for collaborative editing.
Ask the User - when automatic merging isn't possible, show both versions and let the user decide. Bad if it happens constantly, but sometimes it's the only honest option.
I try to design the queue as a state machine: each action has a status of pending, syncing, synced, or failed. This simplifies both the UI state display and the retry logic considerably.
Showing "Pending" vs "Failed" - Different States Deserve Different Treatment
This distinction gets ignored surprisingly often. "Waiting to send" and "failed to send" are fundamentally different situations from the user's perspective. The first requires no action. The second requires a decision.
For pending, a subtle indicator works fine: a gray checkmark, a clock icon, a small badge. The user needs to understand that their action was recorded but hasn't synced yet. No need to shout about it - a quiet signal is enough.
For failed, you need explicit feedback. A red icon, an inline error message, and - critically - the ability to cancel. If a user sent a message, it didn't go through, and they see a failed status, they need a "Cancel" or "Try Again" button right there. Without it they're stuck: the action appears to have happened, but it hasn't.
Undo for failed states works on the principle of "soft deletion": the action stays in the queue but gets marked as cancelled. That's simpler than rolling back state. For chats and feeds - show failed messages in gray with an error icon and a retry button next to them. This is a pattern users already know from WhatsApp and Telegram. No need to reinvent it.
Auto-Retry with Exponential Backoff
The naive retry implementation is a loop with a fixed interval. The problem: if a thousand clients lose connectivity simultaneously - say, the office WiFi goes down - they all come back online and hammer the server in sync. That's a thundering herd, and it can take a backend down.
Exponential backoff with jitter solves this. Each subsequent attempt waits twice as long as the previous one, and random jitter spreads the requests across time. Here's an implementation in Swift using async/await:
enum RetryError: Error {
case maxAttemptsReached
case cancelled
}
func withExponentialBackoff<T>(
maxAttempts: Int = 5,
baseDelay: TimeInterval = 1.0,
operation: @escaping () async throws -> T
) async throws -> T {
var attempt = 0
while attempt < maxAttempts {
do {
return try await operation()
} catch {
attempt += 1
guard attempt < maxAttempts else {
throw RetryError.maxAttemptsReached
}
let delay = baseDelay * pow(2.0, Double(attempt - 1))
let jitter = TimeInterval.random(in: 0...delay * 0.3)
try await Task.sleep(nanoseconds: UInt64((delay + jitter) * 1_000_000_000))
}
}
throw RetryError.maxAttemptsReached
}
Usage:
let result = try await withExponentialBackoff {
try await apiClient.syncPendingActions()
}
For the Combine stack this looks different, but the idea is the same - .retry() in Combine doesn't support delays out of the box, so you need a custom operator or a flatMap with Deferred + Future and DispatchQueue.asyncAfter.
One important detail: retries need to stop when the user explicitly cancels an action, or when the app goes to background. Otherwise you're burning battery and burning data for nothing. I usually tie the retry lifecycle to the task's lifetime in the queue and cancel the Task on controller or view model deinitialization.
The maximum number of attempts is a product policy question. For critical transactions - payments, medical data - more attempts and an explicit notification when they're exhausted. For non-critical stuff - analytics, logs - fewer attempts and a silent fail.
Testing Offline Scenarios
This is where most teams cut corners, and it shows in production. Testing offline isn't "turn off WiFi and see what happens." It's a systematic check of every state transition.
On iOS there's Network Link Conditioner - a tool in [Additional Tools for Xcode](https://developer.apple.com/download/all/?q=Additional Tools). It lets you simulate different network qualities: 100% packet loss, high latency, unstable connectivity. Installs as a system extension on Mac or directly on a device via Settings for real hardware. I use the "100% Loss" profile for full offline testing and "Very Bad Network" for degraded connectivity - these are genuinely different UX situations.
On Android the equivalent is adb shell with network state commands:
# Disable mobile data
adb shell svc data disable
# Disable WiFi
adb shell svc wifi disable
# Turn them back on
adb shell svc data enable
adb shell svc wifi enable
For web - Chrome DevTools, Network tab, throttle to "Offline" or set up a custom profile.
But beyond tooling, methodology matters. You need to test specific scenarios: what happens if connectivity drops mid-load? Mid-form submission? What if the user closed the app while a pending action was sitting in the queue, then reopened it? What if the auth token expired during the offline period?
That last one is particularly nasty. The user comes back online, the queue starts syncing, gets a 401 Unauthorized, and now there's a conflict between "need to retry" and "need to refresh the token first." This needs to be tested explicitly and handled explicitly in code. I'd genuinely love to hear how others approach this one in the comments.
Unit tests for queue logic are worth writing against a mock network layer that lets you control success and failure. XCTest makes this straightforward through protocols and dependency injection - no URLSession directly in production code.
When to Warn Users About Data Loss
This is the most painful scenario, and it demands honesty. If data might be lost - warn the user. Before it happens, not after.
Two typical cases. First: a user tries to exit an unsaved document while offline. The classic "Save / Don't Save / Cancel" dialog isn't enough here - you need to say explicitly: "You have unsaved changes. They can't be synced right now and will only be stored locally." That's more honest.
Second: when the sync queue has grown large enough that you can't guarantee all data will survive a cache clear or app update. This calls for a proactive notification - not necessarily a push, but at least an in-app banner on the next launch.
A separate case is critical data that simply shouldn't be stored locally at all: medical records, financial transactions, legally binding signatures. My view is that for these, the right UX is to block the action entirely when there's no network, and explain clearly why. Yes, it's inconvenient. But a lost transaction or a missynced medical record is incomparably worse.
The general principle I work by: users should have the ability to make an informed decision. If we can't guarantee data integrity - say so explicitly and give them a choice: wait for connectivity, save a local copy at their own risk, or cancel the action. Silence here isn't neutral. It's a lie.
A well-implemented offline mode is one of those places where an app either earns long-term user trust or loses it for good. A lost message, a forgotten form, a silent interface on the subway - it all accumulates. The investment in offline UX pays off not in metrics but in reputation.