Skip to content

Local-First Apps ​

A local-first application treats on-device data as the user's working copy. Reads and edits continue without waiting for the network; synchronization reconciles that working copy with a remote authority when communication succeeds.

Routier supplies local storage, reactive queries, tracked mutations, and HTTP replication building blocks. It does not make an application local-first merely by installing a plugin: you must choose what is cached, make pending work durable, define the server contract, and design conflicts and recovery.

Quick navigation ​

What “offline” actually guarantees ​

A warm local cache can answer without the server. A device that has never downloaded the requested data cannot invent it.

SituationExpected behavior with HttpSwrDbPlugin
Matching cached data is freshReturn local data without a remote request
Matching cached data is staleReturn local data, then revalidate in the background
No matching cached data existsFetch remotely and populate the cache; offline, this read throws unless onError calls useCached()
A local write has not reached the serverKeep it in the local store and unsynced queue
Connectivity returnsWith autoSync on, an immediate queue flush

The browser's online event is only a retry trigger; it is not proof that your API is reachable. Successful requests and queue state are the useful signals.

Choose an architecture ​

GoalCompositionTradeoff
Local-only browser appDexiePluginDurable and simple; no remote synchronization
Cache-first HTTP synchronizationHttpSwrDbPlugin(DexiePlugin)Durable local reads; IndexedDB is the query path
Cache-first HTTP plus memory-speed readsHttpSwrDbPlugin(OptimisticUpdatesDbPlugin(DexiePlugin))Fastest reads; extra memory and initial hydration
Direct server reads and writesHttpDbPluginNot local-first; network is on the critical path
CouchDB-compatible replicationPouchDbPlugin with PouchDB replicationDifferent synchronization protocol and conflict model

Start with HttpSwrDbPlugin(DexiePlugin) unless profiling shows that IndexedDB query latency requires the memory layer. OptimisticUpdatesDbPlugin mirrors each hydrated collection in memory, so dataset size matters.

text
Application collections and live queries
                 │
                 ▼
HttpSwrDbPlugin ───────────────────────► HTTP API
  │  cache-first reads                    GET revalidation
  │  locally accepted writes              POST synchronization
  │
  ▼
OptimisticUpdatesDbPlugin (optional)
  ├── MemoryPlugin       fast read model
  └── DexiePlugin        durable local cache

Separate DexiePlugin
  └── _routier_unsynced  durable delivery obligations

Install the replication and local-storage packages:

bash
npm install @routier/replication-plugin @routier/dexie-plugin

A production-oriented setup looks like this:

ts
import { DataStore } from "@routier/datastore";
import { DexiePlugin } from "@routier/dexie-plugin";
import {
  createRetry,
  HttpSwrDbPlugin,
  OptimisticUpdatesDbPlugin,
} from "@routier/replication-plugin";
import { productSchema } from "./schemas";

export class AppDataStore extends DataStore {
  readonly sync: HttpSwrDbPlugin;

  constructor(
    storageNamespace: string, // A stable, non-secret account/tenant namespace.
    getAccessToken: () => Promise<string>,
    refreshToken: () => Promise<void>,
  ) {
    const cache = new DexiePlugin(`${storageNamespace}_cache`);
    const queue = new DexiePlugin(`${storageNamespace}_unsynced`);

    // Optional: hydrate a memory read model from the durable IndexedDB cache.
    const memoryFirstCache = new OptimisticUpdatesDbPlugin(cache);

    const backoff = createRetry({ maxAttempts: 5 });

    const sync = new HttpSwrDbPlugin(memoryFirstCache, {
      databaseName: `${storageNamespace}:production-api`,
      getUrl: collection => `https://api.example.com/data/${collection}`,
      getHeaders: async () => ({
        Authorization: `Bearer ${await getAccessToken()}`,
      }),
      maxAgeMs: 30_000,
      unsyncedQueueStore: queue,
      autoSync: true,

      // Adapt GET responses such as { data: [...] }.
      translateRemoteResponse(_schema, body) {
        return (body as { data?: unknown[] }).data ?? [];
      },

      // Reconcile server-generated ids, versions, and timestamps after POST.
      translatePersistResponse(_schema, body) {
        return (body as { data?: unknown[] }).data ?? null;
      },

      // Decide what each failing request does. getHeaders runs again for every retry.
      onError: async (error) => {
        if (error.kind === "http" && error.status === 401 && error.attempt === 1) {
          await refreshToken();
          return error.retry();
        }

        if (error.kind === "network" && error.operation === "read") {
          return error.useCached();
        }

        return backoff(error);
      },

      // Report what happened, for status UI and logging.
      onEvent(event) {
        if (event.type === "read" && !event.ok) {
          console.warn("Showing cached data; refresh failed", event.collectionName, event.error);
        }

        if (event.type === "changes-rejected") {
          console.error(
            event.conflict ? "Server rejected a conflicting local change" : "Changes need user or developer action",
            event.changes,
            event.error,
          );
        }
      },
    });

    super(sync);
    this.sync = sync;
  }

  products = this.collection(productSchema).proxy().create();
}

Keep the HttpSwrDbPlugin instance so the application can inspect and control synchronization. Keep the main cache and unsynced queue in separate Dexie databases. Use durable queue storage in production; a MemoryPlugin queue loses pending writes on reload.

Plugin order matters ​

The HTTP SWR plugin must be outermost:

ts
new HttpSwrDbPlugin(
  new OptimisticUpdatesDbPlugin(new DexiePlugin("app_cache")),
  options,
);

This lets remote revalidation update the optimistic layer and notify live queries. Reversing the wrappers can leave the in-memory read model stale. See the full composition guide.

Read lifecycle ​

For each serialized query, HttpSwrDbPlugin tracks cache freshness independently.

  1. Read local first. The wrapped plugin executes the Routier query. With the optimistic wrapper, the first query hydrates that collection from the durable cache into memory.
  2. Handle a cache miss. The HTTP request is blocking because there is no local result to display. A successful response is translated, persisted locally, and returned. A failed one goes to onError; the read throws unless it calls retry() and that succeeds, or calls useCached() to answer from the local store.
  3. Return a fresh hit. Before maxAgeMs expires, the local result is returned without revalidation.
  4. Return a stale hit. The stale result is returned immediately. Revalidation runs in the background, sending the last response's ETag as If-None-Match. A 304 Not Modified keeps the local rows and marks the query fresh, and is reported as a read event with status: 304; turn it off with conditionalRevalidation: false. Serve these reads with Cache-Control: no-store: the plugin keeps its own cache, and a browser's HTTP cache would otherwise send its own If-None-Match and answer a 304 from its copy, even with conditionalRevalidation: false.
  5. Apply the remote diff. Server rows are compared with the cache. When the schema declares an .etag(), a newer server row replaces the local one, the same etag is skipped, and an older one is ignored. Adds, updates, and removals are persisted locally; subscribed queries are notified.
  6. Keep stale data on failure. A failed background revalidation does not replace a successful cached result. It goes to onError, and is reported as a read event with ok: false.

Concurrent reads for the same URL are coalesced, and concurrent revalidations for the same cache key share work.

Joins through HTTP SWR

HttpSwrDbPlugin intentionally rejects joins because a local joined tuple cannot be coherently reconciled with remote entity rows. Use HttpDbPlugin for a server-backed joined read, query the collections separately, or maintain a denormalized local read model.

Write and sync lifecycle ​

Calling saveChangesAsync() with HttpSwrDbPlugin means accepted locally and recorded for delivery, not necessarily accepted by the server.

  1. Routier sends tracked adds, updates, and removes to the SWR plugin.
  2. The change is applied to the local store, updating local/live reads.
  3. Every operation is written to _routier_unsynced. This queue write is awaited before success is reported.
  4. By default, postOnPersist: true also sends the queue shortly after the save. Rapid saves share a short batching window and go out as one request.
  5. A successful response removes acknowledged queue entries. translatePersistResponse can replace optimistic entities with canonical server versions.
  6. A failed request goes to onError, which can retry(), reject() or defer() it. With no onError the entries stay queued. With autoSync on, the queue is flushed on a backing-off timer and immediately when the platform emits online.

With postOnPersist: false, saves remain local until the next flush. Without autoSync, that is the next syncNow() call.

Delivery is at least once ​

Queued operations have IDs in meta.opIds. A request can reach the server even if its response never reaches the client, so the client may replay it. The server should record operation IDs and make replays idempotent. Do not rely on “the browser only sent this once.”

Server contract ​

HttpDbPlugin and HttpSwrDbPlugin use one URL per collection.

Reads ​

http
GET /data/products?filter=...&sort=...&skip=...&take=...

getUrl(collectionName) chooses the endpoint. The server must enforce authentication and tenant scope regardless of incoming query parameters. Use ignoreQueryForCollections when the endpoint always returns its complete authorized set and should not receive serialized query options.

Adapt wrapped API responses with translateRemoteResponse:

ts
translateRemoteResponse(_schema, body) {
  return (body as { data?: unknown[] }).data ?? [];
}

Writes ​

The default POST body is:

json
{
  "adds": [{ "id": "p1", "name": "New" }],
  "updates": [{ "id": "p2", "name": "Renamed" }],
  "removes": [{ "id": "p3", "name": "Old" }],
  "meta": {
    "opIds": {
      "adds": ["operation-1"],
      "updates": ["operation-2"],
      "removes": ["operation-3"]
    }
  }
}

Updates contain key fields plus changed fields. When the schema declares an .etag(), an update also carries the etag the edit was based on, so the server can refuse a stale edit with 409; it should apply the changed fields onto its stored row. Operation-ID arrays are parallel to the corresponding operation arrays. A server may ignore meta, but replay safety is stronger when it deduplicates these IDs.

Return canonical saved entities when the server assigns IDs, versions, normalized values, or timestamps, then adapt them with translatePersistResponse.

Status behavior ​

Every failure goes to onError, which decides. Without one, failed writes stay queued. createRetry() is a ready-made policy:

ResponseWith createRetry()
SuccessRemove confirmed operations from the queue
401 or 403Not retried; the write stays queued. Refresh credentials in your own onError and call retry()
408, 429, network error, or 5xxRetry with backoff; once attempts run out, keep queued
409Reject: the change is dead-lettered, reported as changes-rejected with conflict: true, and the server copy wins on the next read
Other permanent 4xxReject rather than retry forever

For batched validation, the server can return rejectedOpIds so valid operations continue without one request per entity. See Structured Permanent Rejections.

Conflicts and rejected changes ​

Routier protects an entity with pending local work from being overwritten by revalidation. That protection ends when the server confirms the operation or permanently rejects it.

Routier does not provide an automatic CRDT or business-level merge policy. Choose one explicitly:

  • Server wins: return 409; notify the user, dead-letter the local operation, and revalidate.
  • Version check: declare the version with .etag(). Each update carries the version it was based on; reject an older one with 409.
  • Field merge: let the server merge non-overlapping fields and return the canonical entity.
  • Manual resolution: retain enough context to show local and remote values and let the user decide.

Do not silently discard dead letters. Surface the changes-rejected event and provide a correction, dismissal, export, or retry workflow. retryDeadLetters() is appropriate only after the reason for rejection has been fixed.

Show sync state ​

“Offline” and “synced” are different states. A reachable network can still have rejected work, and an offline app can have no pending work.

ts
const pending = await store.sync.pendingCount();
const outcome = await store.sync.syncNow();
const failures = await store.sync.deadLetters();

console.log({ pending, outcome, failures });

// After the user fixes the underlying problem:
const retried = await store.sync.retryDeadLetters();

Use the synced event (sent, failed, rejected) to update a last-sync indicator after automatic or manual flushes. A useful UI distinguishes:

  • saved locally
  • waiting to sync
  • synchronizing
  • synced
  • needs attention

Do not block normal local editing merely because a background revalidation failed. Do make permanent rejection visible.

Data modeling for local-first ​

Prefer client-generated stable keys ​

A locally created row needs an identity before contacting the server. UUID/string identities avoid temporary-key replacement. If the server assigns IDs, implement translatePersistResponse and test references to the replaced entity.

Carry server versions explicitly ​

Add a version, revision, or server-updated timestamp if the API performs optimistic concurrency checks. Default update payloads contain keys plus changed fields; declare the version with .etag() and every update also carries the version it was based on. For any other field that must accompany every update, override formatRequestBody(...). Treat client clocks as untrusted for conflict ordering unless the product deliberately accepts that limitation.

Scope cached data ​

Collection scopes are useful for tenant/user filters, but the server must enforce the same boundary. Use distinct cache and queue names per account or tenant, and decide what happens to each database on sign-out.

Plan schema evolution ​

A local cache can survive many application releases. Test old stored rows against new defaults, mappings, transforms, and backend migrations. Do not assume clearing all local data is safe while unsynced work exists. See Migrating Stored Data for backfill and reshape recipes.

Cache only what can be local ​

Sensitive data in IndexedDB is accessible to the browser profile and any script running with the application's privileges. Minimize cached fields and use property encryption when its threat model fits. Encryption does not replace XSS prevention, authorization, or secure key handling.

Security and account isolation ​

  • Fetch authorization headers for every request; Routier reevaluates getHeaders on retries.
  • On a 401 in onError, call retry() only after credentials were actually refreshed.
  • Enforce authorization and tenant filters on the server. Client scopes are not security controls.
  • Namespace local cache and queue databases by environment and account.
  • Never expose one user's warm cache while another user is signing in.
  • On logout, decide whether to flush, retain, export, or discard pending work before deleting local storage.
  • Set databaseName when multiple HTTP backends use the same schemas so subscription notifications do not cross backend boundaries.

Testing offline behavior ​

A local-first release should exercise state transitions, not only happy-path CRUD:

  1. Cold start offline: verify the app clearly handles data that was never cached.
  2. Warm start offline: reload and confirm cached reads still work.
  3. Offline mutation: add, update, and remove; verify live queries update and pendingCount() increases.
  4. Reload before sync: confirm a durable queue survives a full page reload.
  5. Reconnect: dispatch/use a real connectivity restoration and verify the queue drains.
  6. Lost response: let the server apply a POST but drop the response; verify replay is idempotent.
  7. Auth expiry: return 401, refresh credentials, return true, and verify exactly one retry.
  8. Conflict: return 409; verify the change dead-letters and the server version appears after revalidation.
  9. Validation rejection: reject one item in a batch and ensure valid operations still synchronize.
  10. Concurrent local write and revalidation: verify pending local work is not clobbered.
  11. Multi-tab update: verify subscribed views receive the expected storage notification.
  12. Upgrade with pending work: open a previous cache/queue using the new application version.

Use throttling and request failure injection rather than relying only on navigator.onLine. Enable debug logging while validating queue and revalidation behavior.

Production checklist ​

  • [ ] Durable local store selected for every offline-required collection
  • [ ] Durable, separate unsynced queue configured
  • [ ] Client-generated or correctly reconciled identities
  • [ ] Server deduplicates meta.opIds
  • [ ] Authentication refresh returns the correct retry signal
  • [ ] Tenant authorization enforced remotely and cache names isolated locally
  • [ ] Conflict and dead-letter UX defined
  • [ ] Pending/synced/failed states visible where users need them
  • [ ] Cold-cache offline behavior designed
  • [ ] Cache and queue schema upgrades tested
  • [ ] Sensitive cached fields reviewed
  • [ ] Revalidation, synchronization, and dead-letter callbacks monitored
  • [ ] Logout behavior for pending work decided
  • [ ] Dataset fits IndexedDB and, if enabled, the memory mirror

Released under the MIT License.