Swoff Swoff

Mutation Queuing

Queue write operations when offline — sync when connection returns, reconcile data, invalidate cache.

Mutation Queuing

Status: Available Pattern

Queue write operations (POST, PUT, DELETE) when offline, then sync when connection returns — with ID reconciliation and cache invalidation.

The Problem

User creates a todo while offline → POST to your API fails. Data is lost. Even when online: after mutation syncs, cached GET responses are stale. Even when online: server assigns its own ID, local record still has temp ID.

Note: Swoff doesn't provide backend code. These patterns are for when you build your own backend separately.

The Solution

User clicks "Save"

Queue mutation: { method, url, body, _tempId }

Show pending indicator

When online: process queue

For each mutation:
  FETCH to server → server responds with created/updated record

  RECONCILE: update local record with server ID + fields, $synced: true

  INVALIDATE: clear cached GET responses tagged with related tags

  UI reactively refetches fresh data

Prerequisites

  1. IndexedDB Patterns — understand object stores and CRUD
  2. API IntegrationfetchWithCache with cache tags for invalidation
  3. SW Template — tag-indexed caching + INVALIDATE_TAG message handler
  4. Data Layer — how query, mutate, reconcile, and invalidate connect

Implementation

Queue Schema

Each mutation in the queue stores:

FieldTypePurpose
idstringUnique queue item ID (crypto.randomUUID())
methodstringHTTP method (POST, PUT, DELETE)
urlstringBackend API endpoint
bodyobjectPayload sent to server
headersobjectExtra headers (e.g., auth tokens)
previousDataobject|nullSnapshot of data before optimistic update (for rollback on failure)
timestampnumberWhen queued (for FIFO ordering)
retryCountnumberHow many times sync has been attempted
tagsstring[]Cache tags to invalidate on success
storeNamestringLocal IndexedDB store for reconciliation
tempIdstringLocal record ID to reconcile with server response

swoff/mutation-queue.js

swoff/mutation-queue.js
const DB_NAME = "swoff-queue";
const STORE_NAME = "mutations";
const MAX_RETRIES = 5;

function openQueueDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, 1);
    request.onupgradeneeded = (e) => {
      const db = e.target.result;
      if (!db.objectStoreNames.contains(STORE_NAME)) {
        const store = db.createObjectStore(STORE_NAME, { keyPath: "id" });
        store.createIndex("by-timestamp", "timestamp");
      }
    };
    request.onsuccess = (e) => resolve(e.target.result);
    request.onerror = (e) => reject(e.target.error);
  });
}

let isSyncing = false;

/**
 * Queue a mutation for later delivery.
 * @param {object} mutation
 * @param {string} mutation.method - POST, PUT, DELETE
 * @param {string} mutation.url - Backend endpoint
 * @param {object} mutation.body - Payload
 * @param {object} [mutation.headers] - Extra headers
 * @param {string[]} [mutation.tags] - Cache tags to invalidate on success
 * @param {string} [mutation.storeName] - App's IndexedDB store for reconciliation
 * @param {string} [mutation.tempId] - Local record ID to reconcile (omit if none)
 */
export async function queueMutation(mutation) {
  const db = await openQueueDB();
  const tx = db.transaction(STORE_NAME, "readwrite");
  const store = tx.objectStore(STORE_NAME);

  store.add({
    id: crypto.randomUUID(),
    method: mutation.method,
    url: mutation.url,
    body: mutation.body,
    headers: mutation.headers || {},
    previousData: mutation.previousData || null,
    timestamp: Date.now(),
    retryCount: 0,
    tags: mutation.tags || [],
    storeName: mutation.storeName || null,
    tempId: mutation.tempId || null,
  });

  await new Promise((resolve, reject) => {
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });

  window.dispatchEvent(new CustomEvent("mutation-queue-changed"));
}

/**
 * Process the queue — send, reconcile, invalidate.
 * Called automatically on `online` event.
 */
export async function processMutationQueue() {
  if (!navigator.onLine || isSyncing) return;
  isSyncing = true;

  try {
    const db = await openQueueDB();
    const tx = db.transaction(STORE_NAME, "readonly");
    const store = tx.objectStore(STORE_NAME);
    const index = store.index("by-timestamp");
    const queue = await new Promise((resolve, reject) => {
      const request = index.getAll();
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });

    let succeeded = 0;
    let failed = 0;

    for (const item of queue) {
      if (item.retryCount >= MAX_RETRIES) {
        await rollbackMutation(item);
        await removeFromQueue(item.id);
        failed++;
        continue;
      }

      try {
        const response = await fetch(item.url, {
          method: item.method,
          headers: {
            "Content-Type": "application/json",
            ...item.headers,
          },
          body: JSON.stringify(item.body),
        });

        if (!response.ok) throw new Error(`HTTP ${response.status}`);

        const serverData = await response.json();

        // Reconcile: update local record with server response
        if (item.tempId && item.storeName) {
          await reconcileRecord(item.storeName, item.tempId, serverData);
        }

        // Invalidate: clear stale cached queries
        if (item.tags && item.tags.length > 0) {
          await invalidateByTags(item.tags);
        }

        await removeFromQueue(item.id);
        succeeded++;
      } catch (error) {
        item.retryCount++;
        await updateInQueue(item);
        failed++;
      }
    }

    window.dispatchEvent(
      new CustomEvent("mutation-sync-complete", {
        detail: { succeeded, failed },
      }),
    );
  } finally {
    isSyncing = false;
    window.dispatchEvent(new CustomEvent("mutation-queue-changed"));
  }
}

async function removeFromQueue(id) {
  const db = await openQueueDB();
  const tx = db.transaction(STORE_NAME, "readwrite");
  tx.objectStore(STORE_NAME).delete(id);
  await new Promise((resolve, reject) => {
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

async function updateInQueue(item) {
  const db = await openQueueDB();
  const tx = db.transaction(STORE_NAME, "readwrite");
  tx.objectStore(STORE_NAME).put(item);
  await new Promise((resolve, reject) => {
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

async function rollbackMutation(item) {
  if (!item.storeName) return;

  const { getRecord, putRecord, deleteRecord } = await import("./store.js");

  if (item.method === "POST" && item.tempId) {
    // Created record never reached the server — delete optimistic data
    await deleteRecord(item.storeName, item.tempId);
  } else if (
    (item.method === "PUT" || item.method === "PATCH") &&
    item.previousData
  ) {
    // Update failed — restore previous state
    await putRecord(item.storeName, {
      ...item.previousData,
      $synced: true,
    });
  } else if (item.method === "DELETE" && item.tempId && item.previousData) {
    // Deletion failed — restore the record
    await putRecord(item.storeName, {
      ...item.previousData,
      $synced: true,
    });
  }

  window.dispatchEvent(
    new CustomEvent("mutation-rollback", {
      detail: {
        method: item.method,
        url: item.url,
        tempId: item.tempId,
        previousData: item.previousData,
      },
    }),
  );
}

// Auto-process when back online
window.addEventListener("online", processMutationQueue);

ID Reconciliation

When the server creates a record, it assigns its own ID — auto-increment integer, UUID, nanoid, whatever. The client must update its local data to match.

The Problem

Local IndexedDB:   { id: "temp_abc123", title: "Grocery", $synced: false }
                       ↓ POST /api/todos
Server returns:    { id: 42, title: "Grocery", createdAt: "..." }
                       ↓ now what?
Local IndexedDB:   { id: "temp_abc123", ... }  ← stale ID
                   transactions referencing "temp_abc123" → broken
                   cached GET /api/todos → still returns old data

Solution: reconcileRecord()

swoff/reconcile.js
import { getRecord, putRecord, deleteRecord } from "./store.js";

/**
 * Update a local record with server data after a successful mutation sync.
 * Replaces temp ID with server ID, merges server fields, marks $synced.
 *
 * @param {string} storeName - IndexedDB store name
 * @param {string} tempId - Local temp ID
 * @param {object} serverData - Response from the server
 */
export async function reconcileRecord(storeName, tempId, serverData) {
  const existing = await getRecord(storeName, tempId);
  if (!existing) return; // Already reconciled or deleted

  // Merge: server data wins, keep any local-only fields
  const reconciled = {
    ...existing,
    ...serverData,
    id: serverData.id, // Server ID replaces temp ID
    $synced: true,
    $syncedAt: Date.now(),
  };

  // Write the reconciled record under the server ID
  await putRecord(storeName, reconciled);

  // Remove the old temp ID entry if different from server ID
  if (String(tempId) !== String(serverData.id)) {
    await deleteRecord(storeName, tempId);
  }

  // Update references in related stores
  await reconcileReferences(tempId, serverData.id);
}

/**
 * Update foreign-key references from oldId to newId across related stores.
 * Override this for your app's schema.
 */
export async function reconcileReferences(oldId, newId) {
  // Example: update transactions that reference this todo
  // const txns = await queryRecords("transactions", { todoId: oldId });
  // for (const txn of txns) {
  //   txn.todoId = newId;
  //   await putRecord("transactions", txn);
  // }
}

This function:

  • Reads the local record by temp ID
  • Merges server fields (server data wins)
  • Overwrites id with the server-assigned ID
  • Sets $synced: true and $syncedAt
  • Writes under the server ID, deletes old temp ID entry
  • Updates references in related stores

No assumption about ID type. Works with auto-increment integers, UUIDs, nanoids, snowflakes — whatever your backend uses. The server is always authoritative.

Cache Invalidation

After a mutation syncs, cached GET responses are stale. The queue item's tags field tells the system what to invalidate.

How Tags Flow

queueMutation({
  url: "/api/todos",
  method: "POST",
  body: { title: "Grocery" },
  tags: ["todos"],              // ← invalidate these after sync
  storeName: "todos",           // ← reconcile this store
  tempId: "temp_abc123",        // ← reconcile this record
})

processMutationQueue() sends fetch → success

reconcileRecord("tokens", "temp_abc123", serverData)

invalidateByTag("todos")
    → SW clears cached GET /api/todos
    → window event "cache-invalidated"
    → useQuery("todos") auto-refetches

The invalidateByTag Helper

swoff/cache.js
/**
 * Invalidate cached responses by tag.
 * Clears SW runtime cache + dispatches window event.
 */
export async function invalidateByTag(tag) {
  if (!navigator.serviceWorker?.controller) return;
  navigator.serviceWorker.controller.postMessage({
    type: "INVALIDATE_TAG",
    tag,
  });
  window.dispatchEvent(
    new CustomEvent("cache-invalidated", { detail: { tags: [tag] } }),
  );
}

export async function invalidateByTags(tags) {
  for (const tag of tags) {
    await invalidateByTag(tag);
  }
}

See API Integration for the full implementation.

$synced Tracking

Each locally-stored record should carry $synced to indicate whether it has been confirmed by the server:

FieldTypeMeaning
$syncedbooleanfalse = optimistic local-only, true = confirmed by server
$syncedAtnumber (epoch)When the server confirmed

Records with $synced: false are optimistic — the user sees them immediately, but they only exist locally until the mutation syncs.

// When inserting a record optimistically:
const tempId = crypto.randomUUID();
await putRecord("todos", {
  id: tempId,
  title: "Grocery",
  completed: false,
  $synced: false,
});

// Queue the mutation with reconciliation info
await queueMutation({
  method: "POST",
  url: "/api/todos",
  body: { title: "Grocery", completed: false },
  tags: ["todos"],
  storeName: "todos",
  tempId,
});

Integration with Existing Patterns

API Integration

When online, use fetchWithCache with tags. When offline, queue the mutation with the same tags:

import { fetchWithCache } from "./swoff/fetch-wrapper.js";
import { queueMutation } from "./swoff/mutation-queue.js";

async function createTodo(title) {
  const tempId = crypto.randomUUID();
  const todo = { title, completed: false };

  await putRecord("todos", {
    id: tempId,
    ...todo,
    $synced: false,
  });
  addTodoToUI({ id: tempId, ...todo, $synced: false });

  if (navigator.onLine) {
    const response = await fetchWithCache("/api/todos", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(todo),
    });
    const serverData = await response.json();
    await reconcileRecord("todos", tempId, serverData);
    await invalidateByTag("todos");
  } else {
    await queueMutation({
      method: "POST",
      url: "/api/todos",
      body: todo,
      tags: ["todos"],
      storeName: "todos",
      tempId,
    });
  }
}

async function updateTodo(id, changes) {
  const existing = await getRecord("todos", id);
  const updated = { ...existing, ...changes, $synced: false };

  await putRecord("todos", updated);
  updateTodoInUI(updated);

  await queueMutation({
    method: "PUT",
    url: `/api/todos/${id}`,
    body: changes,
    tags: ["todos", `todo:${id}`],
    storeName: "todos",
    tempId: id,
    previousData: existing,
  });
}

async function deleteTodo(id) {
  const existing = await getRecord("todos", id);

  await deleteRecord("todos", id);
  removeTodoFromUI(id);

  await queueMutation({
    method: "DELETE",
    url: `/api/todos/${id}`,
    body: null,
    tags: ["todos", `todo:${id}`],
    storeName: "todos",
    tempId: id,
    previousData: existing,
  });
}

Integration with Background Sync

For apps that need to sync even after the user closes the tab, combine with Background Sync:

import { queueMutation } from "./swoff/mutation-queue.js";
import { syncWhenPossible } from "./swoff/background-sync.js";

async function createTodo(title) {
  const tempId = crypto.randomUUID();
  // ... optimistic update ...

  await queueMutation({
    method: "POST",
    url: "/api/todos",
    body: { title },
    tags: ["todos"],
    storeName: "todos",
    tempId,
  });

  // Try Background Sync (falls back to online event)
  await syncWhenPossible();
}

UI Indicators

import { getPendingCount } from "./swoff/mutation-queue.js";

window.addEventListener("mutation-queue-changed", async () => {
  const count = await getPendingCount();
  if (count > 0) {
    showIndicator(`${count} changes pending sync`);
  } else {
    showIndicator("✔ Synced");
  }
});
swoff/mutation-queue.js
export async function getPendingCount() {
  const db = await openQueueDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(STORE_NAME, "readonly");
    const request = tx.objectStore(STORE_NAME).count();
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

Window Events

EventDetailWhen
mutation-queue-changednoneQueue added/removed/updated
mutation-sync-complete{ succeeded, failed }After queue drain
mutation-rollback{ method, url, tempId, previousData }Mutation exhausted retries, optimistic data reverted
cache-invalidated{ tags: string[] }After cache tags cleared

Framework Adapters

React

hooks/useMutationQueue.js
import { useState, useEffect } from "react";
import { getPendingCount, processMutationQueue } from "../swoff/mutation-queue.js";

export function useMutationQueue() {
  const [pendingCount, setPendingCount] = useState(0);
  const [rollback, setRollback] = useState(null);

  useEffect(() => {
    async function update() {
      setPendingCount(await getPendingCount());
    }
    update();

    function onRollback(e) {
      setRollback(e.detail);
    }

    window.addEventListener("mutation-queue-changed", update);
    window.addEventListener("online", update);
    window.addEventListener("offline", update);
    window.addEventListener("mutation-rollback", onRollback);
    return () => {
      window.removeEventListener("mutation-queue-changed", update);
      window.removeEventListener("online", update);
      window.removeEventListener("offline", update);
      window.removeEventListener("mutation-rollback", onRollback);
    };
  }, []);

  return { pendingCount, syncNow: processMutationQueue, rollback };
}

Vue

composables/useMutationQueue.js
import { ref, onMounted, onUnmounted } from "vue";
import { getPendingCount, processMutationQueue } from "../swoff/mutation-queue.js";

export function useMutationQueue() {
  const pendingCount = ref(0);
  const rollback = ref(null);

  async function update() {
    pendingCount.value = await getPendingCount();
  }

  function onRollback(e) {
    rollback.value = e.detail;
  }

  onMounted(() => {
    update();
    window.addEventListener("mutation-queue-changed", update);
    window.addEventListener("online", update);
    window.addEventListener("offline", update);
    window.addEventListener("mutation-rollback", onRollback);
  });

  onUnmounted(() => {
    window.removeEventListener("mutation-queue-changed", update);
    window.removeEventListener("online", update);
    window.removeEventListener("offline", update);
    window.removeEventListener("mutation-rollback", onRollback);
  });

  return { pendingCount, syncNow: processMutationQueue, rollback };
}

Integration Checklist

  • Create swoff/mutation-queue.js with queue schema + queueMutation() + processMutationQueue()
  • Create swoff/reconcile.js with reconcileRecord() for your entity types
  • Create swoff/cache.js with invalidateByTag() for SW cache invalidation
  • Tag your read requests via fetchWithCache(url, { tags })
  • Pass tags, storeName, and tempId when calling queueMutation() for offline writes
  • Pass previousData with a snapshot of data before PUT/DELETE for rollback safety
  • Add $synced: false to locally-created records
  • Call reconcileRecord() + invalidateByTag() after online mutations too
  • Add mutation-queue-changed listener to show pending count in UI
  • Add mutation-rollback listener to handle sync failures in the UI
  • If using cookie auth, include CSRF tokens in queued mutation headers (see CSRF Considerations)
  • (Optional) Implement useMutationQueue for your framework
  • Your backend receives the synced requests — you build that part

CSRF Considerations

When the mutation queue replays requests on reconnect, it uses raw fetch(). If your backend uses cookie/session auth:

  1. Cookies are not available in the SW context — background sync (the SW sync event) cannot send cookies. Only use client-side sync (window.addEventListener("online", processMutationQueue)) with cookie auth.
  2. CSRF tokens must be stored in the queue payload — if your backend requires CSRF tokens for unsafe methods (POST/PUT/DELETE), include the token in the queued mutation's headers field when calling queueMutation():
await queueMutation({
  method: "POST",
  url: "/api/todos",
  body: { title: "Grocery" },
  headers: { "X-CSRF-Token": csrfToken }, // Include CSRF token in queued mutation
  tags: ["todos"],
  storeName: "todos",
  tempId,
});
  1. Idempotency keys — to prevent duplicate processing when a mutation syncs but the response is lost, include an idempotency key in the mutation body. Your backend should reject duplicate keys.

Status

  • Queue schema with tags, storeName, tempId
  • queueMutation() with full metadata (including previousData for rollback)
  • processMutationQueue() with fetch, reconcile, invalidate, rollback
  • ID reconciliation via reconcileRecord() (any backend ID type)
  • Cache invalidation via invalidateByTag()
  • $synced tracking on local records
  • Online event listener
  • Rollback on terminal failure — optimistic data reverted, event dispatched
  • Window events for UI reactivity
  • Framework adapters (React hook, Vue composable)
  • Background Sync integration
  • Framework-specific components (pending indicator) — see Framework Guides

Next Steps

On this page