For years, "Postgres in the browser" meant one of two things: a thin client talking to a remote server, or an entire Linux virtual machine compiled to WebAssembly just to boot a database. Both are heavy. PGlite takes a different route. It compiles PostgreSQL itself directly to WASM, packaged as a tidy TypeScript client, so a full Postgres instance runs inside the browser, Node.js, Bun, or Deno with no server, no Docker, and zero runtime dependencies.
This is not an emulation or a Postgres-flavored query layer. It is the real engine, which means you get JSONB, CTEs, window functions, full ACID transactions, and crucially the Postgres extension ecosystem, all running on the client. That opens the door to local-first apps, offline PWAs, in-browser AI search with pgvector, lightning-fast isolated tests, and interactive playgrounds that embed a genuine database in a static page. Built by the team behind ElectricSQL, PGlite is already powering Prisma's local dev database and a growing crowd of local-first tools.
Why It Is Not a Virtual Machine
The clever part of @electric-sql/pglite is what it avoids. Postgres normally relies on fork() to spawn worker processes for its client/server architecture, and Emscripten (the C-to-WASM compiler) cannot fork. Rather than wrap the whole thing in a Linux VM, PGlite runs Postgres in its single-user mode, a mode originally designed for bootstrapping and disaster recovery, and wires a custom I/O pathway so JavaScript talks to the WASM engine directly.
The payoff is a build that is roughly 3MB gzipped and boots quickly. The tradeoff, which follows directly from single-user mode, is that each instance is a single connection. It is an embedded database, not a multi-tenant server. For the local-first and testing use cases PGlite targets, that is exactly the right shape.
Here is the highlight reel:
- Real PostgreSQL, not an approximation, with the full SQL surface and ACID transactions.
- Runs everywhere JS runs including the browser, Node.js, Bun, and Deno.
- Tiny and self-contained, around 3MB gzipped with zero npm dependencies.
- Flexible persistence across in-memory, native filesystem, IndexedDB, and OPFS.
- Live, reactive queries through the
liveextension. - 40+ bundled extensions plus external packages for pgvector, PostGIS, and Apache AGE.
- Framework bindings for React and Vue, plus a multi-tab worker for sharing one instance across tabs.
Getting It Installed
Pick your runtime and install the core package.
npm install @electric-sql/pglite
yarn add @electric-sql/pglite
Bun and Deno work too (bun install @electric-sql/pglite or deno add npm:@electric-sql/pglite). Once it is in your project, importing the client is a single line.
import { PGlite } from "@electric-sql/pglite";
There is also a CDN/ESM path if you want real Postgres in a no-build static page, but the bundler import is what you will reach for in most apps.
Spinning Up a Database
Creating a database is a matter of choosing where it lives. Pass a connection-string-style prefix and PGlite picks the right storage backend automatically.
import { PGlite } from "@electric-sql/pglite";
// Ephemeral, lives only in memory
const memDb = new PGlite();
// Persistent in the browser, backed by IndexedDB
const browserDb = new PGlite("idb://my-app");
// Persistent on disk in Node, Bun, or Deno
const nodeDb = new PGlite("./data/pgdata");
The constructor returns immediately, but the engine takes a moment to boot. For anything beyond a quick script, prefer the async factory PGlite.create(), which resolves only once the database is ready and is also the form you need when loading extensions.
const db = await PGlite.create("idb://my-app");
Talking to It in SQL
PGlite gives you three ways to run statements, and you will reach for different ones depending on the job. Use exec for schema setup and migrations where you run several statements at once.
await db.exec(`
CREATE TABLE IF NOT EXISTS todos (
id SERIAL PRIMARY KEY,
task TEXT NOT NULL,
done BOOLEAN DEFAULT false
);
INSERT INTO todos (task) VALUES ('Learn PGlite');
`);
For single queries with values, query takes positional parameters and returns a typed result. There is also a tagged-template form, sql, that reads naturally and still parameterizes safely under the hood.
interface Todo {
id: number;
task: string;
done: boolean;
}
const byId = await db.query<Todo>(
"SELECT * FROM todos WHERE id = $1;",
[1]
);
console.log(byId.rows); // [{ id: 1, task: "Learn PGlite", done: false }]
const open = await db.sql<Todo>`
SELECT * FROM todos WHERE done = ${false} ORDER BY id;
`;
The result object carries more than rows. You also get affectedRows, a fields array describing each column's name and Postgres type ID, and an optional blob when you run a COPY TO. If you prefer arrays over objects for performance, pass { rowMode: "array" } in the query options.
Wrapping Work in Transactions
Because PGlite is a genuine Postgres instance, transactions behave exactly as you would expect on the server. The transaction helper hands you a scoped object and commits when the callback resolves, or rolls back if it throws.
await db.transaction(async (tx) => {
await tx.query("INSERT INTO todos (task) VALUES ($1)", ["Write tests"]);
await tx.query("INSERT INTO todos (task) VALUES ($1)", ["Ship feature"]);
const count = await tx.query<{ n: number }>(
"SELECT count(*)::int AS n FROM todos;"
);
if (count.rows[0].n > 100) {
await tx.rollback(); // bail out, nothing above is persisted
}
});
This is the same atomicity guarantee you rely on in production, which is the whole point of running real Postgres rather than a key-value store like IndexedDB. Your test suite and your local prototype exercise the same semantics your server does.
Making Queries React to Change
The feature that makes PGlite shine in UI work is the live extension. Instead of polling, you subscribe to a query and receive a callback whenever the underlying data changes. Load it through the extensions option at creation time.
import { PGlite } from "@electric-sql/pglite";
import { live } from "@electric-sql/pglite/live";
const pg = await PGlite.create({ extensions: { live } });
const sub = pg.live.query<Todo>(
"SELECT * FROM todos ORDER BY id;",
[],
(res) => {
render(res.rows);
}
);
// Later, when the component unmounts
sub.unsubscribe();
The subscription object also exposes initialResults and a refresh() method, and it supports windowed pagination with offset and limit so you can drive an infinite list off live data. For large result sets, swap in live.incrementalQuery(), which keeps a snapshot of the previous state inside Postgres, diffs it, and transfers only the rows that actually changed. It needs a key column to track identity.
const sub = pg.live.incrementalQuery<Todo>(
"SELECT * FROM todos ORDER BY id;",
[],
"id",
(res) => render(res.rows)
);
If you want the finest grain, live.changes() emits per-row change objects tagged with the operation (INSERT, UPDATE, DELETE) and the list of columns that changed, which is ideal for surgical DOM updates.
The React Hook
If you are in React, the @electric-sql/pglite-react package wraps all of this in a hook. Provide the database through context, then call useLiveQuery and let the component re-render itself whenever the data shifts. Vue developers get an equivalent in @electric-sql/pglite-vue.
import { useLiveQuery } from "@electric-sql/pglite-react";
function TodoList() {
const todos = useLiveQuery<Todo>("SELECT * FROM todos ORDER BY id;");
return (
<ul>
{todos?.rows.map((t) => (
<li key={t.id} style={{ opacity: t.done ? 0.5 : 1 }}>
{t.task}
</li>
))}
</ul>
);
}
There is no reducer to write, no cache to invalidate, and no manual refetch. Insert a row anywhere in your app and every useLiveQuery watching that table updates on its own.
Vectors, Extensions, and AI Search
The extension story is where PGlite pulls decisively ahead of SQLite-in-WASM. Beyond the 40-plus bundled contrib modules (pg_trgm, hstore, ltree, uuid-ossp, pgcrypto, and friends), you can load pgvector for similarity search, which makes in-browser RAG and embedding lookups genuinely practical.
import { PGlite } from "@electric-sql/pglite";
import { vector } from "@electric-sql/pglite/vector";
const pg = await PGlite.create({ extensions: { vector } });
await pg.exec("CREATE EXTENSION IF NOT EXISTS vector;");
await pg.exec(`
CREATE TABLE items (
id SERIAL PRIMARY KEY,
embedding vector(3)
);
`);
await pg.query(
"INSERT INTO items (embedding) VALUES ($1);",
["[0.1, 0.2, 0.3]"]
);
const nearest = await pg.query(
"SELECT id FROM items ORDER BY embedding <-> $1 LIMIT 5;",
["[0.1, 0.2, 0.25]"]
);
Heavier extensions exist too, including PostGIS for geospatial work (still experimental and large) and Apache AGE for graph queries. The same loading pattern applies to each: import it, pass it through extensions, and run CREATE EXTENSION.
Backups, ORMs, and Sharing Across Tabs
Three practical conveniences round out the library. First, the whole database serializes to a tarball blob via dumpDataDir(), and you can restore it through the loadDataDir option, which makes snapshotting state for tests or persisting between sessions trivial. Second, PGlite plays nicely with the ORM you already use. Drizzle treats it as a first-class driver, Kysely ships a built-in dialect, and adapters exist for Knex, TypeORM, and Prisma.
import { PGlite } from "@electric-sql/pglite";
import { drizzle } from "drizzle-orm/pglite";
const client = new PGlite();
const db = drizzle(client);
Third, because each instance is single-connection, the browser worker package lets multiple tabs share one PGlite through a Web Worker rather than each opening its own. Combine that with the relaxedDurability: true constructor option, which skips waiting on storage flushes, and you get a noticeably snappier feel in client-heavy apps where you can tolerate the small durability tradeoff.
When to Reach for It
PGlite is not trying to replace your production Postgres server, and its single-connection design and roughly 3MB footprint are honest costs to weigh. Pure in-memory benchmarks still favor SQLite-WASM. What PGlite offers instead is parity: the same database engine, the same SQL, and the same extensions in your tests, your local dev setup, your offline app, and your edge function as you run in production.
If you are building local-first software, prototyping against a database you will later host server-side, running fast isolated tests without a Docker container, or shipping in-browser AI search, a real Postgres that boots in a tab is a genuinely new capability. Pre-1.0 it may still be, but with Prisma leaning on it for local dev, a funded team behind it, and frequent releases, PGlite has quietly turned "Postgres everywhere" from a slogan into an import statement.