Networking, APIs, Persistence, and Offline-First DesignIntroductionA mobile application is useful only when it can obtain, interpret, store, and update information reliably. Networking therefore involves more than sending an HTTP request. A production application must handle asynchronous execution, serialization, authentication, local persistence, intermittent connectivity, retries, cancellation, synchronization, and conflicting edits. These concerns are especially important on mobile devices, where bandwidth, battery, storage, and network availability vary continuously. This chapter presents a practical architecture for React Native and Flutter applications. The examples use REST and GraphQL concepts, but the design principles also apply to other service styles. Learning ObjectivesBy the end of this chapter, you should be able to: - Explain HTTP, REST, GraphQL, asynchronous operations, and serialization.
- Design API clients with authentication, timeouts, cancellation, retries, and structured errors.
- Persist data locally and protect sensitive credentials using secure storage.
- Distinguish cache freshness from synchronization state.
- Design an offline-first data flow and an operation queue.
- Analyze synchronization conflicts and select appropriate resolution policies.
- Evaluate security, privacy, reliability, and governance implications.
1. The Mobile Data ArchitectureA robust mobile data layer commonly has five parts: | Layer | Responsibility |
|---|
| Presentation | Displays loading, stale, empty, and error states | | Repository | Offers application-oriented operations such as getTasks() | | Remote data source | Calls REST or GraphQL services | | Local data source | Reads and writes a database or cache | | Synchronization engine | Exchanges pending local changes with the server |
The repository prevents screens from depending directly on HTTP details. A typical read operation is: Screen → Repository → Local database → Remote API when required → Repository → Screen
For an offline-first read, the local database is normally the first source consulted. The interface can display cached data immediately and refresh it in the background. This is superior to showing a blank screen while waiting for a network request. Important distinctions- Cache: A copy retained to reduce latency or network usage.
- Persistence: Data retained across application launches.
- Secure storage: Protected storage intended for secrets, such as refresh tokens.
- Synchronization: Reconciliation of local and remote state.
- Source of truth: The authority used when deciding which state is valid.
- Idempotency: Repeating an operation produces the same effective result as performing it once.
2. REST and GraphQLRESTREST commonly represents resources through URLs and uses HTTP methods: | Method | Typical meaning | Idempotency expectation |
|---|
| GET | Retrieve a representation | Yes | | POST | Create or invoke a non-idempotent action | Usually no | | PUT | Replace a resource | Yes | | PATCH | Partially modify a resource | Depends on design | | DELETE | Remove a resource | Usually yes |
A REST response should communicate status through both the HTTP status code and a structured body. For example, 401 indicates missing or invalid authentication, 403 indicates insufficient permission, 404 indicates that the resource is unavailable, and 429 indicates rate limiting. A client should not interpret every failure as a generic network error. GraphQLGraphQL generally exposes one endpoint. The client sends a query describing the desired fields or a mutation describing a change. GraphQL can reduce over-fetching and under-fetching, but it does not remove the need for authentication, caching, validation, error handling, or authorization. A response may contain both data and errors; a client must inspect both. GraphQL caching is more complex because identity is commonly represented by a type and an identifier rather than a URL. Normalized caches therefore store entities such as Task:42 and update references to them. Serialization and validationSerialization converts in-memory objects into a transport representation, commonly JSON. Deserialization performs the reverse operation. A reliable model layer should: - Validate required fields and types.
- Convert dates explicitly rather than relying on locale-dependent parsing.
- Handle unknown fields for forward compatibility.
- Distinguish absent, null, and empty values.
- Avoid trusting client-provided authorization fields.
For example, a server timestamp should be represented as an ISO 8601 string and converted to a platform date type. Local database schemas should store a consistent representation, such as UTC milliseconds or an ISO string. 3. Asynchronous Operations and Failure SemanticsNetwork and database operations are asynchronous because they involve external resources. An asynchronous function has at least three logical outcomes: success, failure, and cancellation. Cancellation is not necessarily an error; it may simply mean that the user left the screen. A good user interface distinguishes: - initial loading from background refresh;
- an empty successful result from a failed request;
- stale data from no data;
- retryable failures from permanent failures;
- user cancellation from service failure.
React Native API clientThe following TypeScript client uses fetch, an abort signal, a timeout, and typed error categories. In a real application, the token provider would read a short-lived access token from memory and refresh it through a secure refresh-token flow. export class ApiError extends Error {
constructor(
message: string,
public readonly kind: 'network' | 'timeout' | 'http' | 'decode' | 'cancelled',
public readonly status?: number,
public readonly body?: unknown
) { super(message); }
}
type RequestOptions = RequestInit & { timeoutMs?: number };
export async function requestJson<T>(
url: string,
token: string | undefined,
options: RequestOptions = {}
): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 10000);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers ?? {})
}
});
const text = await response.text();
let body: unknown = undefined;
try { body = text ? JSON.parse(text) : undefined; }
catch { throw new ApiError('Invalid JSON response', 'decode', response.status); }
if (!response.ok) {
throw new ApiError(`HTTP ${response.status}`, 'http', response.status, body);
}
return body as T;
} catch (e) {
if (e instanceof ApiError) throw e;
if ((e as Error).name === 'AbortError') {
throw new ApiError('Request timed out or was cancelled', 'timeout');
}
throw new ApiError('Network unavailable', 'network');
} finally { clearTimeout(timer); }
}The example uses AbortController for both timeout and cancellation. For a user-driven cancellation, retain the controller and call controller.abort() when the component unmounts or a new search supersedes an old one. Production code may distinguish timeout from explicit cancellation by using separate signals or a cancellation flag. Flutter API clientFlutter applications commonly use the http package. A timeout can be applied with Future.timeout; cancellation requires a client supporting cancellation, such as Dio, or an application-level mechanism that ignores results after disposal. The following example demonstrates typed decoding and error classification with http. import 'dart:convert';
import 'package:http/http.dart' as http;
class ApiException implements Exception {
final String message;
final int? status;
ApiException(this.message, {this.status});
}
Future<T> getJson<T>(
Uri uri,
T Function(Object?) decode,
String? accessToken,
) async {
try {
final response = await http.get(uri, headers: {
'Accept': 'application/json',
if (accessToken != null) 'Authorization': 'Bearer $accessToken',
}).timeout(const Duration(seconds: 10));
final body = response.body.isEmpty ? null : jsonDecode(response.body);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException('HTTP ${response.statusCode}', status: response.statusCode);
}
return decode(body);
} on ApiException {
rethrow;
} on FormatException {
throw ApiException('Invalid JSON response');
} on Exception catch (e) {
throw ApiException('Network or timeout failure: $e');
}
}A FutureBuilder is convenient for demonstrations, but a repository plus state-management solution is usually more testable and more suitable for offline-first behavior. 4. Authentication and Secure StorageA common architecture uses a short-lived access token and a longer-lived refresh token. The access token is sent in an Authorization: Bearer … header. The refresh token should be stored in platform-protected storage, such as iOS Keychain or Android Keystore-backed storage, rather than ordinary preferences or an unencrypted database. React Native example using expo-secure-store: import * as SecureStore from 'expo-secure-store';
export async function saveRefreshToken(token: string) {
await SecureStore.setItemAsync('refresh_token', token, {
keychainAccessible: SecureStore.WHEN_UNLOCKED
});
}
export const readRefreshToken = () =>
SecureStore.getItemAsync('refresh_token');
export const deleteRefreshToken = () =>
SecureStore.deleteItemAsync('refresh_token');Flutter example using flutter_secure_storage: import 'package:flutter_secure_storage/flutter_secure_storage.dart';
const secure = FlutterSecureStorage();
Future<void> saveRefreshToken(String token) =>
secure.write(key: 'refresh_token', value: token);
Future<String?> readRefreshToken() =>
secure.read(key: 'refresh_token');Security rules include never logging tokens, never placing secrets in source control, validating TLS certificates through the operating system, limiting token scope, rotating refresh tokens where supported, and revoking credentials after logout. Secure storage protects confidentiality at rest but does not protect a token from a compromised device or malicious code executing inside the application. 5. Persistence and Cache FreshnessA local database is appropriate for structured, queryable application data. SQLite, Drift, Room, Core Data, Realm, and Isar are examples of technologies used in mobile systems. Key-value storage is suitable for small preferences, feature flags, and metadata; it is not a replacement for relational queries or secure secret storage. A cache record may contain: entity_id, payload, stored_at, server_version, sync_state, deleted_at The freshness test must account for clock anomalies. Let t be the current device time, t_stored the storage time, and TTL the permitted freshness interval. A record is fresh only when: 0 ≤ t − tstored ≤ TTL The lower bound prevents a future-dated record from being incorrectly treated as fresh after a clock rollback or faulty timestamp. The formula assumes both times use the same clock domain and units. A monotonic clock is preferable for measuring elapsed durations; wall-clock time is needed for timestamps shared with a server. For example, if t = 14:10:00, t_stored = 14:08:30, and TTL = 120 s, then the age is 90 s, so the record is fresh. If a device reports t_stored = 14:11:00, then t − t_stored = −60 s; the lower bound fails and the record should be revalidated. A stale-while-revalidate policy returns cached data immediately, then refreshes it in the background. The UI should label stale data when that distinction matters, especially for financial, medical, or operational information. 6. Retry, Backoff, and IdempotencyRetries are appropriate for transient network failures, selected 5xx responses, and rate limiting when the server supplies guidance. They are generally inappropriate for invalid credentials, validation errors, authorization failures, or a non-idempotent operation whose outcome is unknown. Let D₀ be the initial base delay, n the zero-based retry number, D_max the maximum total delay including jitter, and J a random jitter value satisfying 0 ≤ J ≤ J_max. A mathematically consistent capped exponential-backoff delay is: Dn = min(Dmax, D₀ × 2ⁿ + J) The cap is applied after adding jitter, so the result cannot exceed D_max. For D₀ = 500 ms, n = 2, J = 120 ms, and D_max = 3000 ms: D_n = min(3000, 500 × 4 + 120) = 2120 ms.
If the uncapped value is 3320 ms, the actual delay is 3000 ms, not 3320 ms. Jitter reduces synchronized retry storms, but it does not make an unsafe operation safe to repeat. For creation requests, use an idempotency key when the server supports it. The client sends a unique key; repeated requests with that key refer to the same logical operation. This is safer than blindly retrying POST after a timeout because the server may have completed the operation even though the response was lost. 7. Offline-First DesignOffline-first design treats connectivity as optional rather than guaranteed. A write is first recorded locally, displayed optimistically when appropriate, and placed in an outbox for later transmission. The outbox record should include an operation identifier, entity identifier, operation type, payload, creation time, retry count, and current state. A robust write sequence is: - Validate input locally.
- Begin a database transaction.
- Apply the local change.
- Add an outbox operation with a unique identifier.
- Commit the transaction.
- Attempt synchronization when connectivity is available.
- Mark the operation acknowledged or record a retryable/permanent failure.
The transaction is essential: a local change without an outbox entry can never synchronize, while an outbox entry without the local change can produce misleading UI state. React Native persistence sketchUsing an SQLite library, the essential transaction is conceptually: await db.withTransactionAsync(async () => {
await db.runAsync(
'UPDATE tasks SET title = ?, sync_state = ? WHERE id = ?',
[title, 'pending', taskId]
);
await db.runAsync(
`INSERT INTO outbox(op_id, entity_id, kind, payload, attempts)
VALUES (?, ?, ?, ?, 0)`,
[crypto.randomUUID(), taskId, 'update_task', JSON.stringify({ title }),]
);
});The exact API varies by library, but the atomicity requirement does not. Flutter persistence sketchWith Drift, a transaction can combine the entity and outbox writes: await db.transaction(() async {
await (db.update(db.tasks)..where((t) => t.id.equals(taskId)))
.write(TasksCompanion(
title: Value(title),
syncState: const Value('pending'),
));
await db.into(db.outbox).insert(OutboxCompanion.insert(
opId: const Uuid().v4(),
entityId: taskId,
kind: 'update_task',
payload: jsonEncode({'title': title}),
));
});The synchronization worker should be bounded, observable, and safe to run more than once. It should use connectivity signals only as a hint: a device can report Wi-Fi while the service remains unreachable, so the actual request determines availability. 8. Synchronization and Conflict ResolutionSynchronization requires a protocol, not merely a background HTTP call. A server may expose a version number, an update timestamp, an ETag, or a change cursor. The client sends its base version with a mutation. If the server version has changed, the server can reject the update with a conflict response rather than silently overwriting another user’s work. Common policies are: - Last-write-wins: The later timestamp or version wins. Simple, but can lose valid edits and depends on reliable ordering.
- Server-wins: Remote state replaces local state. Appropriate when the server is authoritative and local edits are disposable.
- Client-wins: Local state replaces remote state. Risky for shared data.
- Field-level merge: Independent fields are merged, while overlapping fields conflict.
- Domain-specific merge: For example, set union for tags or additive logic for counters.
- Manual resolution: The user chooses between versions or edits a merged draft.
A timestamp-based rule should not be described as universally correct: device clocks can be inaccurate. Logical versions or server-issued sequence numbers are usually more defensible. For a task title, a field-level conflict can be presented as: Local: “Submit final report”
Remote: “Submit signed report”
Result: show both versions and request user choice For a set of labels, a union may be safe only if removal semantics are also represented. Naive union can resurrect an item that another user deliberately removed. Conflict strategy must therefore reflect domain meaning. 9. GraphQL Client ConsiderationsA GraphQL mutation should include the fields required for cache updates and, where relevant, a version condition: mutation UpdateTask($id: ID!, $title: String!, $version: Int!) {
updateTask(id: $id, title: $title, expectedVersion: $version) {
task { id title version updatedAt }
conflict { serverTitle serverVersion }
}
}The client should treat GraphQL errors as structured data, not assume that HTTP 200 means success. Queries should request only needed fields, while mutations should return enough identity and version data to update local records. Persisted queries, pagination, normalized caches, and cursor-based synchronization are contemporary techniques that improve performance but add operational complexity. 10. Error Handling and ObservabilityErrors should be classified by action: | Class | Example | Client action |
|---|
| Connectivity | DNS failure, airplane mode | Keep local data; retry later | | Timeout | Slow service | Retry with bounded backoff | | Authentication | Expired access token | Refresh once, then sign in again | | Authorization | 403 | Explain permission failure; do not retry | | Validation | 422 | Show field-level correction | | Conflict | Version mismatch | Merge or request user choice | | Permanent server error | Unsupported operation | Mark failed and inform user |
Log correlation identifiers, operation types, latency, status class, retry count, and synchronization outcome. Do not log access tokens, passwords, personal content, or full request bodies by default. Crash and performance monitoring should respect consent, retention limits, and regional privacy requirements. 11. Security, Privacy, and GovernanceThe client is an untrusted environment. Authorization must be enforced on the server, even if the client hides a button. Apply least privilege, validate every request server-side, use TLS, protect local databases where threat models require it, and erase sensitive data on logout or account removal. Offline copies create additional privacy risk. A user may expect a record to disappear immediately after deletion, while the device retains it in a cache or outbox. Define deletion propagation, retention, backup behavior, and remote wipe requirements. For regulated information, document data classification, consent, auditability, breach response, and applicable law. Accessibility also matters: offline and error states must be announced clearly to assistive technologies. 12. Illustrative Case: Field Service Inspection AppBackgroundA field-service company equips technicians with a mobile application for equipment inspections. Technicians often work in basements where connectivity is unreliable. Each inspection includes readings, photographs, comments, and a signature. ProblemA network-dependent design caused blank screens, lost comments, duplicate submissions after retries, and conflicts when supervisors edited inspection metadata from a web portal. AnalysisThe data has different consistency requirements. A draft comment can be stored locally and synchronized later. A safety-critical reading requires validation and an audit trail. A signature must be protected from casual alteration. Supervisor changes require version-aware conflict handling. ApproachThe redesigned app uses a local SQLite database, an outbox transaction, secure storage for tokens, and a repository that exposes local data immediately. Each inspection has a server-issued version. Mutations include the expected version and an idempotency key. Images are stored in an encrypted or protected file area, with upload metadata in the database. Synchronization uses bounded backoff and resumes after process termination. When a version conflict occurs, non-overlapping fields are merged; conflicting safety readings are presented to a supervisor rather than silently resolved. OutcomeTechnicians can complete inspections offline, duplicate submissions are eliminated by idempotency keys, and conflicts become visible rather than destructive. The trade-off is greater schema, testing, and support complexity. Learning pointsOffline-first is a consistency design decision. It requires explicit ownership, versioning, durable queues, security controls, and a user-visible conflict policy. 13. Testing and Professional PracticeTest the data layer independently of screens. Important tests include: - decoding valid, missing, null, and unknown fields;
- timeout, cancellation, malformed JSON, and HTTP failures;
- token refresh race conditions;
- database transaction rollback;
- duplicate outbox delivery;
- process termination during synchronization;
- clock rollback and future-dated cache entries;
- conflict policies and deletion propagation;
- migration from old local schemas;
- accessibility of stale and error states.
Use mock servers and deterministic clocks. Property-based tests are useful for queue invariants, such as “an acknowledged operation is not sent again” and “a local mutation always has a corresponding outbox record.” Measure cache hit rate, synchronization success rate, median and tail latency, retry volume, conflict frequency, and data-loss incidents. These metrics connect engineering decisions to user and business outcomes. Chapter SummaryMobile networking is a systems problem involving transport, data modeling, persistence, security, and synchronization. REST uses resource-oriented HTTP conventions, while GraphQL allows clients to specify response shape; both require validation and structured error handling. Asynchronous operations must distinguish success, failure, timeout, and cancellation. Access tokens should be short-lived and refresh credentials should use secure platform storage. Persistence enables responsive applications and offline operation. Cache freshness is valid only when 0 ≤ t − t_stored ≤ TTL, with careful treatment of clock domains. Retry delays must be capped after jitter: D_n = min(D_max, D₀ × 2ⁿ + J). Offline-first writes require an atomic local update plus an outbox entry. Synchronization depends on versions, idempotency, bounded retries, and an explicit conflict policy. Finally, privacy, observability, accessibility, and testing are part of the data architecture rather than afterthoughts. Key TermsAPI: An interface through which software components exchange data or operations. REST: A resource-oriented architectural style using HTTP semantics. GraphQL: A typed query and mutation language commonly served through one endpoint. Serialization: Conversion of in-memory data into a transferable representation. Deserialization: Reconstruction of application data from a transport representation. Asynchronous operation: Work whose completion occurs independently of the caller’s immediate execution flow. Access token: A short-lived credential used to authorize API requests. Refresh token: A longer-lived credential used to obtain a new access token. Secure storage: Platform-protected storage intended for sensitive values. Persistence: Retention of data across process or application launches. Cache: A retained copy used to reduce latency or network use. TTL: Time-to-live, the permitted freshness interval for cached data. Idempotency: The property that repetition has the same effective result as one execution. Backoff: Deliberate delay before retrying an operation. Jitter: Random variation added to a retry delay. Outbox: A durable local queue of changes awaiting synchronization. Synchronization: Reconciliation of local and remote state. Conflict: A condition in which local and remote changes cannot be safely combined automatically. Optimistic update: Immediate local presentation of a change before server confirmation. Repository: An abstraction that coordinates local and remote data sources. Review QuestionsShort Answer Questions- Why should a repository separate screens from HTTP implementation details?
- What is the difference between a cache and secure storage?
- Why is an outbox entry written in the same transaction as a local mutation?
- What does an idempotency key protect against?
- Why can an HTTP 200 response still represent a GraphQL failure?
Long Answer Questions- Compare REST and GraphQL with respect to resource modeling, caching, errors, and client flexibility.
- Explain a complete access-token and refresh-token lifecycle, including security controls.
- Describe an offline-first synchronization protocol from local edit through server acknowledgement.
- Compare last-write-wins, field-level merge, and manual conflict resolution.
- Design an error taxonomy for a mobile application and explain the user action associated with each category.
Analytical / Application Questions- An application stores a record at 09:00:00, reads it at 09:01:30, and uses a TTL of 120 seconds. Determine whether it is fresh. Then explain what should happen if the device clock reports 08:59:30 at read time.
- A retry policy uses
D₀ = 400 ms, n = 3, J = 150 ms, and D_max = 2500 ms. Calculate the actual delay and explain why the cap is applied where it is. - Two users edit different fields of the same inspection while offline, but both edits are based on server version 7. Propose a synchronization and conflict policy, including what should happen when both edit the same field.
Multiple Choice Questions with AnswersQuestion 1Which expression correctly caps exponential backoff after jitter? A. Dn = D0 × 2n + min(J,Dmax) B. Dn = min(Dmax, D0 × 2n + J) C. Dn = min(Dmax, D0) × 2n + J D. Dn = D0 × 2n + Dmax + J Correct Answer: B. Dn = min(Dmax, D0 × 2n + J) Question 2A cache entry has t − t_stored = 90 seconds and TTL = 120 seconds. What is its status? A. Fresh B. Expired C. Invalid because its age is negative D. Permanently synchronized Correct Answer: A. Fresh Question 3Where should a refresh token normally be stored in a mobile application? A. A source-code constant B. An ordinary UI label C. Platform-protected secure storage D. A public analytics event Correct Answer: C. Platform-protected secure storage Question 4What is the principal purpose of an outbox? A. To store screen colors B. To retain pending local changes for later synchronization C. To replace server authorization D. To compress images automatically Correct Answer: B. To retain pending local changes for later synchronization Question 5Which HTTP method is normally intended to replace a resource and is idempotent by design? A. GET B. POST C. PUT D. CONNECT Correct Answer: C. PUT Question 6A GraphQL response contains HTTP 200 and a non-empty errors field. What should the client do? A. Assume every requested operation succeeded B. Inspect and handle the GraphQL errors C. Delete the local database D. Retry indefinitely Correct Answer: B. Inspect and handle the GraphQL errors Question 7Why are retries unsafe for some POST requests? A. POST responses are always encrypted B. The server may have completed the operation before the response was lost C. POST cannot contain JSON D. POST is always rejected offline Correct Answer: B. The server may have completed the operation before the response was lost Question 8Which mechanism best detects that a server record changed since a client read it? A. A server-issued version checked during mutation B. The device’s screen brightness C. A random UI color D. The number of application icons Correct Answer: A. A server-issued version checked during mutation Question 9What is the main benefit of cancelling an obsolete search request? A. It prevents stale results from replacing newer results and saves resources B. It guarantees the server never received the request C. It permanently authenticates the user D. It converts REST into GraphQL Correct Answer: A. It prevents stale results from replacing newer results and saves resources Question 10For D₀ = 400 ms, n = 3, J = 150 ms, and D_max = 2500 ms, what delay results? A. 1200 ms B. 2550 ms C. 2500 ms D. 3350 ms Correct Answer: C. 2500 ms
|