Skip to main content

Guide

PGlite vs Electric SQL vs Triplit 2026

PGlite vs Electric SQL vs Triplit for local-first development. Compare offline sync, conflict resolution, reactivity, TypeScript, and real-time capabilities.

·PkgPulse Team·
0

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

FeaturePGliteElectric SQLTriplit
Query languageFull SQL (PostgreSQL)SQL (server), Shapes (client)Custom TS API
TypeScript typesVia Drizzle/PrismaManual typingNative (schema-first)
Offline support✅ (WASM, IndexedDB)✅ (client cache)✅ (IndexedDB)
Real-time syncVia Live queries (local)✅ Server → client streaming✅ Bidirectional
Conflict resolutionManualCRDT (server-side)CRDT (last-write wins)
Write pathLocal only (bring your own sync)Via your API → PostgresTriplit client → Triplit server
Existing Postgres✅ Compatible✅ Required
Server required❌ (optional sync)✅ Electric service✅ Triplit server
Permissions / AuthZVia Postgres RLSVia server filters✅ Native (schema-level)
React hooksuseLiveQueryuseShapeuseQuery, 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

Conflict Resolution and Offline Write Semantics

The hardest problem in local-first databases is not reading data offline — it's writing data offline and merging those writes when connectivity returns. Each of these three tools takes a different approach. PGlite runs a full PostgreSQL engine locally and does not include a sync protocol; you are responsible for designing your own conflict resolution when writes from an offline client meet writes from other clients or the server. The common pattern is to add updated_at timestamps and last-write-wins semantics at the application layer, or to use vector clocks if you need finer-grained conflict detection. This flexibility is PGlite's strength and also its limitation — you must implement what Electric SQL and Triplit provide out of the box.

Electric SQL uses a log-based CRDT approach where the server's Postgres logical replication log is the source of truth. Writes happen through your existing API (a standard REST or GraphQL endpoint), not directly to the client-side cache. Electric then streams the committed server state back to clients. This means Electric avoids the classic offline write conflict problem by design — clients don't write to their local cache directly; they write to the server via your API, and the sync layer propagates confirmed writes back. For offline scenarios where users make changes without connectivity, you must implement an optimistic update layer and reconcile it when the API write succeeds. This is more work than Triplit but keeps your server as the authoritative state.

Triplit's conflict resolution is last-write-wins at the attribute level using hybrid logical clocks. When two clients both modify the same entity attribute offline, the write with the higher timestamp wins when they sync. Triplit's transaction API (client.transact()) provides atomic local updates that are queued for server sync, and the schema-level permissions system ensures that conflicting writes from unauthorized clients are rejected at sync time. For truly collaborative applications where two users might edit the same document paragraph simultaneously, last-write-wins is insufficient — teams building Google Docs-style real-time collaboration should evaluate Yjs or Automerge for the CRDT layer alongside one of these databases.

Bundle Size and WebAssembly Loading

PGlite's WebAssembly bundle deserves special attention in browser deployment contexts. The @electric-sql/pglite package includes a ~3.5 MB gzipped WASM binary that must be loaded before any database operations can begin. Modern browsers cache WASM modules aggressively after the first load, so repeat visits are fast, but the initial load adds a meaningful Time to Interactive delay on slower connections. For applications where the database is not needed on the initial page view (for example, a note-taking app where the user first sees a login screen), lazy loading PGlite with a dynamic import() after authentication minimizes the initial bundle impact. The WASM initialization time — parsing and compiling the PostgreSQL module — adds another 100–200ms even after the binary is cached, which should be accounted for in startup latency budgets for offline-capable applications.

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.

See also: Lit vs Svelte and How to Set Up Drizzle ORM with Next.js (2026 Guide)

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.