A reconnect that fires while a previous sync is still in progress can submit the same queued action twice — a duplicate order, a duplicate payment, or a duplicate form entry. CWE-362 (Race Condition) and CWE-694 (Use of Multiple Primitives to Lock a Resource) both apply to queue processing without deduplication guards. Without idempotency keys, network flicker during sync transforms a reliability feature into a correctness hazard. On APIs that charge per-request or trigger side effects (emails, webhooks, charges), duplicates have direct financial and trust consequences.
High because duplicate submissions during reconnect can trigger double charges, double emails, or duplicate records — failures that are difficult to reverse and immediately visible to users.
Stamp each queued action with an idempotency key at enqueue time, then send it as a header on every retry. Mark items as synced rather than deleting them immediately, so a mid-sync crash doesn't re-enqueue an already-completed action.
type QueuedAction = {
idempotencyKey: string;
action: unknown;
synced: boolean;
};
export async function enqueue(action: { type: string; payload: { id: string } }) {
const raw = await AsyncStorage.getItem(QUEUE_KEY);
const queue: QueuedAction[] = raw ? JSON.parse(raw) : [];
queue.push({
idempotencyKey: `${action.type}__${action.payload.id}__${Date.now()}`,
action,
synced: false,
});
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
}
export async function processQueue() {
const raw = await AsyncStorage.getItem(QUEUE_KEY);
if (!raw) return;
const queue: QueuedAction[] = JSON.parse(raw);
for (const item of queue) {
if (item.synced) continue;
const res = await fetch('/api/action', {
method: 'POST',
headers: { 'Idempotency-Key': item.idempotencyKey, 'Content-Type': 'application/json' },
body: JSON.stringify(item.action),
});
if (res.ok) item.synced = true;
}
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue.filter(i => !i.synced)));
}
Your server must also honor the Idempotency-Key header and return the original response on replay.
ID: mobile-offline-storage.offline-behavior.duplicate-prevention
Severity: high
What to look for: Examine the offline queue implementation. Look for idempotency keys, request deduplication, or tracking mechanisms that prevent the same action from being submitted multiple times if reconnect happens while syncing.
Pass criteria: Count all queued action patterns in the codebase. Queued actions include unique identifiers (idempotency keys) or state tracking that prevents duplicate submissions even if sync is interrupted and retried. At least 1 deduplication mechanism must exist (idempotency key, unique constraint, or exists-check).
Fail criteria: Queue has no deduplication mechanism. If sync is interrupted, the same action could be submitted multiple times. No more than 0 queue enqueue paths should allow duplicates.
Skip (N/A) when: Never — duplicate prevention is critical for data integrity.
Detail on fail: "Offline queue has no idempotency keys. Same action could be submitted multiple times if connection flickers during sync."
Remediation: Add idempotency to your queue and API:
export async function queueAction(action) {
const queue = await AsyncStorage.getItem(QUEUE_KEY);
const actions = queue ? JSON.parse(queue) : [];
// Generate idempotency key
const idempotencyKey = `${action.type}_${action.payload.id}_${Date.now()}`;
actions.push({
id: Date.now(),
idempotencyKey,
action,
synced: false,
timestamp: new Date().toISOString(),
});
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(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) {
if (item.synced) continue; // Skip already synced
try {
const response = await fetch('/api/action', {
method: 'POST',
headers: {
'Idempotency-Key': item.idempotencyKey,
'Content-Type': 'application/json',
},
body: JSON.stringify(item.action),
});
if (response.ok) {
item.synced = true;
} else {
failed.push(item);
}
} catch (error) {
failed.push(item);
}
}
// Only keep unsynced actions
const remaining = actions.filter(a => !a.synced && failed.includes(a));
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(remaining));
}
On the server, implement idempotency:
// Node.js backend
const idempotencyStore = new Map(); // In production, use a database
app.post('/api/action', (req, res) => {
const idempotencyKey = req.headers['idempotency-key'];
if (idempotencyStore.has(idempotencyKey)) {
return res.json(idempotencyStore.get(idempotencyKey));
}
// Process action
const result = processAction(req.body);
idempotencyStore.set(idempotencyKey, result);
res.json(result);
});