Skip to content

Fix: PGlite Not Working — IndexedDB Persistence, Worker Setup, Extensions, and Live Queries

FixDevs ·

Quick Answer

How to fix PGlite errors — async init not awaited, IndexedDB persistence lost on reload, Web Worker isolation, pgvector and other extensions, live queries with @electric-sql/pglite-react, and migration patterns.

The Error

You try to query PGlite before it’s ready and get nothing:

import { PGlite } from "@electric-sql/pglite";

const db = new PGlite();
const result = await db.query("SELECT 1");
// May throw or return weird results before init completes.

Or your data disappears after a page reload:

const db = new PGlite();  // In-memory only — gone on reload.
await db.query("INSERT INTO notes ...");
// Reload → table doesn't exist.

Or installing pgvector fails silently:

const db = new PGlite("idb://my-db", {
  extensions: { vector }
});
await db.query("CREATE EXTENSION vector");
// ERROR: could not open extension control file

Or React components don’t re-render when PGlite data changes:

const notes = await db.query("SELECT * FROM notes");
// notes is static. INSERT from another component → no re-render.

Why This Happens

PGlite is a full Postgres compiled to WebAssembly that runs entirely in the browser (or Node, or Bun). It’s not a wrapper around a server — there is no server. Three things commonly bite:

  • Async initialization. new PGlite() returns immediately but the WASM isn’t loaded yet. Either await PGlite.create(...) or await db.waitReady before querying.
  • In-memory vs persistent. Default is in-memory. To persist, use a connection string like "idb://my-db" (IndexedDB) or "memory://my-db" for explicit memory.
  • Extensions are pre-bundled, not installable at runtime. You import them as ES modules and pass them in extensions: { ... }. The standard Postgres CREATE EXTENSION works only for the ones you bundled.
  • Live queries need a wrapper. Plain queries are one-shot. Use @electric-sql/pglite-react (or write a subscription layer) to get reactive results that update on INSERT/UPDATE/DELETE.

Fix 1: Wait for Initialization

Use the create factory or waitReady:

import { PGlite } from "@electric-sql/pglite";

// Option A — factory pattern (cleanest):
const db = await PGlite.create();
await db.query("SELECT 1");

// Option B — constructor + await waitReady:
const db = new PGlite();
await db.waitReady;
await db.query("SELECT 1");

For React (or any framework with lazy mount), initialize once and share via context:

import { createContext, useContext, useEffect, useState } from "react";
import { PGlite } from "@electric-sql/pglite";

const DBContext = createContext<PGlite | null>(null);

export function DBProvider({ children }: { children: React.ReactNode }) {
  const [db, setDB] = useState<PGlite | null>(null);

  useEffect(() => {
    PGlite.create("idb://app-db").then(setDB);
  }, []);

  if (!db) return <Loading />;
  return <DBContext.Provider value={db}>{children}</DBContext.Provider>;
}

export const useDB = () => {
  const db = useContext(DBContext);
  if (!db) throw new Error("DBContext not provided");
  return db;
};

Pro Tip: Initialize PGlite at app startup, not per-component. WASM load is ~5 MB; creating multiple instances doubles that.

Fix 2: Persist Data With IndexedDB

The connection string decides persistence:

// In-memory (default — no persistence):
const db = await PGlite.create();

// IndexedDB (survives reload, per-origin):
const db = await PGlite.create("idb://my-app-db");

// OPFS (faster, Chrome/Edge — Origin Private File System):
const db = await PGlite.create("opfs-ahp://my-app-db");

// Node filesystem:
const db = await PGlite.create("/path/to/my-app-db");

For local-first apps, IDB is the safe default — works in every browser, persists across reloads, ~tens-of-MB capacity per origin.

OPFS is faster for large databases but Chrome/Edge only as of 2024-2026. Detect at runtime:

const supportsOPFS = "storage" in navigator && "getDirectory" in navigator.storage;
const url = supportsOPFS ? "opfs-ahp://my-db" : "idb://my-db";
const db = await PGlite.create(url);

Common Mistake: Switching between idb://, opfs-ahp://, and memory:// between sessions. Each backend has its own storage — switching means a fresh empty DB. Pick one and stick with it.

Fix 3: Bundle Extensions at Build Time

Extensions are imported as ESM and passed in:

import { PGlite } from "@electric-sql/pglite";
import { vector } from "@electric-sql/pglite/vector";
import { live } from "@electric-sql/pglite/live";

const db = await PGlite.create({
  dataDir: "idb://my-db",
  extensions: { vector, live },
});

// Now you can use vector:
await db.query(`
  CREATE TABLE embeddings (
    id SERIAL PRIMARY KEY,
    embedding vector(1536)
  );
`);

await db.query(
  "INSERT INTO embeddings (embedding) VALUES ($1)",
  [`[${new Array(1536).fill(0.0).join(",")}]`],
);

Available bundled extensions include vector (pgvector), live (live queries), and a few others. Check the PGlite docs for the current list — non-bundled Postgres extensions can’t be installed at runtime.

Pro Tip: For LLM/RAG apps in the browser, the combination of vector + a local model (Transformers.js) gives you on-device semantic search with zero backend.

Fix 4: Run PGlite in a Web Worker

Querying PGlite blocks the main thread for milliseconds at a time on large queries. For UI responsiveness, run it in a Worker:

// worker.ts
import { PGlite } from "@electric-sql/pglite";
import { worker } from "@electric-sql/pglite/worker";

worker({
  async init() {
    return new PGlite("idb://my-db");
  },
});
// app.ts
import { PGliteWorker } from "@electric-sql/pglite/worker";

const db = new PGliteWorker(
  new Worker(new URL("./worker.ts", import.meta.url), { type: "module" }),
);

const result = await db.query("SELECT * FROM notes");

The Worker API mirrors the main-thread API — same query, exec, transaction methods. Behind the scenes, calls are message-passed over postMessage.

Inside the worker, you can run heavy aggregations without blocking the UI:

// Slow analytics query — doesn't block clicks/scrolls in the main thread:
const stats = await db.query("SELECT date, COUNT(*) FROM events GROUP BY date");

Common Mistake: Sharing a PGliteWorker across multiple browser tabs. Each tab gets its own Worker — and each Worker tries to open the same IDB store. Conflicts happen. Use leader election if you need cross-tab sharing, or accept per-tab connections with proper locking.

Fix 5: Live Queries For Reactive UI

Plain db.query(...) is one-shot. For data that should update when underlying tables change, use the live extension:

import { PGlite } from "@electric-sql/pglite";
import { live } from "@electric-sql/pglite/live";

const db = await PGlite.create({
  dataDir: "idb://my-db",
  extensions: { live },
});

const subscription = await db.live.query(
  "SELECT * FROM notes ORDER BY created_at DESC",
  [],
  (rows) => {
    console.log("notes changed:", rows);
  },
);

// Later, to unsubscribe:
subscription.unsubscribe();

For React, the dedicated package is @electric-sql/pglite-react:

import { useLiveQuery } from "@electric-sql/pglite-react";

function NotesList() {
  const results = useLiveQuery(
    "SELECT * FROM notes ORDER BY created_at DESC",
    [],
  );

  if (!results) return <p>Loading…</p>;
  return (
    <ul>
      {results.rows.map((note) => (
        <li key={note.id}>{note.title}</li>
      ))}
    </ul>
  );
}

The hook re-renders whenever an INSERT, UPDATE, or DELETE on the queried tables fires. Internally, PGlite uses Postgres notify/listen to track changes — efficient even for large tables.

Fix 6: Migrations

PGlite has no built-in migration tool. Two simple patterns:

Pattern A — version table:

async function migrate(db: PGlite) {
  await db.exec(`CREATE TABLE IF NOT EXISTS migrations (id INTEGER PRIMARY KEY, applied_at TIMESTAMPTZ DEFAULT now())`);

  const applied = await db.query<{ id: number }>("SELECT id FROM migrations");
  const appliedIds = new Set(applied.rows.map((r) => r.id));

  const migrations = [
    { id: 1, sql: "CREATE TABLE notes (id SERIAL PRIMARY KEY, title TEXT, body TEXT)" },
    { id: 2, sql: "ALTER TABLE notes ADD COLUMN tags TEXT[]" },
    { id: 3, sql: "CREATE INDEX notes_title_idx ON notes (title)" },
  ];

  for (const m of migrations) {
    if (!appliedIds.has(m.id)) {
      await db.transaction(async (tx) => {
        await tx.exec(m.sql);
        await tx.exec("INSERT INTO migrations (id) VALUES ($1)", [m.id]);
      });
    }
  }
}

await migrate(db);

Pattern B — use Drizzle/Kysely with the PGlite driver:

Both Drizzle and Kysely have PGlite adapters. Run migrations via their CLI against the persistent DB.

import { drizzle } from "drizzle-orm/pglite";
import { PGlite } from "@electric-sql/pglite";

const client = await PGlite.create("idb://my-db");
const db = drizzle(client);
// Use drizzle-kit migrate against the same dataDir.

Note: For browser-resident DBs, run migrations on first load, before the UI shows data. Otherwise a UI rendering a non-existent column blows up.

Fix 7: Bundle Size

The PGlite WASM is ~3-5 MB. To keep your initial bundle small:

// Lazy-load PGlite — first paint is fast, DB loads in the background:
const dbPromise = (async () => {
  const { PGlite } = await import("@electric-sql/pglite");
  return PGlite.create("idb://my-db");
})();

export const useDB = () => use(dbPromise);  // React 19 `use`

Dynamic import("@electric-sql/pglite") puts the WASM in its own chunk that’s fetched only when first needed.

For Vite, pre-bundle the WASM:

// vite.config.ts
import { defineConfig } from "vite";

export default defineConfig({
  optimizeDeps: {
    include: ["@electric-sql/pglite"],
  },
  worker: {
    format: "es",
  },
});

Common Mistake: Pre-loading PGlite on the landing page where the user might never sign in. Reserve the load for authenticated routes.

Fix 8: Sync With a Remote Backend

PGlite is local-only. For multi-device or backup, sync with a remote Postgres. Options:

ElectricSQL — purpose-built for this. Bidirectional sync between PGlite (or other local SQLite) and a remote Postgres, with conflict resolution.

PowerSync — similar, vendor-managed sync layer.

Roll your own — Postgres LISTEN/NOTIFY + a sync table tracking last-synced timestamps:

// On connect:
const lastSync = await getLastSync();
const updates = await fetch(`/api/sync?since=${lastSync}`).then((r) => r.json());

for (const upd of updates) {
  await db.exec(upd.sql, upd.args);
}

await setLastSync(updates.maxTimestamp);

For CRDT-based offline-first, libraries like Yjs or Automerge complement PGlite — they handle conflict resolution; PGlite stores the resolved state.

Still Not Working?

A few less-obvious failures:

  • SharedArrayBuffer is not defined in older Chrome. PGlite needs cross-origin isolation. Set Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp headers on the page.
  • IDB quota exceeded. Browsers limit per-origin storage. Chrome offers ~10% of disk; Safari is stricter. Check usage with navigator.storage.estimate().
  • CORS errors loading the WASM. PGlite hosts its WASM file on the same origin by default. If you proxy through a CDN, ensure CORS headers allow the load.
  • Date types serialize as strings, not Date objects. PGlite returns ISO strings for TIMESTAMP. Cast on read: new Date(row.created_at). Or use a custom row deserializer.
  • BIGINT returns as bigint (BigInt), not number. Postgres BIGINT is 64-bit; JS number is 53. Either Number(row.id) (safe for most app IDs) or keep as bigint in your types.
  • Worker fails with “Module not found” in production. Vite/webpack worker bundling has gotchas. The new Worker(new URL("./worker.ts", import.meta.url), { type: "module" }) pattern is portable across bundlers; legacy worker syntax doesn’t always survive bundling.
  • Multiple tabs corrupt the IDB. PGlite locks single-writer; concurrent tabs may see “DB locked” errors. Solutions: leader-election library, BroadcastChannel coordination, or accept single-tab behavior.
  • Slow first query after page load. WASM cold start. Pre-warm with a trivial db.query("SELECT 1") on init.

For related local-first and database issues, see Electric SQL not working, IndexedDB not working, SQLite database is locked, and Postgres connection refused.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles