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 dataPrerequisites
- IndexedDB Patterns — understand object stores and CRUD
- API Integration —
fetchWithCachewith cache tags for invalidation - SW Template — tag-indexed caching +
INVALIDATE_TAGmessage handler - Data Layer — how query, mutate, reconcile, and invalidate connect
Implementation
Queue Schema
Each mutation in the queue stores:
| Field | Type | Purpose |
|---|---|---|
id | string | Unique queue item ID (crypto.randomUUID()) |
method | string | HTTP method (POST, PUT, DELETE) |
url | string | Backend API endpoint |
body | object | Payload sent to server |
headers | object | Extra headers (e.g., auth tokens) |
previousData | object|null | Snapshot of data before optimistic update (for rollback on failure) |
timestamp | number | When queued (for FIFO ordering) |
retryCount | number | How many times sync has been attempted |
tags | string[] | Cache tags to invalidate on success |
storeName | string | Local IndexedDB store for reconciliation |
tempId | string | Local record ID to reconcile with server response |
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 dataSolution: reconcileRecord()
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
idwith the server-assigned ID - Sets
$synced: trueand$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-refetchesThe invalidateByTag Helper
/**
* 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:
| Field | Type | Meaning |
|---|---|---|
$synced | boolean | false = optimistic local-only, true = confirmed by server |
$syncedAt | number (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");
}
});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
| Event | Detail | When |
|---|---|---|
mutation-queue-changed | none | Queue 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
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
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.jswith queue schema +queueMutation()+processMutationQueue() - Create
swoff/reconcile.jswithreconcileRecord()for your entity types - Create
swoff/cache.jswithinvalidateByTag()for SW cache invalidation - Tag your read requests via
fetchWithCache(url, { tags }) - Pass
tags,storeName, andtempIdwhen callingqueueMutation()for offline writes - Pass
previousDatawith a snapshot of data before PUT/DELETE for rollback safety - Add
$synced: falseto locally-created records - Call
reconcileRecord()+invalidateByTag()after online mutations too - Add
mutation-queue-changedlistener to show pending count in UI - Add
mutation-rollbacklistener to handle sync failures in the UI - If using cookie auth, include CSRF tokens in queued mutation
headers(see CSRF Considerations) - (Optional) Implement
useMutationQueuefor 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:
- Cookies are not available in the SW context — background sync (the SW
syncevent) cannot send cookies. Only use client-side sync (window.addEventListener("online", processMutationQueue)) with cookie auth. - 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
headersfield when callingqueueMutation():
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,
});- 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 (includingpreviousDatafor rollback) -
processMutationQueue()with fetch, reconcile, invalidate, rollback - ID reconciliation via
reconcileRecord()(any backend ID type) - Cache invalidation via
invalidateByTag() -
$syncedtracking 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
- Background Sync — sync after tab close
- Conflict Resolution — handle concurrent edits
Swoff