Fix: PGlite Not Working — IndexedDB Persistence, Worker Setup, Extensions, and Live Queries
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 fileOr 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. Eitherawait PGlite.create(...)orawait db.waitReadybefore 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 PostgresCREATE EXTENSIONworks 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 definedin older Chrome. PGlite needs cross-origin isolation. SetCross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: require-corpheaders 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. BIGINTreturns asbigint(BigInt), notnumber. Postgres BIGINT is 64-bit; JSnumberis 53. EitherNumber(row.id)(safe for most app IDs) or keep asbigintin 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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Supabase Realtime Not Working — RLS Filters, Channel Subscribe, Presence, and Broadcast
How to fix Supabase Realtime errors — postgres_changes subscription not firing, RLS blocking events, channel.subscribe callback timing, presence diff payloads, broadcast vs database events, auth refresh, and reconnection.
Fix: AWS RDS Proxy Not Working — Endpoint, IAM Auth, Connection Pinning, and Lambda VPC
How to fix AWS RDS Proxy errors — IAM authentication token mismatch, connection pinning blocking reuse, Lambda VPC routing, Secrets Manager rotation, max_connections, read/write splitter, and TLS requirement.
Fix: Inertia.js Not Working — Shared Data, Lazy Props, Versioning, Forms, and SSR
How to fix Inertia.js errors — Inertia.render not returning a component, shared data missing on every page, lazy props not deferring, asset versioning forcing reloads, useForm helper, and SSR setup.
Fix: next-themes Not Working — Hydration Mismatch, Tailwind Dark Mode, FOUC, and System Preference
How to fix next-themes errors — hydration mismatch on mount, FOUC flash before theme applies, Tailwind dark: classes not switching, ThemeProvider in App Router, defaultTheme system not respected, and TypeScript types.