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.

How PGlite runs Postgres in a page Application code calls PGlite's query API. PGlite runs the PostgreSQL server compiled to Wasm in single-user mode. Its file system is an in-memory virtual file system that can be persisted to IndexedDB or the Origin Private File System. Running it in a worker keeps the page responsive. application await db.query('select …') PGlite API query, exec, transaction, live queries Postgres in Wasm parser, planner, executor, single user virtual file system pages and WAL in memory persistence idb:// (IndexedDB) or opfs-ahp:// (OPFS)

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.

A query through PGliteWorker The page calls query on the PGliteWorker proxy, which posts the SQL and parameters to the worker. The worker runs the query in the Wasm Postgres instance, which reads and writes pages in its virtual file system and persists changes, then returns rows to the page. page worker Postgres (Wasm) query(sql, params) execute in single-user mode rows + persisted pages { rows, fields }

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 PGliteWorker with 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.

Inserting 10,000 rows in one transaction Milliseconds to insert ten thousand rows into PGlite in a single transaction with in-memory storage, OPFS persistence and IndexedDB persistence. ms for 10,000 inserts memory:// 120 ms opfs-ahp:// 180 ms idb:// 540 ms

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.

← Back to Databases & Persistent Storage in Wasm