PGlite vs Electric SQL vs Triplit: Local-First JavaScript Databases 2026
PGlite vs Electric SQL vs Triplit: Local-First JavaScript Databases 2026
TL;DR
PGlite brings the full PostgreSQL engine into your browser or Node.js process via WebAssembly — it's a literal Postgres instance running locally, making it ideal for developers who want SQL familiarity without a server. Electric SQL is a sync layer on top of your existing Postgres that streams changes to clients in real-time, solving the hard distributed sync problem. Triplit is an opinionated full-stack database with built-in sync, reactivity, and a TypeScript schema — the fastest way to build collaborative apps without writing sync code at all.
Key Takeaways
- PGlite runs full PostgreSQL in WebAssembly — WASM bundle is ~3.5 MB gzipped, startup under 200ms in modern browsers
- Electric SQL uses Postgres as the source of truth with a sync engine that streams CRDT-resolved changes to clients — no new database to learn
- Triplit ships with React/Vue/Svelte bindings and handles conflict resolution, permissions, and offline queuing out of the box
- PGlite GitHub stars: ~10k — fast growth driven by the "Postgres everywhere" trend
- Electric SQL GitHub stars: ~8k — strong traction in the local-first community
- Triplit GitHub stars: ~3k — younger project, rapidly maturing
- All three are in active development — local-first is one of the fastest-moving segments of the JS ecosystem in 2026
The Local-First Problem
Traditional web apps store data on a server and the client is a view. This breaks when:
- The user is offline (or on a flaky connection)
- Latency makes the UI feel sluggish waiting for round trips
- Multiple users edit the same data simultaneously and the last write wins
Local-first databases keep data on the device and sync to the server when possible. The hard problems are: conflict resolution, sync protocols, and offline queuing. The three tools here solve this with different tradeoffs.
PGlite: PostgreSQL in WebAssembly
PGlite, built by ElectricSQL (the company, not to be confused with the sync product below), takes the actual PostgreSQL C source code and compiles it to WebAssembly. It runs inside your browser tab, Worker, or Node.js process with full ACID compliance.
Browser Installation and Setup
npm install @electric-sql/pglite
import { PGlite } from "@electric-sql/pglite";
import { live } from "@electric-sql/pglite/live";
// In-memory database (data lost on refresh)
const db = new PGlite();
// Persistent database backed by IndexedDB (survives refresh)
const db = new PGlite("idb://my-app-db");
// With OPFS (Origin Private File System — faster, larger storage)
const db = new PGlite("opfs://my-app-db");
// Enable extensions
const db = new PGlite({
dataDir: "idb://my-app-db",
extensions: { live }, // Live queries extension
});
Full SQL — Including Complex Queries
// Schema setup — any valid PostgreSQL DDL
await db.exec(`
CREATE TABLE IF NOT EXISTS posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
content TEXT,
author_id UUID NOT NULL,
tags TEXT[] DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS posts_author_idx ON posts(author_id);
CREATE INDEX IF NOT EXISTS posts_created_idx ON posts(created_at DESC);
`);
// Parameterized queries — prevents SQL injection
const { rows } = await db.query<{ id: string; title: string; tags: string[] }>(
`SELECT id, title, tags FROM posts
WHERE author_id = $1
AND 'typescript' = ANY(tags)
ORDER BY created_at DESC
LIMIT $2`,
[userId, 20]
);
// Complex joins — full PostgreSQL power
const { rows: feed } = await db.query(`
SELECT
p.id,
p.title,
p.content,
u.name AS author_name,
COUNT(c.id) AS comment_count,
ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS tags
FROM posts p
JOIN users u ON u.id = p.author_id
LEFT JOIN comments c ON c.post_id = p.id
LEFT JOIN post_tags pt ON pt.post_id = p.id
LEFT JOIN tags t ON t.id = pt.tag_id
WHERE p.created_at > NOW() - INTERVAL '30 days'
GROUP BY p.id, p.title, p.content, u.name
ORDER BY p.created_at DESC
`);
Live Queries (Reactive Subscriptions)
import { PGlite } from "@electric-sql/pglite";
import { live } from "@electric-sql/pglite/live";
const db = new PGlite({ dataDir: "idb://app", extensions: { live } });
// Live query — re-runs automatically when underlying data changes
const liveResult = await db.live.query<{ id: string; title: string }>(
`SELECT id, title FROM posts ORDER BY created_at DESC LIMIT 50`
);
// Subscribe to changes
const unsub = liveResult.subscribe((result) => {
console.log("Posts updated:", result.rows);
renderPosts(result.rows);
});
// React hook integration
import { useLiveQuery } from "@electric-sql/pglite-react";
function PostList({ userId }: { userId: string }) {
const result = useLiveQuery(
`SELECT * FROM posts WHERE author_id = $1 ORDER BY created_at DESC`,
[userId]
);
if (!result) return <div>Loading...</div>;
return <ul>{result.rows.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}
PGlite with Drizzle ORM
import { PGlite } from "@electric-sql/pglite";
import { drizzle } from "drizzle-orm/pglite";
import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core";
import { eq, desc } from "drizzle-orm";
const client = new PGlite("idb://drizzle-app");
const db = drizzle(client);
const posts = pgTable("posts", {
id: uuid("id").defaultRandom().primaryKey(),
title: text("title").notNull(),
content: text("content"),
createdAt: timestamp("created_at").defaultNow(),
});
// Type-safe queries with Drizzle
const recentPosts = await db
.select()
.from(posts)
.orderBy(desc(posts.createdAt))
.limit(10);
Electric SQL: Postgres Sync for Real Apps
Electric SQL (the sync product) is different from PGlite — it keeps your existing Postgres as the backend and syncs a subset of data to clients using a log-based CRDT protocol. Your server runs Postgres normally; Electric acts as a sync engine between server and clients.
Architecture Overview
PostgreSQL (server) ←→ Electric Sync Service ←→ Client (IndexedDB / SQLite)
↑
Handles conflict resolution,
partial sync, permissions
Setup: Electric Sync Service
# docker-compose.yml
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: electric
POSTGRES_PASSWORD: electric
POSTGRES_DB: electric
command: postgres -c wal_level=logical # Required for Electric
electric:
image: electricsql/electric:latest
environment:
DATABASE_URL: postgresql://electric:electric@postgres:5432/electric
AUTH_MODE: insecure # Use JWT in production
HTTP_API_PORT: 3000
ports:
- "3000:3000"
depends_on:
- postgres
Client-Side Shape Subscription
import { ShapeStream, Shape } from "@electric-sql/client";
// Shape = a live sync of a subset of server-side Postgres data
const stream = new ShapeStream({
url: "http://localhost:3000/v1/shape",
params: {
table: "posts",
where: `author_id = '${userId}'`, // Server-side filter
},
});
const shape = new Shape(stream);
// Subscribe to live changes
shape.subscribe(({ rows }) => {
console.log("Current posts:", rows);
});
// One-time snapshot
const { rows } = await shape.value;
React Integration
import { useShape } from "@electric-sql/react";
function PostList({ userId }: { userId: string }) {
const { data: posts, isLoading } = useShape({
url: "http://localhost:3000/v1/shape",
params: {
table: "posts",
where: `author_id = '${userId}'`,
},
});
if (isLoading) return <Spinner />;
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
{post.title}
{/* Mutations go directly to Postgres via your API */}
<button onClick={() => updatePost(post.id, { title: "New Title" })}>
Edit
</button>
</li>
))}
</ul>
);
}
// Mutations — Electric doesn't handle writes, your API does
async function updatePost(id: string, updates: Partial<Post>) {
await fetch(`/api/posts/${id}`, {
method: "PATCH",
body: JSON.stringify(updates),
});
// Electric automatically propagates the change back to all clients
}
Partial Sync and Permissions
// Electric supports row-level sync based on user context
// Pass JWT claims to filter what data syncs to each client
const stream = new ShapeStream({
url: `${ELECTRIC_URL}/v1/shape`,
params: {
table: "documents",
where: `workspace_id IN (SELECT workspace_id FROM members WHERE user_id = '${userId}')`,
},
headers: {
Authorization: `Bearer ${userJWT}`,
},
});
Triplit: Full-Stack Database with Built-In Sync
Triplit is an opinionated client-server database designed from the ground up for collaborative apps. It handles the full stack: schema definition, client-side storage, server sync, conflict resolution, and React/Vue/Svelte reactivity.
Installation
npm install @triplit/client
npm install @triplit/react # or @triplit/vue, @triplit/svelte
# For the sync server
npm install @triplit/server
Schema Definition
import { Schema as S } from "@triplit/client";
// Define schema with TypeScript — fully typed
export const schema = {
posts: {
schema: S.Schema({
id: S.Id(),
title: S.String(),
content: S.String({ nullable: true }),
authorId: S.String(),
tags: S.Set(S.String()),
published: S.Boolean({ default: false }),
createdAt: S.Date({ default: S.Default.now() }),
}),
permissions: {
// Row-level permissions
read: {
filter: [["published", "=", true]],
},
insert: {
filter: [["authorId", "=", "$session.userId"]],
},
update: {
filter: [["authorId", "=", "$session.userId"]],
},
},
},
users: {
schema: S.Schema({
id: S.Id(),
name: S.String(),
email: S.String(),
avatarUrl: S.String({ nullable: true }),
}),
},
};
export type Schema = typeof schema;
Client Setup
import { TriplitClient } from "@triplit/client";
import { schema } from "./schema";
export const client = new TriplitClient({
schema,
serverUrl: process.env.TRIPLIT_SERVER_URL, // Your sync server
token: userJWT, // JWT with user claims
storage: "indexeddb", // Persists offline
// storage: "memory" for server-side / testing
});
Queries and Mutations
// Queries — fully typed based on schema
const publishedQuery = client.query("posts")
.where("published", "=", true)
.order("createdAt", "DESC")
.limit(20)
.build();
// One-time fetch
const { results: posts } = await client.fetch(publishedQuery);
// Insert — automatically synced to server
const postId = await client.insert("posts", {
title: "Hello Local-First",
content: "This persists offline and syncs when online",
authorId: currentUserId,
tags: new Set(["local-first", "triplit"]),
published: false,
});
// Update with optimistic UI — updates locally immediately
await client.update("posts", postId, (post) => {
post.title = "Updated Title";
post.published = true;
});
// Transactional updates
await client.transact(async (tx) => {
await tx.update("posts", postId, (p) => { p.published = true; });
await tx.insert("activity_log", {
action: "published",
postId,
userId: currentUserId,
});
});
// Delete
await client.delete("posts", postId);
React Bindings — Live Reactive Queries
import { useQuery, useEntity } from "@triplit/react";
import { client } from "./triplit-client";
import { schema } from "./schema";
function PostList() {
// Automatically re-renders when matching posts change
const { results: posts, fetching } = useQuery(
client,
client.query("posts")
.where("published", "=", true)
.order("createdAt", "DESC")
.limit(20)
.build()
);
if (fetching) return <Skeleton />;
return (
<ul>
{[...posts.values()].map((post) => (
<PostCard key={post.id} post={post} />
))}
</ul>
);
}
function PostCard({ post }: { post: any }) {
// Subscribe to a single entity
const { result: author } = useEntity(client, "users", post.authorId);
return (
<li>
<h2>{post.title}</h2>
<p>By: {author?.name}</p>
</li>
);
}
Triplit Sync Server
// server.ts — minimal sync server
import { createServer } from "@triplit/server";
import { schema } from "./schema";
const server = createServer({
schema,
storage: "sqlite", // or "postgres" for production
dbPath: "./triplit.db",
jwtSecret: process.env.JWT_SECRET!,
port: 8787,
});
server.listen(() => {
console.log("Triplit sync server running on :8787");
});
Feature Comparison
| Feature | PGlite | Electric SQL | Triplit |
|---|---|---|---|
| Query language | Full SQL (PostgreSQL) | SQL (server), Shapes (client) | Custom TS API |
| TypeScript types | Via Drizzle/Prisma | Manual typing | Native (schema-first) |
| Offline support | ✅ (WASM, IndexedDB) | ✅ (client cache) | ✅ (IndexedDB) |
| Real-time sync | Via Live queries (local) | ✅ Server → client streaming | ✅ Bidirectional |
| Conflict resolution | Manual | CRDT (server-side) | CRDT (last-write wins) |
| Write path | Local only (bring your own sync) | Via your API → Postgres | Triplit client → Triplit server |
| Existing Postgres | ✅ Compatible | ✅ Required | ❌ |
| Server required | ❌ (optional sync) | ✅ Electric service | ✅ Triplit server |
| Permissions / AuthZ | Via Postgres RLS | Via server filters | ✅ Native (schema-level) |
| React hooks | ✅ useLiveQuery | ✅ useShape | ✅ useQuery, useEntity |
| Bundle size (client) | ~3.5 MB (WASM) | ~30 KB | ~120 KB |
| Self-hosted | ✅ | ✅ | ✅ |
| Managed cloud | ❌ | ❌ | ✅ (Triplit Cloud) |
When to Use Each
Choose PGlite if:
- You want full PostgreSQL in the browser — complex joins, window functions, full SQL power
- You're building an offline-first app that doesn't need server sync (local tool, dev utility)
- You use Drizzle or another SQL ORM and want the same interface locally
- You're prototyping and want a real database in-browser without a backend
Choose Electric SQL if:
- You already have a Postgres backend and want to add real-time sync to your frontend
- You don't want to change your data model — Electric sits on top of existing tables
- You're building a read-heavy app where data flows mostly server → client
- You want PostgreSQL on the server with live sync, not a new database abstraction
Choose Triplit if:
- You're building a collaborative app (notes, docs, project management) from scratch
- You want the least code for offline + sync + reactivity — Triplit handles everything
- TypeScript-first schema definition and type-safe queries matter to your team
- You want built-in permissions without writing custom RLS policies
The Local-First Ecosystem in 2026
Local-first is finally going mainstream. The trend started with tools like CRDTs (CRDT.tech), Yjs, and Automerge, but those required too much low-level thinking. PGlite, Electric SQL, and Triplit represent the next generation: high-level abstractions that make local-first accessible to any JavaScript developer.
Watch this space — libraries like Evolu, Liveblocks Storage, and Replicache are also gaining traction. The common thread: the browser is becoming a legitimate database runtime, not just a view layer.
Methodology
Data sourced from official documentation, GitHub repositories (stars as of February 2026), and community discussions on the ElectricSQL Discord and local-first.fm Slack. Bundle sizes measured with build tools in production mode. Performance characteristics from library documentation and community benchmarks.
Related: SurrealDB vs EdgeDB vs ArangoDB for multi-model databases, or Turso vs PlanetScale vs Neon for serverless SQL hosting.