PGlite: Run PostgreSQL in the Browser with WebAssembly

What You Will Learn
In this tutorial, you will discover how to use PGlite, an open-source project by ElectricSQL that compiles PostgreSQL to WebAssembly (WASM). By the end, you will be able to:
- Run a full PostgreSQL database entirely in the browser — no server required
- Persist data across page reloads using IndexedDB
- Use live reactive queries that automatically update your UI
- Build a local-first React todo application with TypeScript
- Understand when and why PGlite is the right choice for your project
Prerequisites
Before starting, ensure you have:
- Node.js 20+ installed
- npm or pnpm package manager
- Basic knowledge of React and TypeScript
- Familiarity with SQL (SELECT, INSERT, UPDATE, DELETE)
- A modern browser (Chrome, Firefox, or Edge)
Why PGlite?
Traditional web apps require a backend database server. PGlite changes this equation by embedding a complete PostgreSQL instance directly in your JavaScript runtime. Here is why this matters:
- Zero infrastructure: No database server to provision, configure, or maintain
- Instant startup: The database initializes in milliseconds inside the browser
- Full SQL support: Unlike IndexedDB or localStorage, you get real SQL with joins, indexes, JSON operators, and extensions
- Offline-first: Data lives in the browser — your app works without internet
- Reactive queries: PGlite's live query system pushes updates to your UI automatically
- Lightweight: The WASM bundle is approximately 3 MB gzipped
PGlite is ideal for prototyping, local-first applications, embedded tools, browser extensions, and any scenario where you want SQL power without server complexity.
Step 1: Create a New React Project
Start by scaffolding a new React project with Vite and TypeScript:
npm create vite@latest pglite-todo -- --template react-ts
cd pglite-todoInstall the core dependencies:
npm install @electric-sql/pglite
npm install @electric-sql/pglite-reactThe @electric-sql/pglite package contains the PostgreSQL WASM build, and @electric-sql/pglite-react provides React hooks for live queries and database management.
Step 2: Initialize the PGlite Database
Create a new file src/db.ts to set up the database instance:
// src/db.ts
import { PGlite } from "@electric-sql/pglite";
// Use IndexedDB for persistent storage across page reloads
const db = new PGlite("idb://todo-app");
export default db;The idb:// prefix tells PGlite to store data in IndexedDB. Without it, data would only exist in memory and disappear on refresh. Other storage options include:
memory://— in-memory only (great for tests)idb://database-name— persisted to IndexedDB- A file path in Node.js — persisted to the filesystem
Step 3: Create the Database Schema
Create a migration file src/migrate.ts that sets up the todos table:
// src/migrate.ts
import db from "./db";
export async function runMigrations() {
await db.exec(`
CREATE TABLE IF NOT EXISTS todos (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
completed BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW()
);
`);
}This is standard PostgreSQL DDL — PGlite supports the same SQL syntax you would use with a regular Postgres server. The IF NOT EXISTS clause ensures the migration is idempotent and safe to run on every app startup.
Step 4: Build the PGlite Provider
Wrap your app with a provider that initializes the database before rendering child components. Create src/PGliteProvider.tsx:
// src/PGliteProvider.tsx
import { PGliteProvider } from "@electric-sql/pglite-react";
import { useEffect, useState, type ReactNode } from "react";
import db from "./db";
import { runMigrations } from "./migrate";
export function DatabaseProvider({ children }: { children: ReactNode }) {
const [ready, setReady] = useState(false);
useEffect(() => {
runMigrations().then(() => setReady(true));
}, []);
if (!ready) {
return <div className="loading">Initializing database...</div>;
}
return <PGliteProvider db={db}>{children}</PGliteProvider>;
}Step 5: Implement the Todo List with Live Queries
Now create the main todo component in src/TodoApp.tsx. This is where PGlite truly shines — the useLiveQuery hook automatically re-renders your component whenever the underlying data changes:
// src/TodoApp.tsx
import { useLiveQuery, usePGlite } from "@electric-sql/pglite-react";
import { useState } from "react";
interface Todo {
id: number;
title: string;
completed: boolean;
created_at: string;
}
export function TodoApp() {
const db = usePGlite();
const [newTitle, setNewTitle] = useState("");
// Live query — automatically updates when data changes
const todos = useLiveQuery<Todo>(
"SELECT * FROM todos ORDER BY created_at DESC"
);
const addTodo = async () => {
if (!newTitle.trim()) return;
await db.exec("INSERT INTO todos (title) VALUES ($1)", [newTitle.trim()]);
setNewTitle("");
};
const toggleTodo = async (id: number, completed: boolean) => {
await db.exec("UPDATE todos SET completed = $1 WHERE id = $2", [
!completed,
id,
]);
};
const deleteTodo = async (id: number) => {
await db.exec("DELETE FROM todos WHERE id = $1", [id]);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") addTodo();
};
return (
<div className="todo-app">
<h1>PGlite Todo App</h1>
<p className="subtitle">
Powered by PostgreSQL running in your browser via WebAssembly
</p>
<div className="input-row">
<input
type="text"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="What needs to be done?"
/>
<button onClick={addTodo}>Add</button>
</div>
<ul className="todo-list">
{todos?.rows.map((todo) => (
<li key={todo.id} className={todo.completed ? "completed" : ""}>
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id, todo.completed)}
/>
<span>{todo.title}</span>
</label>
<button className="delete" onClick={() => deleteTodo(todo.id)}>
Remove
</button>
</li>
))}
</ul>
{todos?.rows.length === 0 && (
<p className="empty">No todos yet. Add one above!</p>
)}
</div>
);
}Notice that you never manually refresh the query results. When you call db.exec to insert, update, or delete a row, the useLiveQuery hook detects the change and re-renders the component automatically. This is similar to how real-time subscriptions work in Supabase or Firebase, but everything happens locally with zero network latency.
Step 6: Wire Up the App
Update src/App.tsx to use the database provider and todo component:
// src/App.tsx
import { DatabaseProvider } from "./PGliteProvider";
import { TodoApp } from "./TodoApp";
import "./App.css";
function App() {
return (
<DatabaseProvider>
<TodoApp />
</DatabaseProvider>
);
}
export default App;Step 7: Add Styling
Replace the contents of src/App.css with clean styling for the todo app:
/* src/App.css */
:root {
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.6;
color: #1a1a2e;
background: #f0f0f5;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-size: 1.2rem;
color: #666;
}
.todo-app {
max-width: 600px;
margin: 2rem auto;
padding: 2rem;
background: white;
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
}
h1 {
margin: 0 0 0.25rem;
font-size: 1.8rem;
}
.subtitle {
margin: 0 0 1.5rem;
color: #888;
font-size: 0.9rem;
}
.input-row {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.input-row input {
flex: 1;
padding: 0.75rem 1rem;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.2s;
}
.input-row input:focus {
outline: none;
border-color: #6c63ff;
}
.input-row button {
padding: 0.75rem 1.5rem;
background: #6c63ff;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
.input-row button:hover {
background: #5a52d5;
}
.todo-list {
list-style: none;
padding: 0;
margin: 0;
}
.todo-list li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 0;
border-bottom: 1px solid #f0f0f0;
}
.todo-list li label {
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
flex: 1;
}
.todo-list li.completed span {
text-decoration: line-through;
color: #aaa;
}
.delete {
background: none;
border: none;
color: #e74c3c;
cursor: pointer;
font-size: 0.85rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.delete:hover {
background: #ffeaea;
}
.empty {
text-align: center;
color: #999;
padding: 2rem 0;
}Step 8: Run and Test the Application
Start the development server:
npm run devOpen your browser to http://localhost:5173. You should see the todo app. Try the following:
- Add a few todos — they appear instantly in the list
- Check and uncheck todos — the UI updates in real-time
- Refresh the page — your todos persist thanks to IndexedDB storage
- Open DevTools and go to Application then IndexedDB — you can see the PGlite database files
Step 9: Advanced Queries and Features
One of PGlite's greatest strengths is full PostgreSQL compatibility. Let us add some advanced features to demonstrate this power.
Full-Text Search
Add a search bar that uses PostgreSQL's built-in tsvector full-text search:
// Add a search function to your TodoApp
const searchTodos = async (query: string) => {
if (!query.trim()) return;
const result = await db.query<Todo>(
`SELECT * FROM todos
WHERE to_tsvector('english', title) @@ plainto_tsquery('english', $1)
ORDER BY created_at DESC`,
[query]
);
return result.rows;
};Aggregate Statistics
Query statistics about your todos using standard SQL aggregations:
const stats = useLiveQuery<{
total: number;
completed: number;
pending: number;
}>(`
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE completed = true) as completed,
COUNT(*) FILTER (WHERE completed = false) as pending
FROM todos
`);JSON Operations
PGlite supports PostgreSQL's JSONB operators, enabling complex document storage:
// Store metadata as JSONB
await db.exec(`
ALTER TABLE todos ADD COLUMN IF NOT EXISTS
metadata JSONB DEFAULT '{}'::jsonb
`);
// Query with JSON operators
await db.exec(
`UPDATE todos SET metadata = metadata || $1::jsonb WHERE id = $2`,
[JSON.stringify({ priority: "high", tags: ["work"] }), todoId]
);Step 10: Using PGlite in Node.js
PGlite is not limited to the browser. You can use the same API in Node.js for CLI tools, scripts, tests, and serverless functions:
// node-example.ts
import { PGlite } from "@electric-sql/pglite";
async function main() {
// In Node.js, use a file path for persistence
const db = new PGlite("./my-local-db");
await db.exec(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE
)
`);
await db.exec(
"INSERT INTO users (name, email) VALUES ($1, $2) ON CONFLICT DO NOTHING",
["Alice", "alice@example.com"]
);
const result = await db.query("SELECT * FROM users");
console.log(result.rows);
await db.close();
}
main();Run it with:
npx tsx node-example.tsNo Docker, no docker-compose, no Postgres installation — just a single npm package.
Step 11: Testing with PGlite
PGlite is excellent for testing. Instead of mocking your database or running Docker containers in CI, use an in-memory PGlite instance:
// __tests__/todo.test.ts
import { PGlite } from "@electric-sql/pglite";
import { describe, it, expect, beforeEach } from "vitest";
describe("Todo operations", () => {
let db: PGlite;
beforeEach(async () => {
// Fresh in-memory database for each test
db = new PGlite();
await db.exec(`
CREATE TABLE todos (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
completed BOOLEAN DEFAULT false
)
`);
});
it("should insert a todo", async () => {
await db.exec("INSERT INTO todos (title) VALUES ($1)", ["Buy milk"]);
const result = await db.query("SELECT * FROM todos");
expect(result.rows).toHaveLength(1);
expect(result.rows[0].title).toBe("Buy milk");
});
it("should toggle completion", async () => {
await db.exec("INSERT INTO todos (title) VALUES ($1)", ["Exercise"]);
await db.exec("UPDATE todos SET completed = true WHERE id = 1");
const result = await db.query("SELECT completed FROM todos WHERE id = 1");
expect(result.rows[0].completed).toBe(true);
});
});This approach gives you real PostgreSQL behavior in tests — no mocks, no Docker, and tests run in milliseconds.
Troubleshooting
WASM fails to load
If the WASM binary fails to load, ensure your bundler is configured to handle .wasm files. With Vite, this works out of the box. For Webpack, you may need:
// webpack.config.js
module.exports = {
experiments: {
asyncWebAssembly: true,
},
};Data not persisting
Make sure you are using the idb:// prefix in the constructor. Without it, PGlite defaults to in-memory storage:
// Persistent
const db = new PGlite("idb://my-app");
// In-memory only (data lost on refresh)
const db = new PGlite();Large bundle size
The PGlite WASM binary is approximately 3 MB gzipped. To improve initial load time:
- Use dynamic imports to lazy-load PGlite
- Show a loading indicator while the WASM initializes
- Consider using a service worker to cache the WASM binary
SharedArrayBuffer errors
Some PGlite features require SharedArrayBuffer, which needs specific CORS headers:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
In Vite, add this plugin to your config:
// vite.config.ts
export default defineConfig({
plugins: [react()],
server: {
headers: {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
},
},
});When to Use PGlite
PGlite is an excellent choice for:
- Prototyping: Skip the database setup and start building immediately
- Local-first apps: Todo apps, note-taking, personal tools
- Browser extensions: Embedded database with full SQL power
- Testing: Replace Docker-based test databases with instant in-memory instances
- Edge functions: Run Postgres in serverless environments without external connections
- Offline apps: Progressive Web Apps that need structured data storage
Consider a traditional database server when you need:
- Multi-user access to shared data
- Databases larger than a few hundred MB
- Server-side data validation and access control
- Replication and high availability
Next Steps
Now that you have PGlite running in the browser, here are some ways to extend your knowledge:
- Add ElectricSQL sync: Combine PGlite with ElectricSQL to sync your local database with a server-side Postgres instance
- Build a PWA: Add a service worker to make your app fully offline-capable
- Use PGlite extensions: PGlite supports extensions like
pgvectorfor vector similarity search directly in the browser - Explore multi-tab support: Use PGlite's multi-tab coordination to share a database across browser tabs
Conclusion
PGlite represents a paradigm shift in how we think about databases in web applications. By compiling PostgreSQL to WebAssembly, it brings the full power of SQL — joins, indexes, full-text search, JSONB, and more — directly into the browser with zero server infrastructure.
In this tutorial, you built a complete todo application that persists data locally, reacts to changes automatically, and runs entirely client-side. You also explored advanced features like full-text search, JSON operations, and testing strategies.
The local-first movement is gaining momentum, and PGlite is at its forefront. Whether you are building a prototype, a browser extension, or an offline-first app, PGlite gives you the database power you need without the operational overhead.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Building Local-First Collaborative Apps with Yjs and React
Learn how to build real-time collaborative applications that work offline using Yjs CRDTs and React. This tutorial covers conflict-free data synchronization, offline-first architecture, and building a shared document editor from scratch.

Build a Real-Time App with Supabase and Next.js 15: Complete Guide
Learn how to build a full-stack real-time application using Supabase and Next.js 15 App Router. This guide covers authentication, database setup, Row Level Security, and real-time subscriptions.

Capacitor + React — Build Cross-Platform Mobile Apps from Your Web App (2026)
Turn your React web app into a native iOS and Android app using Capacitor. This hands-on tutorial covers project setup, native plugins, camera access, local storage, deployment to app stores, and production-ready patterns.