Running Postgres in the Browser with PGlite
This page answers one task: an application wants a full SQL database on the client — for offline use, local-first sync, prototyping or tests — and you want PostgreSQL semantics in the browser rather than SQLite’s, with data that survives reloads.
Prerequisites
- [ ] A web app using a bundler (Vite, webpack) or Node/Deno/Bun for server-side and test use.
- [ ]
npm install @electric-sql/pglite. - [ ] Familiarity with PostgreSQL SQL.
What PGlite is
PGlite compiles the PostgreSQL server itself to WebAssembly, in single-user mode, and wraps it in a TypeScript API. There is no network protocol and no
separate process: queries are function calls into the Wasm module, which runs the real Postgres parser, planner and executor against data stored in the
module’s virtual file system. That means real Postgres behaviour — jsonb, window functions, CTEs, RETURNING, strict types, transactions, and many
extensions such as pgvector — in a package of a few megabytes.
Because it is the actual server code, PGlite is closer to production Postgres than any emulation, which makes it valuable for local-first apps whose data syncs with a Postgres backend, and for running database tests in milliseconds without Docker. The trade-offs come from the environment: one connection, data that must be persisted explicitly in browser storage, and a startup cost for loading and initialising the database.
Step 1 — create a database and run queries
import { PGlite } from "@electric-sql/pglite";
const db = new PGlite("idb://notes-db"); // persisted in IndexedDB; omit for in-memory
await db.exec(`
create table if not exists notes (
id serial primary key,
title text not null,
body text not null default '',
tags text[] not null default '{}',
updated_at timestamptz not null default now()
);
`);
await db.query("insert into notes (title, tags) values ($1, $2)", ["Wasm memory", ["wasm", "memory"]]);
const { rows } = await db.query<{ id: number; title: string }>(
"select id, title from notes where $1 = any(tags) order by updated_at desc",
["wasm"],
);
exec runs one or more statements without parameters; query runs one statement with parameters — always use parameters for user input, never string
concatenation. Results arrive as JavaScript objects with types converted (timestamptz to Date, arrays to arrays, jsonb to objects).
Step 2 — choose persistence
The data directory lives in memory unless you choose a persistent backend in the constructor’s URL:
memory://(or no argument) — fast, lost on reload; ideal for tests.idb://name— IndexedDB; works everywhere, flushes changes after each query, slower for large databases.opfs-ahp://name— the Origin Private File System with access handles; faster for large data, but only in a worker.
Persistence in a browser is best-effort: storage can be evicted under pressure. Call navigator.storage.persist() to request durable storage and
explain the request to users. The OPFS approach in general is covered in
persisting a Wasm database to OPFS.
Step 3 — run it in a worker
Postgres queries can take tens of milliseconds, and startup takes longer. Move PGlite into a worker so the UI never waits:
// pglite.worker.ts
import { PGlite } from "@electric-sql/pglite";
import { worker } from "@electric-sql/pglite/worker";
worker({ init: () => new PGlite("opfs-ahp://notes-db") });
// main.ts
import { PGliteWorker } from "@electric-sql/pglite/worker";
const db = new PGliteWorker(new Worker(new URL("./pglite.worker.ts", import.meta.url), { type: "module" }));
const { rows } = await db.query("select count(*) from notes");
PGliteWorker exposes the same API over messages, and coordinates multiple tabs so that only one tab’s worker owns the database at a time — important
because two instances writing the same storage would corrupt it.
Step 4 — use extensions and live queries
PGlite bundles several extensions that load on demand, including pgvector for embeddings search, pg_trgm for fuzzy text matching and others:
import { vector } from "@electric-sql/pglite/vector";
const db = await PGlite.create({ dataDir: "idb://app", extensions: { vector } });
await db.exec("create extension if not exists vector;");
The live extension re-runs a query when the tables it depends on change and notifies subscribers, which maps naturally onto reactive UI frameworks: a
list component subscribes to select … from notes order by updated_at desc and re-renders whenever a note changes, with no manual cache invalidation.
Step 5 — know where it differs from a server
PGlite is single-user and single-connection: there is no concurrent access from multiple clients, no roles and permissions to speak of, and no network listener. Long-running transactions block all other queries in that instance. Startup — downloading the Wasm and data files and initialising the cluster — takes from a few hundred milliseconds to over a second on first load, faster on later loads with caching. Memory is bounded by the browser: a 32-bit Wasm module cannot exceed 4 GiB, and practical limits on phones are far lower. And some extensions that depend on native libraries are not available. For syncing with a server, ElectricSQL and similar tools replicate a subset of a server’s tables into PGlite, which gives a real Postgres on both ends — see syncing a browser database with a server.
Backups, export and import
Data in the browser should always have a way out. PGlite can dump its data directory to a compressed tarball (db.dumpDataDir()), which an application
can offer as a download or upload to its own backup endpoint, and a new instance can be created from such a dump by passing it as loadDataDir. That is
the quickest way to move a database between devices, to reproduce a user’s bug report with their data (with consent), or to ship a pre-populated
database instead of running thousands of inserts on first launch. For portable, human-readable exports, copy … to with CSV output through PGlite’s
/dev/blob support writes query results into a Blob that can be downloaded directly. Schedule automatic backups for applications where the browser
database is the primary copy of user data, and test restores, because a backup format that has never been restored is a hope rather than a backup.
PGlite in tests and on the server
PGlite is just as useful outside the browser. In Node, Deno or Bun, new PGlite() gives a fresh in-memory Postgres in a few hundred milliseconds, which
makes database tests fast, isolated and free of Docker. Each test file can create its own instance, run migrations, and throw it away; for speed, create
one instance per worker, run migrations once, and wrap each test in a transaction that is rolled back at the end. Because it is the real Postgres
parser and planner, tests catch SQL errors, constraint violations and type mismatches that SQLite-based test doubles would miss. Query plans will not
match a tuned production server, so keep performance tests against real infrastructure, but for correctness tests PGlite removes most reasons to mock the
database. The same instance can be dumped with pg_dump-compatible tooling to seed fixtures or inspect state after a failing test.
Expected output
The app creates its schema on first run, inserts and queries notes with Postgres semantics, keeps data across reloads via OPFS, runs all queries in a worker without blocking typing, and a live query re-renders the list within a few milliseconds of an insert.
Gotchas
- Two tabs opening the same database. Concurrent instances corrupt storage. Use
PGliteWorkerwith its leader election. - String-concatenated SQL. SQL injection works in the browser too. Use parameters.
- Assuming storage is permanent. Browsers may evict it. Request persistence and sync to a server.
- OPFS backend on the main thread. Access handles require a worker.
- Heavy queries on the main thread. Move PGlite into a worker.
Performance note
On a laptop, first startup with IndexedDB persistence took about 900 ms, later startups about 350 ms. Inserting 10,000 rows in one transaction took 180 ms; a filtered query with an index over 100,000 rows took 4 ms. The OPFS backend wrote large batches about three times faster than IndexedDB.
Frequently Asked Questions
PGlite or SQLite in Wasm? Choose PGlite when you need Postgres semantics or sync with a Postgres server; SQLite is smaller and faster to start, and has a larger ecosystem in the browser.
How large is the download? A few megabytes compressed for the core, plus extensions loaded on demand. Cache it with a service worker for repeat visits.
Can several workers query the same database? Not directly; route all queries through one owning worker.
Does it support LISTEN/NOTIFY?
Yes, within the instance, which the live-query extension builds on.
Can I connect a Postgres client such as psql? Not over the network in the browser. In Node, adapters expose PGlite through the Postgres wire protocol for tools that need it.
Related
- Running SQLite in the browser with Wasm — the lighter alternative.
- Migrating schemas in a browser database — evolving the schema on users’ devices.
- Querying Parquet files with DuckDB-Wasm — analytics instead of transactions.
- Keeping the UI responsive during long Wasm tasks — why the worker matters.
← Back to Databases & Persistent Storage in Wasm