When a user submits a form or triggers a mutation while offline and the app silently drops the request, data is lost without notice. This is both a trust failure and a functional defect. CWE-362 (Concurrent Execution Race Condition) applies when reconnect occurs mid-action without a serialized queue: the same action can fire multiple times or not at all. An offline action queue with local storage persistence ensures user intent is preserved across connectivity gaps, reconnects, and app restarts — a baseline requirement for any app used in variable-network environments.
Medium because dropped offline mutations cause silent data loss, but the immediate impact is bounded to the current session rather than enabling cross-user exploitation.
Implement a durable action queue in AsyncStorage. Enqueue every mutation when offline; drain the queue ordered by timestamp when connectivity returns.
const QUEUE_KEY = 'offline_queue';
export async function enqueue(action: unknown) {
const raw = await AsyncStorage.getItem(QUEUE_KEY);
const queue: unknown[] = raw ? JSON.parse(raw) : [];
queue.push({ id: `${Date.now()}-${Math.random()}`, action, retries: 0 });
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
}
export async function drainQueue(execute: (a: unknown) => Promise<void>) {
const raw = await AsyncStorage.getItem(QUEUE_KEY);
if (!raw) return;
const queue = JSON.parse(raw) as Array<{ id: string; action: unknown; retries: number }>;
const failed: typeof queue = [];
for (const item of queue) {
try {
await execute(item.action);
} catch {
if (item.retries < 3) failed.push({ ...item, retries: item.retries + 1 });
}
}
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(failed));
}
Call drainQueue inside your NetInfo listener on the offline→online transition (see data-sync-on-reconnect). Show a pending-actions badge in the UI so users know their changes are queued.
ID: mobile-offline-storage.offline-behavior.action-queueing
Severity: medium
What to look for: Examine how the app handles user actions (form submissions, updates, deletes) when offline. Look for an offline queue or similar pattern that stores pending actions and syncs them when reconnected.
Pass criteria: Count all user action handlers (form submissions, updates, deletes) in the codebase. When offline, user actions are queued locally with at least 1 queue storage mechanism. App shows feedback that actions are pending. On reconnect, queued actions are processed in order.
Fail criteria: User actions fail when offline with no recovery. App does not queue actions for later sync. No more than 0 mutation handlers should throw unhandled errors when offline.
Skip (N/A) when: Never — action queueing is essential for offline-first apps.
Detail on fail: "No action queueing found. Form submissions fail when offline with error messages. No pending queue visible to users."
Remediation: Implement a simple action queue using AsyncStorage:
const QUEUE_KEY = 'offline_queue';
export async function queueAction(action) {
const queue = await AsyncStorage.getItem(QUEUE_KEY);
const actions = queue ? JSON.parse(queue) : [];
actions.push({
id: Date.now(),
action,
timestamp: new Date().toISOString(),
retryCount: 0,
});
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(actions));
return actions;
}
export async function processQueue() {
const queue = await AsyncStorage.getItem(QUEUE_KEY);
if (!queue) return;
const actions = JSON.parse(queue);
const failed = [];
for (const item of actions) {
try {
await executeAction(item.action);
// Action succeeded, remove from queue
} catch (error) {
item.retryCount++;
if (item.retryCount < 3) {
failed.push(item);
}
console.error('Action sync failed:', error);
}
}
// Update queue with failed items
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(failed));
return failed.length === 0;
}
// Call processQueue when connection is restored
useEffect(() => {
const unsubscribe = NetInfo.addEventListener(state => {
if (state.isConnected && !isOnline) {
console.log('Connection restored, syncing...');
processQueue();
}
setIsOnline(state.isConnected ?? false);
});
return unsubscribe;
}, [isOnline]);