Skip to main content

Overview

PassAgent works offline as a progressive web app (PWA). When you lose connectivity, your vault remains accessible through a multi-layer caching architecture: a Service Worker intercepts network requests and serves cached responses, IndexedDB stores vault entries locally, and a sync manager reconciles changes when you come back online.

Caching strategies

The Service Worker (public/sw.js) routes requests through four caching strategies based on request type:
Request typeStrategyCache nameMax entries
Sync API (/api/passwords/sync)Network-firstpassagent-sync-v110
Static assets (/_next/static/)Cache-firstpassagent-app-v1200
Navigation (HTML pages)Network-first with offline fallbackpassagent-app-v1200
Images, fonts, iconsStale-while-revalidatepassagent-app-v1200
Fetches from the network and caches successful responses. If the network fails, returns the cached response. If no cached data exists, returns a 503 JSON error with "error": "offline".

App shell pre-caching

On Service Worker install, the following URLs are eagerly cached so the app shell loads on a full offline refresh:
  • /dashboard
  • /offline
Pre-caching uses Promise.allSettled so the install event succeeds even if individual URLs fail to fetch.

Cache lifecycle

1

Install

Pre-caches the app shell URLs into passagent-app-v1. Calls skipWaiting() to activate immediately.
2

Activate

Deletes any old caches with the passagent- prefix that are not in the current set (passagent-app-v1, passagent-sync-v1). Calls clients.claim() to take control of all open tabs.
3

Runtime

Intercepts same-origin GET requests and routes them through the appropriate caching strategy. Non-GET requests and cross-origin requests pass through unmodified.
4

Eviction

After each cache write, trimCache() checks whether the cache exceeds maxEntries. If so, it evicts the oldest entries (Cache API returns keys in insertion order).

IndexedDB storage

The client-side sync system uses an IndexedDB database for persistent local storage:
DatabaseVersionPurpose
passagent-sync1Vault entries, pending mutations, sync metadata
Object stores:
StoreContents
entriesCached vault entries keyed by (userId, id)
pendingQueued offline mutations (create, update, delete)
metadataSync cursors and timestamps per user

Sync manager

The SyncManager singleton coordinates data flow between IndexedDB and the server. It runs in the main thread and exposes event listeners for status changes, entry updates, and conflict notifications.

Delta sync

1

Pull changes

Sends GET /api/passwords/sync?since=<cursor> to fetch entries modified since the last sync. On the first sync (no cursor), fetches all entries.
2

Merge

For delta responses, each change is applied individually: deleted entries are removed from IndexedDB, new and updated entries are upserted. For full sync responses (fullSyncRequired: true), the local cache is cleared and replaced entirely.
3

Update cursor

Stores the serverTimestamp from the response as the cursor for the next delta sync.
4

Notify listeners

Emits the updated entry set to all registered onEntriesChange listeners.

Pushing mutations

When you create, update, or delete a vault entry while offline, the mutation is queued in the pending store with these fields:
FieldDescription
actioncreate, update, or delete
entryIdThe entry ID
dataThe encrypted entry payload (for create/update)
clientUpdatedAtTimestamp when the mutation was made
userIdThe authenticated user ID
When connectivity returns, pushPendingMutations() sends all queued mutations to POST /api/passwords/sync in a single batch.

Conflict resolution

The sync system uses a server-wins strategy:
  1. Each mutation includes a clientUpdatedAt timestamp.
  2. The server compares it against the entry’s updated_at.
  3. If server.updated_at > clientUpdatedAt, the server version takes precedence and the result status is conflict_server_wins.
  4. The local IndexedDB cache is updated with the server’s version of the entry.
  5. The onConflict listener fires with the affected entry ID so the UI can notify you.
  6. Successfully synced mutations (both applied and conflict_server_wins) are removed from the pending queue.

Background sync

The Service Worker registers for the Background Sync API with the tag vault-sync. When connectivity is restored, the browser wakes the Service Worker, which runs pushPendingMutations():
1

Open IndexedDB

Opens the passagent-sync database and reads all entries from the pending store.
2

Fetch CSRF token

Calls GET /api/auth/csrf-token to obtain a fresh CSRF token. The sync POST requires CSRF validation.
3

Push mutations

Sends POST /api/passwords/sync with the x-csrf-token header and a JSON body containing the queued mutations.
4

Clean up

Removes successfully synced mutations from IndexedDB. Failed mutations remain in the queue for the next sync attempt.

Periodic sync

The sync manager runs a periodic sync loop while the app is active:
BehaviorDetail
Interval30 seconds
Skipped whenTab is hidden (document.visibilityState === "hidden") or offline (!navigator.onLine)
Triggered ononline event (browser regains connectivity)
Triggered onvisibilitychange event (tab becomes visible)
SequencePush pending mutations first, then pull delta changes
Calling clearAll() on the sync manager stops the periodic timer, clears all IndexedDB sync data, and resets the status to idle. This is called on logout to remove all local vault data.

Cache management messages

The app can send messages to the Service Worker to manage caches:
Message typeEffect
CLEAR_SYNC_CACHEDeletes the passagent-sync-v1 cache entirely
This is used during logout to ensure no cached sync responses persist after the session ends.