-
Notifications
You must be signed in to change notification settings - Fork 59
Description
Problem
I was experiencing deadlocks when using multiple browser tabs with OPFSCoopSyncVFS. This happened in both Chrome and Safari (not just Safari as I initially thought).
Reproduction Steps
- Open Tab A with PowerSync app - works fine
- Open Tab B - works fine
- Refresh Tab B - still fine
- Switch back to Tab A - both tabs hang/break
The app would get stuck on "Syncing..." and never recover. Had to close all tabs and start fresh.
My Setup
@powersync/web(latest, updated packages recently)- Next.js 16 with Turbopack
- Manual worker setup (copying from node_modules to public/ since I can't use webpack)
What I Tried
-
Safari-only IDBBatchAtomicVFS - Detected Safari and used IDB for Safari, OPFS for Chrome
- Result: Safari worked, Chrome still broke
-
enableMultiTabs: false - Disabled SharedWorker coordination entirely
- Result: Second tab would never load data (hung waiting for DB access)
-
enableMultiTabs: true + IDBBatchAtomicVFS for all browsers
- Result: Works perfectly in both Chrome and Safari!
The Fix
I stopped using OPFSCoopSyncVFS entirely and let it default to IDBBatchAtomicVFS:
const dbFactory = new WASQLiteOpenFactory({
dbFilename: 'app.db',
flags: { enableMultiTabs: true },
// No vfs specified = uses default IDBBatchAtomicVFS
worker: '/powersync/worker/WASQLiteDB.umd.js',
});
const db = new PowerSyncDatabase({
database: dbFactory,
flags: { enableMultiTabs: true },
schema: MySchema,
sync: { worker: '/powersync/worker/SharedSyncImplementation.umd.js' },
});Root Cause
Seems like OPFS file locking doesn't play well with SharedWorker coordination when tabs fight for sync leadership. When you switch from Tab B back to Tab A, something about reclaiming the sync leader role causes an OPFS lock deadlock.
IDBBatchAtomicVFS (IndexedDB) handles concurrent access better and doesn't have this issue.
Any thoughts on this and am missing something? I didn't see much in the docs regarding this.
Maybe worth adding a note to the docs that OPFSCoopSyncVFS can cause multi-tab deadlocks, not just "Safari stability issues"? WeI a while debugging this thinking it was Safari-specific when it affected Chrome too.
Happy to provide more details if helpful!
Here's my full PS client for reference
import { PowerSyncDatabase, WASQLiteOpenFactory } from "@powersync/web";
import { reportError, reportWarning } from "@/lib/errors/report-error";
import { ListiumPowerSyncConnector } from "./connector";
import { ListiumPowerSyncSchema } from "./schema";
import { emitStatus } from "./status";
/** SQLite database filename for PowerSync local storage */
const DB_FILENAME = "listium-powersync.db";
/** Prefix for PowerSync debug logs (dev only) */
const WARN_PREFIX = "[PowerSync]";
/** Singleton PowerSync database instance */
let database: PowerSyncDatabase | null = null;
/** Singleton backend connector instance */
let connector: ListiumPowerSyncConnector | null = null;
/** Promise for ongoing initialization to prevent duplicate init calls */
let initPromise: Promise<PowerSyncDatabase | null> | null = null;
/** Flag to track if cleanup listener has been registered */
let cleanupListenerRegistered = false;
/** Flag to track if database is being cleaned up */
let isCleaningUp = false;
/** Maximum number of initialization retries */
const MAX_INIT_RETRIES = 3;
/** Base delay for exponential backoff (ms) */
const BASE_RETRY_DELAY = 100;
/**
* Debug logging flag for development builds.
*/
const shouldLogPowerSyncDebug =
process.env.NEXT_PUBLIC_POWERSYNC_DEBUG === "true" ||
process.env.NODE_ENV !== "production";
/**
* Checks if an error is the SQLite sequence error.
* @internal
*/
function isSequenceError(error: unknown): boolean {
return (
error instanceof Error &&
error.message.includes("SQLite Sequence should not be empty")
);
}
/**
* Logs diagnostic information for the sqlite_sequence + ps_crud tables when debugging.
*/
async function logCrudSequenceDiagnostics(
instance: PowerSyncDatabase,
phase: string,
): Promise<void> {
if (!shouldLogPowerSyncDebug) {
return;
}
try {
const sequenceRows = (await instance.database.getAll(
"SELECT name, seq FROM sqlite_sequence ORDER BY name",
)) as Array<{ name: string; seq: number }>;
const _crudCountRow = (await instance.database.get(
"SELECT COUNT(*) as count FROM ps_crud",
)) as { count?: number } | undefined;
console.group(`${WARN_PREFIX} Sequence diagnostics (${phase})`);
console.table(sequenceRows);
console.groupEnd();
} catch (error) {
reportWarning("PowerSync sequence diagnostics failed", { cause: error });
}
}
/**
* Ensures the ps_crud entry exists inside sqlite_sequence.
* Uses INSERT OR REPLACE to force-reset corrupted entries.
*/
async function seedCrudSequenceRow(instance: PowerSyncDatabase): Promise<void> {
try {
await instance.database.execute(
"INSERT OR REPLACE INTO sqlite_sequence(name, seq) VALUES('ps_crud', 0)",
);
} catch (error) {
reportWarning("PowerSync seed sequence row failed", { cause: error });
}
}
/**
* Retries an async function with exponential backoff.
* @internal
*/
async function retryWithBackoff<T>(
fn: () => Promise<T>,
_operation: string,
maxRetries = MAX_INIT_RETRIES,
): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (isSequenceError(error) && attempt < maxRetries - 1) {
const delay = BASE_RETRY_DELAY * 2 ** attempt;
reportWarning(
`PowerSync retry ${attempt + 1}/${maxRetries} after sequence error`,
{ cause: error },
);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
throw lastError;
}
/**
* Gets the singleton PowerSync database instance.
*
* @returns The PowerSync database, or null if not initialized
*
* @remarks
* Returns null if PowerSync is not configured or `initPowerSync()` hasn't been called yet.
*/
export function getPowerSyncDatabase(): PowerSyncDatabase | null {
return database;
}
/**
* Cleans up PowerSync database connection on page unload.
*
* @remarks
* - Disconnects from PowerSync service
* - Closes database connection gracefully
* - Prevents race conditions on reload
* @internal
*/
async function cleanupPowerSync(): Promise<void> {
if (isCleaningUp || !database) {
return;
}
isCleaningUp = true;
try {
// Add timeout to cleanup to prevent hanging
const cleanupPromise = (async () => {
if (database.connected) {
await database.disconnect();
}
await database.close();
})();
// Wait max 2 seconds for cleanup
await Promise.race([
cleanupPromise,
new Promise((resolve) => setTimeout(resolve, 2000)),
]);
} catch (error) {
// Log but don't block - we're unloading anyway
reportWarning("PowerSync cleanup failed", { cause: error });
} finally {
// Reset flag after a delay to allow reinit if needed
setTimeout(() => {
isCleaningUp = false;
}, 500);
}
}
/**
* Gets the singleton backend connector instance.
*
* @returns The backend connector, or null if not initialized
*
* @remarks
* Used internally by operations module.
* @internal
*/
export function getConnector(): ListiumPowerSyncConnector | null {
return connector;
}
/**
* Initializes the PowerSync database singleton.
*
* @returns Promise resolving to the PowerSync database, or null if not configured
*
* @remarks
* - Only runs client-side (returns null on server)
* - Uses singleton pattern - safe to call multiple times
* - Concurrent calls return the same promise
* - Connector fetches endpoint URL and auth token from `/api/powersync/auth`
* - Automatically connects to PowerSync service and begins syncing
* - Multi-tab enabled with IDBBatchAtomicVFS for cross-tab coordination
*/
export async function initPowerSync(): Promise<PowerSyncDatabase | null> {
if (typeof window === "undefined") {
return null;
}
// Don't init if we're in the middle of cleanup
if (isCleaningUp) {
reportWarning("PowerSync init called during cleanup - waiting");
// Wait up to 3 seconds for cleanup to finish (cleanup has 2s timeout + 500ms flag reset)
for (let i = 0; i < 30; i++) {
await new Promise((resolve) => setTimeout(resolve, 100));
if (!isCleaningUp) {
break;
}
}
if (isCleaningUp) {
reportWarning("PowerSync cleanup timeout - force resetting");
isCleaningUp = false;
}
}
if (database) {
return database;
}
if (initPromise) {
return initPromise;
}
initPromise = (async () => {
// Register cleanup listeners once
if (!cleanupListenerRegistered && typeof window !== "undefined") {
// Use pagehide instead of beforeunload for better reliability
window.addEventListener("pagehide", () => {
// Use void to fire-and-forget since we can't await in event listener
void cleanupPowerSync();
});
// Handle visibility change to reconnect if needed
document.addEventListener("visibilitychange", () => {
if (
document.visibilityState === "visible" &&
database &&
!database.connected &&
connector
) {
// Page became visible and PowerSync is not connected - reconnect
void database
.connect(connector, {
crudUploadThrottleMs: 5000,
retryDelayMs: 5000,
})
.catch((error) => {
reportWarning("PowerSync visibility reconnect failed", {
cause: error,
});
});
}
});
cleanupListenerRegistered = true;
}
// Small delay to ensure workers are ready (helps with reload race conditions)
await new Promise((resolve) => setTimeout(resolve, 50));
// Universal config using IDBBatchAtomicVFS (default) for all browsers
// IDBBatchAtomicVFS is more stable than OPFSCoopSyncVFS for multi-tab scenarios
// enableMultiTabs: true allows SharedWorker coordination for cross-tab sync
const dbFactory = new WASQLiteOpenFactory({
dbFilename: DB_FILENAME,
debugMode: process.env.NODE_ENV !== "production",
flags: {
enableMultiTabs: true,
},
// No vfs specified = uses default IDBBatchAtomicVFS (IndexedDB-based)
worker: "/powersync/worker/WASQLiteDB.umd.js",
});
const instance = new PowerSyncDatabase({
database: dbFactory,
flags: {
disableSSRWarning: true,
enableMultiTabs: true,
},
schema: ListiumPowerSyncSchema,
sync: {
worker: "/powersync/worker/SharedSyncImplementation.umd.js",
},
});
instance.registerListener({
statusChanged: (status) => emitStatus(status),
});
connector ??= new ListiumPowerSyncConnector();
try {
// Wrap init in retry logic to handle sequence errors
await retryWithBackoff(() => instance.init(), "Database initialization");
await logCrudSequenceDiagnostics(instance, "post-init");
await seedCrudSequenceRow(instance);
// Wrap connect in retry logic as well
if (!connector) {
throw new Error("Connector not initialized");
}
const connectorInstance = connector;
await retryWithBackoff(
() =>
instance.connect(connectorInstance, {
crudUploadThrottleMs: 5000,
retryDelayMs: 5000,
}),
"PowerSync connection",
);
} catch (error) {
reportError({
cause: error,
message: "PowerSync initialization failed",
severity: "critical",
});
await logCrudSequenceDiagnostics(instance, "initialization-error");
// Check if this is still the sequence error after retries
if (isSequenceError(error)) {
await seedCrudSequenceRow(instance);
reportWarning("PowerSync sequence error - resetting state", {
cause: error,
});
// Reset state so next attempt can try again
database = null;
initPromise = null;
// Try to clear the database
try {
await instance.close();
} catch (closeError) {
reportWarning("PowerSync close failed during recovery", {
cause: closeError,
});
}
throw error;
}
// Still set database so app can work offline
// but log error clearly for debugging
}
database = instance;
emitStatus(instance.currentStatus);
return instance;
})();
const instance = await initPromise;
initPromise = null;
return instance;
}
export { clearPowerSyncDatabase, forcePowerSyncReconnect } from "./operations";
// Re-export commonly used functions from other modules
export { subscribeToPowerSyncStatus } from "./status";