Swoff Swoff

Data Layer

The unified read/write data layer — query, mutate, reconcile, and invalidate.

Data Layer

Read caching, mutation queuing, ID reconciliation, and cache invalidation are not separate concerns. They are different faces of the same problem: keeping local data consistent with your backend.

The Loop

Query (read from cache or server)

Mutate (write to server, queue if offline)

Reconcile (update local data with server response)

Invalidate (flush stale cached queries)

Query again (fresh data)

Every mutation is an implicit invalidation of related reads.

Three Systems

Query — Read Data

Fetch from the server, cache the response by URL + tags, serve from cache when offline. When data changes, invalidation marks cached entries as stale so the next read fetches fresh. Use stale-while-revalidate to serve cached data instantly and refresh in the background.

fetchWithCache(url, { tags: ["todos"] })

Check Cache API (by URL)

Miss → fetch from server → cache response + tags

Hit → return cached

Mutate — Write Data

Queue write operations when offline, sync when back online. Capture the server response and use it to reconcile local state. If a mutation exhausts its retries, the optimistic data is automatically rolled back.

queueMutation({ method: "POST", url: "/api/todos", body: {...} })

IndexedDB queue → process when online

Fetch to server → capture response

reconcileRecord() + invalidateByTag()

Reconcile + Invalidate — Keep Data Fresh

After a mutation syncs, two things happen:

Reconcile: Replace temporary local IDs with server-assigned IDs, merge server fields, update references in related records.

Invalidate: Delete all cached responses tagged with related tags (e.g., after POST /api/todos, invalidate all cached queries tagged todos). The next read fetches fresh data. The SW also broadcasts TAG_INVALIDATED to all open tabs so they refetch automatically (see Cross-Tab Sync).

reconcileRecord("todos", tempId, serverData)
    → local record updated with server data
    → $synced: true

invalidateByTag("todos")
    → Cache API entries cleared
    → window event: "cache-invalidated"
    → framework adapters react → refetch

Window Events

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

Cross-Tab Flow

When a mutation syncs in one tab, all other tabs with cached data for the same tags refetch automatically:

Tab A: invalidateByTag("todos")

SW clears runtime cache entries tagged "todos"

SW broadcasts TAG_INVALIDATED { tag: "todos" } → Tab A, Tab B, Tab C

Tab A: already dispatched cache-invalidated → refetches
Tab B: initCrossTabSync receives TAG_INVALIDATED → dispatches cache-invalidated → refetches
Tab C: initCrossTabSync receives TAG_INVALIDATED → dispatches cache-invalidated → refetches

Setup requires calling initCrossTabSync() once after SW registration (see API Integration).

Data Flow

Online

  1. Component calls fetchWithCache("/api/todos", { tags: ["todos"] })
  2. SW checks Cache API → miss → fetches from server
  3. Response cached with tag todos
  4. User creates a todo → queueMutation() → fetch succeeds → reconcileRecord() updates local data → invalidateByTag("todos") clears cached /api/todos → event dispatched → component refetches

Offline

  1. Component reads from IndexedDB (app data) or Cache API (SW-cached read)
  2. User creates a todo → queueMutation() stores in IndexedDB queue → $synced: false
  3. Component shows optimistic state with pending count
  4. Back online → processMutationQueue() → fetch succeeds → reconcileRecord()invalidateByTag() → component refetches

Prerequisites

  1. Offline-First Architecture — understand the client runtime
  2. SW Template — foundation of the caching system
  3. API Integration — fetch wrapper with cache strategy

Next Steps

On this page