Prisma IDB FaviconPrisma IDB

Sync Worker Methods

Client-side API for controlling bidirectional synchronization

The SyncWorker provides a client-side API for controlling bidirectional synchronization between IndexedDB and your server. This guide covers all available methods and patterns for manual sync control.

Creating a Sync Worker

The createSyncWorker() method on your Prisma IDB client initializes a new sync worker:

const syncWorker = idbClient.createSyncWorker({
  push: {
    handler: async (events) => {
      const res = await fetch("/api/sync/push", {
        method: "POST",
        body: JSON.stringify({ events }),
      });
      return res.json();
    },
    batchSize: 10, // Optional: events per batch
  },
  pull: {
    handler: async (cursor) => {
      const res = await fetch("/api/sync/pull", {
        method: "POST",
        body: JSON.stringify({ cursor }),
      });
      return res.json();
    },
    getCursor: () => localStorage.getItem("sync-cursor"),
    setCursor: (cursor) => localStorage.setItem("sync-cursor", cursor),
  },
  schedule: {
    intervalMs: 5000, // Default: 5 seconds between syncs
    backoffMs: 30000, // Default: 30 seconds, exponential: backoffMs * 2^(tries-1)
  },
});

Core Methods

start()

Begins automatic synchronization on a schedule. Does nothing if already running.

syncWorker.start();

Behavior: syncWorker.start() triggers an immediate sync by calling syncOnce() first, then schedules further cycles at the configured intervalMs. This means the first sync runs right away rather than waiting one full interval. Use stop() to halt automatic syncing.


stop()

Stops automatic synchronization. In-flight sync operations are allowed to complete.

syncWorker.stop();

Behavior: Terminates the scheduling loop but does not interrupt an actively running sync cycle. Any queued outbox events remain in IndexedDB and can be synced later.


syncNow()

Execute a single sync cycle immediately, without requiring the worker to be started. Useful for manual sync triggers on user action.

await syncWorker.syncNow();

// Skip backoff logic and force immediate retry
await syncWorker.syncNow({ overrideBackoff: true });

Parameters:

  • options.overrideBackoff (optional): If true, ignores exponential backoff limits and retries immediately. Default: false.

Returns: Promise<void>

Behavior: Returns immediately if a sync cycle is already in progress. Safe to call even if start() has not been invoked.


forceSync()

Force an immediate sync cycle while the worker is actively running (scheduled mode).

await syncWorker.forceSync();

// Skip backoff and force retry
await syncWorker.forceSync({ overrideBackoff: true });

Parameters:

  • options.overrideBackoff (optional): If true, ignores exponential backoff limits. Default: false.

Returns: Promise<void>

Behavior: Returns immediately if the worker is stopped or a sync is already in progress. Requires start() to be called first (i.e., isLooping must be true).

Difference from syncNow(): forceSync() requires the worker to be started, while syncNow() executes independently.


status (Property)

Retrieve the current synchronization status.

const status = syncWorker.status;

console.log(status.status); // 'STOPPED' | 'IDLE' | 'PUSHING' | 'PULLING'
console.log(status.isLooping); // true if start() was called
console.log(status.lastSyncTime); // Date of last completed sync, or null
console.log(status.lastError); // Error from last failed sync, or null

Return Type:

interface SyncWorkerStatus {
  status: "STOPPED" | "IDLE" | "PUSHING" | "PULLING";
  isLooping: boolean;
  lastSyncTime: Date | null;
  lastError: Error | null;
}

Field Meanings:

  • status: Current phase of sync (STOPPED = not running, IDLE = running but not syncing, PUSHING = uploading local changes, PULLING = downloading remote changes)
  • isLooping: Whether automatic scheduling is active
  • lastSyncTime: Timestamp of the most recent completed sync cycle
  • lastError: The last error encountered during sync; null if the most recent cycle succeeded

Event Listeners

Subscribe to sync events using on():

syncWorker.on("statuschange", (event) => {
  console.log(event.detail);
  // { status: 'PUSHING', isLooping: true, lastSyncTime, lastError }
});

syncWorker.on("pushcompleted", (event) => {
  console.log("Push phase completed");
  console.log(event.detail);
  // { results: [{ id, appliedChangelogId, error }] }
});

syncWorker.on("pullcompleted", (event) => {
  console.log("Pull phase completed");
  console.log(event.detail);
  // { affectedModels: [...], changesApplied: number }
});

Event Types:

  • statuschange: Fired whenever status changes
  • pushcompleted: Fired after outbox events are sent to the server
  • pullcompleted: Fired after remote changes are applied locally

Configuration & Defaults

OptionDefaultDescription
schedule.intervalMs5000Milliseconds between automatic sync cycles (when start() is active)
schedule.backoffMs30000Initial backoff duration on sync failure; increases exponentially: backoffMs * 2^(tries - 1)
push.batchSize10Maximum outbox events sent per request
pull.getCursorundefinedFunction to retrieve last-synced cursor from persistent storage
pull.setCursorundefinedFunction to persist cursor after successful pull

Real-World Example

From pidb-kanban-example:

import type { SyncWorker } from "$lib/prisma-idb/client/idb-interface";

class TodosState {
  syncWorker: SyncWorker;

  constructor() {
    this.syncWorker = prisma.createSyncWorker({
      push: {
        handler: async (events) => {
          const response = await fetch("/api/sync/push", {
            method: "POST",
            body: JSON.stringify({ events }),
          });
          return response.json();
        },
      },
      pull: {
        handler: async (cursor) => {
          const response = await fetch("/api/sync/pull", {
            method: "POST",
            body: JSON.stringify({ cursor }),
          });
          return response.json();
        },
        getCursor: () => localStorage.getItem("sync-cursor"),
        setCursor: (cursor) => {
          if (cursor) {
            localStorage.setItem("sync-cursor", cursor);
          }
        },
      },
      schedule: { intervalMs: 10000, backoffMs: 30000 },
    });
  }

  syncWithServer() {
    this.syncWorker.start();
  }

  cleanup() {
    this.syncWorker?.stop?.();
  }
}

Common Patterns

Manual Sync on User Action

// "Sync now" button
button.onclick = async () => {
  await syncWorker.syncNow();
};

Show Sync Status in UI

// React to status changes
syncWorker.on("statuschange", (e) => {
  const { status, isLooping, lastError } = e.detail;

  if (status === "PUSHING") {
    ui.showSpinner("Uploading...");
  } else if (status === "PULLING") {
    ui.showSpinner("Downloading...");
  } else if (status === "IDLE" && isLooping) {
    ui.hideSpinner();
  }

  if (lastError) {
    ui.showError(`Sync failed: ${lastError.message}`);
  }
});

Conditional Auto-Sync

// Start sync only if online
if (navigator.onLine) {
  syncWorker.start();
}

// Resume sync when connection restored
window.addEventListener("online", () => {
  syncWorker.start();
});

// Pause sync when offline
window.addEventListener("offline", () => {
  syncWorker.stop();
});

Skip Backoff on Retry

// User explicitly wants to retry immediately
button.onclick = async () => {
  await syncWorker.syncNow({ overrideBackoff: true });
};

Handling Session Invalidation

When a user's session expires, the server will return a 401 Unauthorized response. Because the pull handler throws on non-OK responses, the sync worker captures this as lastError and stops attempting further sync cycles until the user re-authenticates.

This is intentional — continuing to sync with an expired session would at best produce misleading errors and at worst leak data or corrupt the local state.

What Happens

  1. The pull (or push) handler receives a 401 from the server.
  2. The handler throws, e.g. throw new Error("Pull failed with status 401").
  3. The worker records this as lastError and surfaces it via statuschange.
  4. The worker enters backoff and will not make further network requests until the cycle restarts — but a 401 will keep failing until the session is valid again.

Detecting and Recovering

Listen for statuschange and inspect lastError.message for a 401 to prompt the user to log in again:

syncWorker.on("statuschange", (e) => {
  const { lastError } = e.detail;

  if (lastError?.message.includes("401")) {
    // Session has expired — stop the worker and redirect to login
    syncWorker.stop();
    window.location.href = "/login";
  }
});

Or, if you prefer to show an inline prompt rather than redirect automatically:

syncWorker.on("statuschange", (e) => {
  const { lastError } = e.detail;

  if (lastError?.message.includes("401")) {
    ui.showWarning("Your session has expired. Please log in again to resume syncing.");
  }
});

Do not call syncWorker.stop() and immediately start() again after a 401 — this will just hammer the server with unauthenticated requests. Wait until the user has successfully re-authenticated before restarting the worker.

Throwing the Right Error

For the worker to detect session expiry, your push and pull handlers must throw on non-OK responses. Make sure your handlers include a status check:

pull: {
  handler: async (cursor) => {
    const res = await fetch("/api/sync/pull", {
      method: "POST",
      body: JSON.stringify({ lastChangelogId: cursor }),
      headers: { "Content-Type": "application/json" },
    });
    if (!res.ok) {
      throw new Error(`Pull failed with status ${res.status}`);
    }
    return res.json();
  },
},

The error message is available on syncWorker.status.lastError.message and in every statuschange event.

On this page