Skip to main content

PGlite vs Electric SQL vs Triplit: Local-First JavaScript Databases 2026

·PkgPulse Team

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

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.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.