PGlite: Real Postgres in the Browser, in Tests, and Without Docker

Every time I spin up a new project, the database is the first thing that slows me down. Start Docker. Pull an image. Wait for the container. Remember which port I used last time. Then do it all again in CI.
PGlite removes most of that. It's Postgres compiled to WebAssembly, packaged as an npm module, and it runs anywhere JavaScript runs.
In this guide we'll cover what PGlite is, three places it genuinely earns its keep, where it falls short, and how to use it for Supabase-style local development without Docker.
What PGlite actually is
PGlite is a WASM build of Postgres wrapped in a TypeScript client. It runs in the browser, Node.js, Bun, and Deno with no other dependencies, and it's under 3 MB gzipped.
The important detail: it is not a Postgres emulator and it doesn't boot a Linux VM in the browser the way earlier "Postgres in the browser" projects did. It's Postgres itself, compiled to WASM. Your SQL, your jsonb, your triggers, and your foreign keys behave like Postgres because they are Postgres.
Getting started is one install:
npm install @electric-sql/pglite
And one line to create a database:
import { PGlite } from '@electric-sql/pglite'
const db = new PGlite()
const result = await db.query("select 'Hello world' as message;")
console.log(result.rows) // [ { message: 'Hello world' } ]
That's an in-memory database. Pass a path and it persists to disk:
const db = new PGlite('./path/to/pgdata')
query vs exec
PGlite gives you two methods, and it's worth knowing which to reach for:
| Method | Use it for | Parameters |
|---|---|---|
db.query(sql, params) |
A single statement, especially with user input | Yes ($1, $2, …) |
db.exec(sql) |
Migrations, schema setup, batch inserts | No — raw SQL, multiple statements |
// Schema setup: exec runs multiple statements at once
await db.exec(`
CREATE TABLE IF NOT EXISTS todo (
id SERIAL PRIMARY KEY,
task TEXT NOT NULL,
done BOOLEAN DEFAULT false
);
`)
// User input: always use query with parameters
await db.query('INSERT INTO todo (task) VALUES ($1)', ['Try PGlite'])
const { rows } = await db.query('SELECT * FROM todo WHERE done = $1', [false])
Use case 1: Database tests without Docker
This is where PGlite pays for itself fastest. Most teams run integration tests against a Postgres container, which means slow CI startup, shared state between tests, and a "works on my machine" gap whenever someone's Docker isn't running.
With PGlite, every test gets a fresh, isolated, real Postgres instance:
// users.test.ts
import { PGlite } from '@electric-sql/pglite'
import { beforeEach, afterEach, test, expect } from 'vitest'
let db: PGlite
beforeEach(async () => {
db = new PGlite() // fresh in-memory database per test
await db.exec(`
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL
);
`)
})
afterEach(async () => {
await db.close()
})
test('rejects duplicate emails', async () => {
await db.query('INSERT INTO users (email) VALUES ($1)', ['a@example.com'])
await expect(
db.query('INSERT INTO users (email) VALUES ($1)', ['a@example.com'])
).rejects.toThrow()
})
No container, no cleanup scripts, no port conflicts. The UNIQUE constraint is enforced by real Postgres, so a passing test means something.
Use case 2: A persistent database inside the browser
PGlite ships with virtual file systems for persistence. In the browser, an idb:// prefix stores the database in IndexedDB, so data survives page reloads:
import { PGlite } from '@electric-sql/pglite'
const db = new PGlite('idb://my-app-db')
await db.exec(`
CREATE TABLE IF NOT EXISTS notes (
id SERIAL PRIMARY KEY,
body TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
`)
That opens up local-first apps, offline-capable tools, and interactive SQL tutorials that run entirely client-side.
Reacting to changes with live queries
PGlite's live extension re-runs a query whenever the underlying tables change, which removes a lot of manual cache invalidation:
import { PGlite } from '@electric-sql/pglite'
import { live } from '@electric-sql/pglite/live'
const db = await PGlite.create({
dataDir: 'idb://my-app-db',
extensions: { live },
})
await db.live.query(
'SELECT * FROM notes ORDER BY created_at DESC',
[],
(result) => {
renderNotes(result.rows) // runs on every change to `notes`
}
)
There are also bindings for React and Vue (@electric-sql/pglite-react, @electric-sql/pglite-vue) if you'd rather use hooks.
Use case 3: Local development without the container stack
This is the use case I'm most excited about, because local backend setup is where the most time disappears.
Take Supabase. Its local development stack is a 12-container Docker install weighing in around 2.3 GB. It works, but it's heavy for a laptop and heavier still for CI or a cloud dev environment.
tinbase is an open-source (MIT), Supabase-compatible backend that runs as a single process, and one of its database engines is PGlite. It implements the REST, Auth, Storage, and Realtime protocols, so the official supabase-js SDK works against it unchanged:
npx tinbase start
import { createClient } from '@supabase/supabase-js'
const supabase = createClient('http://127.0.0.1:54321', ANON_KEY)
await supabase.auth.signUp({ email, password })
await supabase.from('todos').insert({ title: 'hello' })
const { data } = await supabase
.from('todos')
.select('*')
.eq('done', false)
Row Level Security policies using auth.uid() work as they do on hosted Supabase, and it reads your existing supabase/migrations/*.sql and seed.sql. If you outgrow it, you push the same migration files to hosted Supabase.
Because the PGlite engine is pure WASM, the whole backend (database, auth, and all) can run in-process in a browser tab. The project came out of RapidNative, with the goal of running an entire dev stack in the browser and on phones without VMs or a cloud backend.
Honest caveat: tinbase is currently alpha and not production-ready. Treat it as a local development, prototyping, and testing tool, not a production backend.
Where PGlite falls short
PGlite is excellent at what it does, but it isn't a drop-in replacement for a Postgres server.
One connection at a time
PGlite has a single, exclusive connection to the database. That's fine for tests and a single browser tab, but it isn't a multi-user server. For multiple tabs, PGlite provides a multi-tab worker that shares one instance between tabs.
Extensions are a curated list
PGlite supports Postgres extensions through its extensions API, including pgvector, but only those on its supported list. If your production schema relies on a niche extension, check the list before committing.
It's not your production database
Use PGlite for tests, local development, embedded and browser databases, and edge workloads. Keep production on a real Postgres server, and treat PGlite as the thing that makes everything around production faster.
Quick comparison
| PGlite | Postgres in Docker | SQLite | |
|---|---|---|---|
| Real Postgres semantics | Yes | Yes | No |
| Runs in the browser | Yes | No | Yes (via WASM builds) |
| Setup | npm install |
Docker + image + container | npm install |
| Concurrent connections | One | Many | Limited |
| Best for | Tests, local dev, embedded apps | Production-like environments | Embedded apps on non-Postgres stacks |
Key takeaways
- PGlite is real Postgres compiled to WASM, under 3 MB gzipped, running in browsers, Node, Bun, and Deno.
- The fastest win is tests: a fresh, isolated Postgres per test with no Docker.
- In the browser,
idb://gives you persistence and theliveextension gives you reactive queries. - Tools like tinbase build on it to replace container-heavy local backends with a single process.
- It's single-connection, so keep production on a real Postgres server.
Have you swapped Docker Postgres for PGlite in your test suite yet? I'd like to hear what broke, or what didn't.


