HttpSwrDbPlugin with Optimistic Replication

Combine HttpSwrDbPlugin (stale-while-revalidate over HTTP) with OptimisticUpdatesDbPlugin for a local-first data flow: durable local cache, in-memory reads, background revalidation, and optimistic writes. This is the pattern to reach for when you want SWR semantics and optimistic updates without making your UI wait on the network.

For the package overview, see Replication Plugin. For a full map of plugin combinations and when to use each, see Plugin Compositions.

Quick Navigation

Architecture

┌─────────────────────────────────────────────────────────────────────────────────┐
│                           Your Application                                        │
└─────────────────────────────────────────┬───────────────────────────────────────┘
                                          │
                                          ▼
┌─────────────────────────────────────────────────────────────────────────────────┐
│                           HttpSwrDbPlugin                                         │
│  • Queries: cache first, revalidate in background when stale                      │
│  • Writes: persist to store, POST to server, retry on failure                    │
└─────────────────────────────────────────┬───────────────────────────────────────┘
                                          │
                                          ▼
┌─────────────────────────────────────────────────────────────────────────────────┐
│                      OptimisticUpdatesDbPlugin                                  │
│  • Reads: from memory (instant)                                                   │
│  • Writes: to Dexie (persistent), then replicate to memory                       │
└─────────────────────────────────────────┬───────────────────────────────────────┘
                                          │
                                          ▼
┌─────────────────────────────────────────────────────────────────────────────────┐
│                           DexiePlugin (IndexedDB)                                 │
└─────────────────────────────────────────────────────────────────────────────────┘

Reads flow: Memory → instant. Writes flow: Memory → Dexie → HTTP POST. Server updates revalidate the cache and propagate to memory.

This gives you a practical local-first stack:

  • SWR: cached reads return immediately and stale data is refreshed in the background.
  • Optimistic updates: writes land locally first so the UI updates without waiting for the server.
  • Offline resilience: the app keeps working from local state and retries sync when connectivity returns.

Critical: Plugin Order

You must pass the OptimisticUpdatesDbPlugin to HttpSwrDbPlugin, not the other way around.

>
1
2
3
// ✅ Correct: HttpSwrDbPlugin wraps OptimisticUpdatesDbPlugin
const optimisticPlugin = new OptimisticUpdatesDbPlugin(localDb);
const swrPlugin = new HttpSwrDbPlugin(optimisticPlugin, options);
>
1
2
3
// ❌ Wrong: OptimisticUpdatesDbPlugin wrapping HttpSwrDbPlugin
const swrPlugin = new HttpSwrDbPlugin(localDb, options);
const optimisticPlugin = new OptimisticUpdatesDbPlugin(swrPlugin); // Don't do this!

If you reverse the order, server updates will require a double refresh to appear in the UI. The revalidate flow must hit the optimistic plugin’s memory store so subscriptions are notified correctly.

Setup

Install the required packages:

npm install @routier/core @routier/datastore @routier/replication-plugin @routier/dexie-plugin

Complete Example

>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { DataStore } from "@routier/datastore";
import { OptimisticUpdatesDbPlugin, HttpSwrDbPlugin } from "@routier/replication-plugin";
import { DexiePlugin } from "@routier/dexie-plugin";

const baseUrl = "https://api.example.com";

// Separate Dexie databases: SWR cache + unsynced queue (required for Dexie)
const swrStoreDb = new DexiePlugin("myapp_swr");
const unsyncedQueueDb = new DexiePlugin("myapp_unsynced");

// Optimistic plugin wraps Dexie: reads from memory, writes to Dexie
const optimisticPlugin = new OptimisticUpdatesDbPlugin(swrStoreDb);

const createPlugin = (getAccessToken: () => Promise<string>) =>
  new HttpSwrDbPlugin(optimisticPlugin, {
    getUrl: (collectionName) => `${baseUrl}/${collectionName}`,
    getHeaders: async () => ({ Authorization: `Bearer ${await getAccessToken()}` }),
    maxAgeMs: 30_000,
    unsyncedQueueStore: unsyncedQueueDb,
    translateRemoteResponse(_schema, data) {
      const response = data as { data: unknown[] };
      return response.data ?? [];
    },
  });

export class AppDataStore extends DataStore {
  constructor(getAccessToken: () => Promise<string>) {
    super(createPlugin(getAccessToken));
  }
}

Production Shape

A production setup often adds three pieces on top of the minimal example:

  • auth headers that can refresh on 401 or 403
  • tenant or organization headers for server-side scoping
  • a custom formatRequestBody(...) to match an existing HTTP contract

The shape below mirrors a real local-first datastore setup:

>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import { DexiePlugin } from "@routier/dexie-plugin";
import { HttpSwrDbPlugin, OptimisticUpdatesDbPlugin } from "@routier/replication-plugin";
import type { SchemaPersistChanges } from "@routier/core/collections";
import type { CompiledSchema } from "@routier/core/schema";
import type { UnknownRecord } from "@routier/core/utilities";

type GetAccessToken = (opts?: { forceRefresh?: boolean }) => Promise<string>;

class AppSwrPlugin extends HttpSwrDbPlugin {
  protected override formatRequestBody(changes: SchemaPersistChanges<UnknownRecord>, _: CompiledSchema<UnknownRecord>) {
    const { adds, updates, removes } = changes;
    return JSON.stringify({ adds, updates, removes });
  }
}

const cacheDb = new DexiePlugin("__app_cache__");
const unsyncedQueueDb = new DexiePlugin("__app_unsynced__");
const optimisticDb = new OptimisticUpdatesDbPlugin(cacheDb);

export const createPlugin = (getAccessToken: GetAccessToken) => {
  let forceRefreshNext = false;

  return new AppSwrPlugin(optimisticDb, {
    getUrl: (collectionName) => `${import.meta.env.VITE_API_URL}/data/${collectionName}`,
    getHeaders: async () => {
      const token = await getAccessToken({ forceRefresh: forceRefreshNext });
      forceRefreshNext = false;

      const activeOrgId =
        typeof window !== "undefined"
          ? window.localStorage.getItem("currentOrganizationId")
          : null;

      return {
        Authorization: `Bearer ${token}`,
        ...(activeOrgId ? { "x-org-id": activeOrgId } : {}),
      };
    },
    ignoreQueryForCollections: ["users"],
    maxAgeMs: 30_000,
    onAuthError: () => {
      forceRefreshNext = true;
    },
    unsyncedQueueStore: unsyncedQueueDb,
    translateRemoteResponse(_schema, data) {
      return (data as { data?: unknown[] }).data ?? [];
    },
  });
};

This works especially well when your DataStore also uses scoped collections for user-specific data. The server remains the source of truth, but the client keeps a fast local working set and synchronizes in the background.

Key points

  1. Optimistic plugin wraps Dexie: new OptimisticUpdatesDbPlugin(swrStoreDb) — all reads come from memory.
  2. HttpSwrDbPlugin receives the optimistic plugin: new HttpSwrDbPlugin(optimisticPlugin, options) — SWR manages cache freshness and HTTP sync.
  3. Separate Dexie for unsynced queue: Use new DexiePlugin('myapp_unsynced') with a different database name. Sharing the same Dexie instance with the SWR store causes Table _routier_unsynced does not exist (see Debug Logging for details).
  4. translateRemoteResponse: Adapt your API’s response shape to { data: unknown[] } if it differs.

Customizing the Request Body

Override formatRequestBody when you need to strip or transform fields before sending to the server (e.g. client-only properties):

>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import type { SchemaPersistChanges } from "@routier/core/collections";
import type { CompiledSchema } from "@routier/core/schema";
import type { UnknownRecord } from "@routier/core/utilities";

class SwrPluginWithCustomFormat extends HttpSwrDbPlugin {
  protected override formatRequestBody(changes: SchemaPersistChanges<UnknownRecord>, schema: CompiledSchema<UnknownRecord>) {
    const { adds, updates, removes } = changes;
    const keysToStrip = new Set<string>(); // e.g. schema properties tagged client-only
    const strip = (r: UnknownRecord) => {
      const out = { ...r };
      keysToStrip.forEach((k) => delete out[k]);
      return out;
    };
    return JSON.stringify({
      adds: adds.map((r) => strip(r as UnknownRecord)),
      updates: updates.map((u) => strip(u.entity as UnknownRecord)),
      removes: removes.map((r) => strip(r as UnknownRecord)),
    });
  }
}

Unsynced Queue Storage

The unsynced queue tracks entities written to the local store but not yet confirmed by the server.

Option Behavior
unsyncedQueueStore: new DexiePlugin('myapp_unsynced') Persistent across refresh. Must use a separate Dexie database (different name than the SWR store).
unsyncedQueueStore: new MemoryPlugin('unsynced') In-memory only; queue cleared on page refresh. Use when persistence is not needed.

This site uses Just the Docs, a documentation theme for Jekyll.