PGlite: Run PostgreSQL in the Browser with WebAssembly

Noqta Team
By Noqta Team ·

Loading the Text to Speech Audio Player...

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-todo

Install the core dependencies:

npm install @electric-sql/pglite
npm install @electric-sql/pglite-react

The @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 dev

Open your browser to http://localhost:5173. You should see the todo app. Try the following:

  1. Add a few todos — they appear instantly in the list
  2. Check and uncheck todos — the UI updates in real-time
  3. Refresh the page — your todos persist thanks to IndexedDB storage
  4. 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.

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.ts

No 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 pgvector for 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.


Want to read more tutorials? Check out our latest tutorial on Translating Audio Content Using GPT-4o: A Step-by-Step Guide.

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.

30 min read·