# Offline queue prevents duplicate submissions on reconnect

- **Pattern:** `ab-001879` (`mobile-offline-storage.offline-behavior.duplicate-prevention`)
- **Severity:** high
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-001879
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-001879)

## Why it matters

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.

## Severity rationale

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.

## Remediation

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.

```tsx
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.

## Detection

- **ID:** `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:

  ```tsx
  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:

  ```typescript
  // 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);
  });
  ```

## External references

- cwe CWE-362
- cwe CWE-694

Taxons: data-integrity

HTML version: https://auditbuffet.com/patterns/ab-001879
