Skip to content

Multi-tab behavior

Every tab that opens the same PGlite database shares one set of files, and therefore one supapower.changes queue. Exactly one tab may drain it, otherwise the same batch is pushed twice.

Opening a plain PGlite instance against the same dataDir from several tabs risks corrupting the database no matter what Supapower does with the queue - only ever touch the files from one process, using a worker.

@supapower/worker is the more stable option: one SharedWorker hosts the database for every tab instead of one tab owning it, falling back automatically to PGlite’s own dedicated worker where SharedWorker isn’t available.

./pglite-worker.ts
import { worker } from '@supapower/worker/worker';
import { IdbFs, PGlite } from '@electric-sql/pglite';
worker({
async init() {
const pg = new PGlite({
fs: new IdbFs('your-app'),
relaxedDurability: true,
});
// Preferably run any database migrations here...
return pg;
},
});
./pglite.ts
import { createPGliteWorker } from '@supapower/worker';
import { supapower } from 'supapower';
export const pg = await createPGliteWorker(
{
shared: () =>
new SharedWorker(new URL('./pglite-worker.ts', import.meta.url), { type: 'module' }),
fallback: () => new Worker(new URL('./pglite-worker.ts', import.meta.url), { type: 'module' }),
},
{ id: 'your-app', extensions: { supapower } },
);

You can use PGlite’s own dedicated multi-tab worker instead if you’d rather not add the extra package - the setup is the same shape, just without the SharedWorker fallback, and id is optional rather than required. The downside with this approach is that queries may be frozen in non-leading tabs if the browser decides to save resources by throttling or suspending background tabs (i.e. the leader tab in that case).

With the SharedWorker approach above this problem is mitigated, as the shared worker continues to run independently of individual tabs, ensuring that queries are not frozen even when the user switches away from the tab that initially started the worker.

That lock cannot live in Postgres. PGlite is a single-connection engine and every tab runs its own instance, so pg_advisory_lock() is invisible to the other tabs and would block the only connection this one has. Supapower coordinates in the browser instead, and picks the strongest mechanism the runtime offers:

leadership When Mechanism
visible-tab A browser tab An exclusive Web Lock named supapower:outgoing:<scope>, held only while the tab is visible
web-lock A browser context without a document (e.g. a worker) The same lock, held unconditionally
single-process Node, Bun, Deno None - a second process is assumed not to exist

Leadership moves on its own when the leading tab is hidden, closed or crashes: a Web Lock is released by the browser when the tab closes or crashes, and a visible tab gives it up the instant it is hidden. A hidden tab never drains the queue - browsers throttle or freeze timers in hidden tabs, so a hidden leader would stall the queue for every tab - and no tab drains while every tab is hidden. Read sync.leadership to see which mechanism you ended up with.

Leadership moving between tabs starts a fresh syncer each time, but it does not mean a fresh download every time: downloadThrottle skips a table whose last download is still within the window, so switching tabs repeatedly inside that window re-downloads nothing.