Why connectivity modeling matters in offline-first apps
Offline-first apps do not treat “online vs offline” as a simple switch. Real users move through elevators, tunnels, captive portals, flaky Wi‑Fi, background restrictions, and partial outages. If your app models connectivity as a single boolean, you will mis-handle many common situations: you may show “offline” while the device has a network but no internet, you may attempt sync while the server is down, or you may block the UI while the network is slow even though local data is available.
Connectivity modeling is the practice of representing network conditions as a set of explicit states and transitions that your app can reason about. State management is how you store, update, and consume those states across the app so that UI, sync, and storage behave consistently. Together, they let you build resilient UX: the app remains useful offline, avoids destructive conflicts, and communicates clearly to the user without being noisy.
Connectivity is multi-dimensional (not a boolean)
To model connectivity well, separate the concerns that are often conflated:
- Link availability: Is there a network interface connected (Wi‑Fi/cellular/ethernet)?
- Internet reachability: Can the device reach the public internet (e.g., DNS + HTTPS to a known endpoint)? Captive portals often fail this.
- Backend reachability: Can the app reach your API specifically? Your service might be down while the internet works.
- Quality: Latency, bandwidth, packet loss, and stability. A “connected” link can still be unusable for sync.
- Policy constraints: OS background limits, data saver, low power mode, user settings like “sync on Wi‑Fi only”.
- Authentication readiness: Tokens may be expired; you might have internet but cannot call the backend until refresh succeeds.
When you treat these as separate signals, you can make better decisions. For example, you can allow local browsing regardless of internet, queue writes regardless of backend, and only run heavy sync when quality and policy allow.
A practical connectivity state model
A useful model is a small, explicit state machine. Keep it understandable; you can always add detail later. One pragmatic approach is to define a ConnectivitySnapshot that includes both a high-level state and supporting fields.
- Listen to the audio with the screen off.
- Earn a certificate upon completion.
- Over 5000 courses for you to explore!
Download the app
Core states
- Offline: No network interface or airplane mode; cannot reach anything.
- LocalNetworkOnly: Connected to a network but no internet (captive portal, restricted network).
- OnlineDegraded: Internet reachable but quality is poor or unstable; backend calls may time out.
- Online: Internet and backend reachable with acceptable quality.
- BackendDown: Internet reachable but your API is failing (5xx, DNS for your domain fails, TLS issues).
- AuthBlocked: Network is fine but requests cannot proceed due to authentication (expired refresh token, revoked session) until user action or re-auth.
These states are not mutually exclusive in the real world, but your model should pick the dominant state that drives behavior. Supporting fields can capture nuance.
Supporting fields
- transport: wifi/cellular/ethernet/unknown
- isMetered: boolean
- latencyMs: rolling estimate
- lastBackendOkAt: timestamp
- lastInternetOkAt: timestamp
- captivePortalSuspected: boolean
- reason: short enum for diagnostics (e.g., DNS_FAIL, TIMEOUT, HTTP_503)
Make the snapshot serializable for logging and debugging. Connectivity bugs are notoriously hard to reproduce; good telemetry (without sensitive data) is essential.
Signals: where connectivity information comes from
Connectivity modeling depends on multiple signals. Relying on a single OS “network connected” callback is insufficient.
1) OS network status
Use platform APIs to detect interface changes (Wi‑Fi/cellular, connected/disconnected). This is fast and battery-friendly, but it does not guarantee internet or backend reachability.
2) Internet reachability probe
Perform a lightweight probe to a stable endpoint. Options include:
- DNS lookup + HTTPS HEAD/GET to a known URL you control
- HTTPS request to your API health endpoint (if it is highly available)
Prefer HTTPS to avoid captive portal false positives. Captive portals often intercept HTTP and return 200 with a login page; HTTPS will typically fail certificate validation or not connect.
3) Backend health and error classification
Even if the internet is reachable, your backend might be down or blocked. Classify errors from real API calls:
- Timeouts and connection resets suggest degraded network.
- HTTP 5xx suggests backend issues.
- HTTP 401/403 suggests auth blocked (after confirming token refresh behavior).
- HTTP 429 suggests rate limiting; treat as “backend constrained” and back off.
Feed these outcomes back into your connectivity snapshot rather than letting each feature interpret them differently.
4) Quality estimation
Quality can be approximated without heavy bandwidth tests:
- Track rolling latency from recent successful requests (p50/p95).
- Track failure rate over a sliding window.
- Track time-to-first-byte for a small endpoint.
Use thresholds to map quality into “degraded” vs “ok”. Keep thresholds configurable remotely if possible.
State management: one source of truth
Once you have signals, you need a consistent way to store and distribute the resulting state. The key principle is single source of truth: one component computes connectivity snapshots; the rest of the app subscribes to it.
Recommended architecture
- ConnectivityMonitor: listens to OS changes, runs probes, collects request outcomes.
- ConnectivityStore: holds the latest ConnectivitySnapshot and exposes it as an observable stream (Flow/Observable/StateNotifier/etc.).
- PolicyEvaluator: merges connectivity with user/OS policies (metered network, battery saver, “Wi‑Fi only”).
- SyncOrchestrator: decides when to run sync, what scope, and with what backoff.
- UI adapters: map snapshot to banners, icons, and disabled/enabled actions.
Keep computation out of UI components. UI should render state, not infer it.
Step-by-step: implement a connectivity state machine
The following steps outline a practical implementation that works across platforms conceptually. The code is pseudocode; adapt to your stack.
Step 1: Define the snapshot and enums
enum ConnectivityState { OFFLINE, LOCAL_ONLY, ONLINE_DEGRADED, ONLINE, BACKEND_DOWN, AUTH_BLOCKED } enum Transport { WIFI, CELLULAR, ETHERNET, UNKNOWN } enum Reason { NONE, NO_INTERFACE, CAPTIVE_PORTAL, DNS_FAIL, TIMEOUT, HTTP_5XX, HTTP_401, RATE_LIMIT, TLS_ERROR } class ConnectivitySnapshot { ConnectivityState state; Transport transport; boolean isMetered; boolean captivePortalSuspected; int latencyMs; long lastInternetOkAt; long lastBackendOkAt; Reason reason; }Keep it small. If you add too many states, you will struggle to keep transitions correct.
Step 2: Collect OS network events
When the OS reports a change, update transport and interface availability immediately, but do not declare “online” yet. Instead, transition to an intermediate assumption and schedule probes.
onOsNetworkChanged(info): snapshot.transport = info.transport snapshot.isMetered = info.isMetered if (!info.hasInterface) emit(state=OFFLINE, reason=NO_INTERFACE) else emit(state=LOCAL_ONLY, reason=NONE) scheduleInternetProbeSoon()Using LOCAL_ONLY as the initial state after interface appears avoids optimistic “online” UI that flips back seconds later.
Step 3: Run an internet probe with debouncing
Debounce probes to avoid battery drain when the OS fires multiple events quickly (common when switching Wi‑Fi networks).
scheduleInternetProbeSoon(): if (probeAlreadyScheduled) return schedule(after=500ms) { runInternetProbe() }runInternetProbe(): result = httpsHead(INTERNET_PROBE_URL, timeout=3s) if (result.success) { updateInternetOk() scheduleBackendProbeSoon() } else { markInternetFailed(result) }Choose a probe URL that is stable and fast. Many teams host a tiny endpoint like /probe returning 204.
Step 4: Probe backend reachability separately
Backend reachability can be checked via a health endpoint or inferred from real requests. A dedicated probe is useful at startup to decide whether to attempt initial sync.
runBackendProbe(): result = httpsGet(API_HEALTH_URL, timeout=3s) if (result.success) emit(state=ONLINE, reason=NONE, lastBackendOkAt=now) else if (result.httpStatus in 500..599) emit(state=BACKEND_DOWN, reason=HTTP_5XX) else if (result.timeout) emit(state=ONLINE_DEGRADED, reason=TIMEOUT) else emit(state=ONLINE_DEGRADED, reason=DNS_FAIL)If your health endpoint requires auth, be careful: a 401 might mean “auth blocked” rather than “backend down”. Prefer an unauthenticated health endpoint if possible, or handle 401 explicitly.
Step 5: Integrate real request outcomes
Probes are periodic; real traffic is continuous. Instrument your HTTP client so every request reports a summarized outcome to the ConnectivityMonitor.
onHttpResult(endpointTag, outcome): if (outcome.success) { recordLatency(outcome.latencyMs) updateBackendOk() } else if (outcome.httpStatus == 401) { emit(state=AUTH_BLOCKED, reason=HTTP_401) } else if (outcome.httpStatus in 500..599) { emit(state=BACKEND_DOWN, reason=HTTP_5XX) } else if (outcome.timeout) { degradeNetwork(reason=TIMEOUT) }Tag endpoints so you can ignore failures from non-critical services (e.g., analytics) when determining backend health.
Step 6: Compute “degraded” based on rolling metrics
Maintain a sliding window of recent requests (e.g., last 20) and compute failure rate and latency percentiles.
computeQuality(): if (failureRate > 0.3) return ONLINE_DEGRADED if (p95LatencyMs > 2000) return ONLINE_DEGRADED return ONLINEUse hysteresis to avoid rapid flipping: require several good samples to move from degraded to online.
Step 7: Persist minimal state for cold start
On app launch, you may not have immediate probes yet. Persist a small subset like lastBackendOkAt and last known state. This helps decide whether to show “Trying to connect…” vs “Working offline” instantly.
onAppStart(): snapshot = loadPersistedSnapshot() emit(snapshotWithState=LOCAL_ONLY) scheduleInternetProbeSoon()Do not persist sensitive details. Timestamps and enums are usually safe.
Mapping connectivity state to app behavior
Connectivity modeling becomes valuable when it drives consistent decisions across sync, UI, and data operations.
UI behavior guidelines
- Offline: show an unobtrusive offline indicator; allow reading and local edits; avoid spinners that imply waiting for network.
- LocalNetworkOnly: show “No internet access” (not “offline”); provide a “Retry” action; avoid repeated login prompts.
- OnlineDegraded: keep UI responsive; prefer background sync with backoff; show a subtle “Sync delayed” message only if user actions are affected.
- BackendDown: communicate “Service temporarily unavailable”; keep queuing writes; avoid telling users to check Wi‑Fi when the problem is server-side.
- AuthBlocked: prompt for re-auth only when necessary; keep local work; clearly indicate which actions require sign-in.
A key UX principle is to avoid oscillation. If the connectivity state flips frequently, the UI should not flash banners repeatedly. Use rate limiting for user-visible notifications.
Sync behavior guidelines
- Run lightweight sync (metadata, small deltas) in ONLINE_DEGRADED; postpone heavy uploads/downloads.
- In BACKEND_DOWN, pause retries and use exponential backoff with jitter; do not hammer the server.
- In AUTH_BLOCKED, stop sync and wait for token refresh or user sign-in; keep the queue intact.
- In LOCAL_NETWORK_ONLY, avoid repeated DNS/HTTP attempts; probe occasionally or on user action.
State management patterns that work well
Derived state vs stored state
Store raw signals and derive the final state, or store the final state and keep raw signals for debugging. A robust approach is:
- Store raw signals (interface present, last probe results, rolling metrics).
- Compute derived snapshot deterministically.
This reduces bugs where multiple parts of the app partially update the snapshot.
Event-driven updates
Connectivity changes are events. Use an event stream to update state, and ensure updates are serialized to avoid race conditions (e.g., a slow probe result arriving after a newer one).
handleEvent(event): if (event.timestamp < lastProcessedTimestamp) ignore else apply(event) recomputeSnapshot() emit(snapshot)Include a monotonic sequence number or timestamp in probe results.
Hysteresis and cooldowns
Without hysteresis, your app will flap between states on unstable networks. Implement rules like:
- Require 2 consecutive successful probes to move from LOCAL_ONLY to ONLINE.
- Require 3 consecutive failures or a failure rate threshold to move from ONLINE to ONLINE_DEGRADED.
- After BACKEND_DOWN, wait at least 30 seconds before the next backend probe (unless user initiates).
These rules should be centralized in the ConnectivityMonitor.
Practical example: coordinating connectivity with a sync queue
Assume you have a local outbox of operations (create/update/delete). Connectivity state determines when the SyncOrchestrator drains the queue.
Step-by-step decision flow
- 1) User performs an action: write to local store immediately; enqueue an operation with a unique id.
- 2) SyncOrchestrator observes queue length > 0: checks current ConnectivitySnapshot.
- 3) If state is OFFLINE or LOCAL_ONLY: do nothing; schedule a re-check on next connectivity change.
- 4) If state is AUTH_BLOCKED: trigger token refresh if possible; otherwise request sign-in; keep queue.
- 5) If state is BACKEND_DOWN: apply exponential backoff; do not retry immediately.
- 6) If state is ONLINE_DEGRADED: attempt sync for small operations first; cap concurrency to 1; shorten payloads (batch smaller).
- 7) If state is ONLINE: run normal sync strategy (batching, parallelism within limits).
This flow prevents wasted retries and keeps the UI consistent: local actions succeed instantly, and network work adapts to conditions.
Pseudocode for orchestrator gating
onQueueOrConnectivityChanged(queueSize, snapshot): if (queueSize == 0) return switch(snapshot.state): case OFFLINE: case LOCAL_ONLY: return case AUTH_BLOCKED: authManager.ensureSignedInOrRefresh(); return case BACKEND_DOWN: scheduler.retryLater(backoff.next()); return case ONLINE_DEGRADED: syncRunner.run(mode="conservative"); return case ONLINE: syncRunner.run(mode="normal");Handling tricky real-world scenarios
Captive portals
Captive portals often look like “connected Wi‑Fi” but block internet until the user signs in. Model this as LOCAL_NETWORK_ONLY with captivePortalSuspected=true when HTTPS probes fail but the interface is up. UI can then suggest “Wi‑Fi requires sign-in” and optionally open the OS captive portal login (platform-specific).
Split-brain: internet works, backend blocked
Corporate networks may block your domain, or DNS may fail for your API while other sites work. If your internet probe succeeds but backend probe fails consistently, prefer BACKEND_DOWN (or a more specific “BackendUnreachable”) rather than ONLINE_DEGRADED. This helps support teams and avoids misleading “your connection is slow” messages.
Background restrictions and doze modes
On mobile OSes, background execution can be limited even when connectivity is good. Treat this as a policy constraint layered on top of connectivity. For example, you might keep snapshot.state=ONLINE but have PolicyEvaluator return “sync not allowed now”. This separation avoids confusing “offline” indicators when the real issue is background policy.
Multi-device and token expiration
AuthBlocked is not just “no internet”. If a refresh token is revoked on another device, your app may have full connectivity but cannot sync. Model this explicitly so the app can keep local work and request re-auth at the right time, rather than endlessly retrying and draining battery.
Testing connectivity modeling and state management
Because connectivity is a state machine, tests should focus on transitions and race conditions.
Unit tests for transitions
Write tests that feed sequences of events and assert emitted snapshots:
- Interface up → probe success → backend success ⇒ ONLINE
- Interface up → probe fail ⇒ LOCAL_NETWORK_ONLY
- ONLINE → repeated timeouts ⇒ ONLINE_DEGRADED
- ONLINE → 5xx burst ⇒ BACKEND_DOWN
- ONLINE → 401 after refresh attempt ⇒ AUTH_BLOCKED
Race condition tests
Simulate out-of-order probe results:
- Probe A starts, then network changes, Probe B starts; Probe A returns late. Ensure Probe A does not overwrite newer state.
Integration tests with network shaping
Use network link conditioners or proxy tools to simulate:
- High latency (e.g., 2000ms)
- Packet loss (e.g., 10%)
- DNS failures
- Backend returning 503
Verify that UI indicators and sync behavior match the model and do not flap.