Skip to main content

Guide

Convex vs InstantDB vs ElectricSQL 2026

Compare Convex, InstantDB, and ElectricSQL for real-time sync databases in JavaScript. Reactive queries, offline-first, local-first, and which real-time.

·PkgPulse Team·
0

TL;DR

Convex is the reactive backend platform — real-time queries, server functions, file storage, scheduling, automatic caching, TypeScript-native, replaces your entire backend. InstantDB is the client-side database — real-time sync, relational queries in the browser, offline support, optimistic updates, like Firebase but relational. ElectricSQL is the local-first sync engine — syncs Postgres to local SQLite, CRDT-based conflict resolution, works offline, Postgres as source of truth. In 2026: Convex for full reactive backends, InstantDB for client-side relational databases, ElectricSQL for local-first Postgres sync.

Key Takeaways

  • Convex: convex ~100K weekly downloads — reactive backend, server functions, real-time queries
  • InstantDB: @instantdb/react ~20K weekly downloads — client-side relational DB, real-time sync
  • ElectricSQL: electric-sql ~15K weekly downloads — Postgres-to-SQLite sync, local-first, CRDTs
  • Convex replaces your entire backend (DB + functions + storage + scheduling)
  • InstantDB provides a Firebase-like experience with relational data
  • ElectricSQL brings Postgres data to the client with offline support

Convex

Convex — reactive backend platform:

Setup and schema

// convex/schema.ts
import { defineSchema, defineTable } from "convex/server"
import { v } from "convex/values"

export default defineSchema({
  packages: defineTable({
    name: v.string(),
    description: v.string(),
    downloads: v.number(),
    version: v.string(),
    tags: v.array(v.string()),
    updatedAt: v.number(),
  })
    .index("by_name", ["name"])
    .index("by_downloads", ["downloads"])
    .searchIndex("search_packages", {
      searchField: "name",
      filterFields: ["tags"],
    }),

  comparisons: defineTable({
    userId: v.string(),
    packages: v.array(v.string()),
    createdAt: v.number(),
  }).index("by_user", ["userId"]),
})

Server functions (queries and mutations)

// convex/packages.ts
import { query, mutation } from "./_generated/server"
import { v } from "convex/values"

// Real-time query — automatically re-runs when data changes:
export const list = query({
  args: {
    tag: v.optional(v.string()),
    limit: v.optional(v.number()),
  },
  handler: async (ctx, args) => {
    let q = ctx.db.query("packages").order("desc")

    if (args.tag) {
      // Filter by tag:
      q = ctx.db
        .query("packages")
        .withIndex("by_downloads")
        .order("desc")
    }

    const packages = await q.take(args.limit ?? 50)

    return args.tag
      ? packages.filter((p) => p.tags.includes(args.tag!))
      : packages
  },
})

// Full-text search:
export const search = query({
  args: { query: v.string() },
  handler: async (ctx, args) => {
    return ctx.db
      .query("packages")
      .withSearchIndex("search_packages", (q) => q.search("name", args.query))
      .take(20)
  },
})

// Mutation — write data:
export const update = mutation({
  args: {
    name: v.string(),
    downloads: v.number(),
    version: v.string(),
  },
  handler: async (ctx, args) => {
    const existing = await ctx.db
      .query("packages")
      .withIndex("by_name", (q) => q.eq("name", args.name))
      .first()

    if (existing) {
      await ctx.db.patch(existing._id, {
        downloads: args.downloads,
        version: args.version,
        updatedAt: Date.now(),
      })
    } else {
      await ctx.db.insert("packages", {
        name: args.name,
        description: "",
        downloads: args.downloads,
        version: args.version,
        tags: [],
        updatedAt: Date.now(),
      })
    }
  },
})

Actions and scheduling

// convex/actions.ts
import { action, internalMutation } from "./_generated/server"
import { v } from "convex/values"
import { internal } from "./_generated/api"

// Action — can call external APIs:
export const syncFromNpm = action({
  args: { packageName: v.string() },
  handler: async (ctx, args) => {
    const response = await fetch(`https://registry.npmjs.org/${args.packageName}`)
    const data = await response.json()

    // Call internal mutation to save data:
    await ctx.runMutation(internal.packages.updateInternal, {
      name: data.name,
      description: data.description,
      version: data["dist-tags"].latest,
    })
  },
})

// Scheduled jobs:
// convex/crons.ts
import { cronJobs } from "convex/server"
import { internal } from "./_generated/api"

const crons = cronJobs()

crons.daily(
  "sync-top-packages",
  { hourUTC: 6, minuteUTC: 0 },
  internal.actions.syncAllPackages
)

crons.interval(
  "update-download-counts",
  { hours: 1 },
  internal.actions.updateDownloads
)

export default crons

React integration

import { useQuery, useMutation } from "convex/react"
import { api } from "../convex/_generated/api"

function PackageList() {
  // Real-time query — re-renders when data changes:
  const packages = useQuery(api.packages.list, { limit: 20 })
  const searchResults = useQuery(api.packages.search, { query: "react" })

  if (packages === undefined) return <Loading />

  return (
    <div>
      {packages.map((pkg) => (
        <PackageCard key={pkg._id} pkg={pkg} />
      ))}
    </div>
  )
}

function CompareButton({ packages }: { packages: string[] }) {
  const saveComparison = useMutation(api.comparisons.create)

  return (
    <button onClick={() => saveComparison({ packages, userId: "user-123" })}>
      Compare
    </button>
  )
}

// Provider setup:
import { ConvexProvider, ConvexReactClient } from "convex/react"

const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!)

function App() {
  return (
    <ConvexProvider client={convex}>
      <Router />
    </ConvexProvider>
  )
}

InstantDB

InstantDB — client-side relational database:

Setup and schema

// instant.schema.ts
import { i } from "@instantdb/react"

const schema = i.schema({
  entities: {
    packages: i.entity({
      name: i.string(),
      description: i.string(),
      downloads: i.number(),
      version: i.string(),
      updatedAt: i.date(),
    }),
    tags: i.entity({
      name: i.string(),
    }),
    users: i.entity({
      email: i.string(),
      name: i.string(),
    }),
    comparisons: i.entity({
      createdAt: i.date(),
    }),
  },
  links: {
    packageTags: {
      forward: { on: "packages", has: "many", label: "tags" },
      reverse: { on: "tags", has: "many", label: "packages" },
    },
    userComparisons: {
      forward: { on: "users", has: "many", label: "comparisons" },
      reverse: { on: "comparisons", has: "one", label: "user" },
    },
    comparisonPackages: {
      forward: { on: "comparisons", has: "many", label: "packages" },
      reverse: { on: "packages", has: "many", label: "comparisons" },
    },
  },
})

export default schema

Queries and transactions

import { init, tx, id } from "@instantdb/react"
import schema from "./instant.schema"

const db = init({
  appId: process.env.NEXT_PUBLIC_INSTANT_APP_ID!,
  schema,
})

// Real-time query with relations:
function PackageList() {
  const { isLoading, error, data } = db.useQuery({
    packages: {
      tags: {},  // Include related tags
      $: {
        order: { serverCreatedAt: "desc" },
        limit: 20,
      },
    },
  })

  if (isLoading) return <Loading />
  if (error) return <Error error={error} />

  return (
    <div>
      {data.packages.map((pkg) => (
        <div key={pkg.id}>
          <h3>{pkg.name} v{pkg.version}</h3>
          <p>{pkg.downloads.toLocaleString()} downloads</p>
          <div>
            {pkg.tags.map((tag) => (
              <span key={tag.id} className="badge">{tag.name}</span>
            ))}
          </div>
        </div>
      ))}
    </div>
  )
}

// Filtered query:
function SearchResults({ query }: { query: string }) {
  const { data } = db.useQuery({
    packages: {
      tags: {},
      $: {
        where: {
          name: { $like: `%${query}%` },
        },
      },
    },
  })

  return <PackageGrid packages={data?.packages || []} />
}

Mutations and transactions

import { init, tx, id } from "@instantdb/react"
import schema from "./instant.schema"

const db = init({
  appId: process.env.NEXT_PUBLIC_INSTANT_APP_ID!,
  schema,
})

// Add a package with tags (transaction):
function AddPackage() {
  const addPackage = async (name: string, tagNames: string[]) => {
    const packageId = id()
    const tagIds = tagNames.map(() => id())

    db.transact([
      // Create package:
      tx.packages[packageId].update({
        name,
        description: "",
        downloads: 0,
        version: "1.0.0",
        updatedAt: new Date().toISOString(),
      }),
      // Create tags and link them:
      ...tagNames.flatMap((tagName, i) => [
        tx.tags[tagIds[i]].update({ name: tagName }),
        tx.packageTags[id()].link({
          packages: packageId,
          tags: tagIds[i],
        }),
      ]),
    ])
  }

  return (
    <button onClick={() => addPackage("react", ["frontend", "ui"])}>
      Add React
    </button>
  )
}

// Update package:
function UpdateDownloads({ packageId, downloads }: { packageId: string; downloads: number }) {
  return (
    <button
      onClick={() => {
        db.transact([
          tx.packages[packageId].update({
            downloads,
            updatedAt: new Date().toISOString(),
          }),
        ])
      }}
    >
      Update Downloads
    </button>
  )
}

// Delete with cascade:
function DeletePackage({ packageId }: { packageId: string }) {
  return (
    <button
      onClick={() => {
        db.transact([tx.packages[packageId].delete()])
        // Links are automatically cleaned up
      }}
    >
      Delete
    </button>
  )
}

Authentication

import { init } from "@instantdb/react"
import schema from "./instant.schema"

const db = init({
  appId: process.env.NEXT_PUBLIC_INSTANT_APP_ID!,
  schema,
})

function Auth() {
  const { isLoading, user, error } = db.useAuth()

  if (isLoading) return <Loading />

  if (user) {
    return <Dashboard user={user} />
  }

  return <LoginForm />
}

function LoginForm() {
  const [email, setEmail] = useState("")
  const [code, setCode] = useState("")
  const [sentEmail, setSentEmail] = useState(false)

  const handleSendCode = async () => {
    await db.auth.sendMagicCode({ email })
    setSentEmail(true)
  }

  const handleVerify = async () => {
    await db.auth.signInWithMagicCode({ email, code })
  }

  if (!sentEmail) {
    return (
      <form onSubmit={(e) => { e.preventDefault(); handleSendCode() }}>
        <input value={email} onChange={(e) => setEmail(e.target.value)} />
        <button type="submit">Send Code</button>
      </form>
    )
  }

  return (
    <form onSubmit={(e) => { e.preventDefault(); handleVerify() }}>
      <input value={code} onChange={(e) => setCode(e.target.value)} />
      <button type="submit">Verify</button>
    </form>
  )
}

ElectricSQL

ElectricSQL — local-first Postgres sync:

Setup

// electric-config.ts
import { electrify } from "electric-sql/wa-sqlite"
import { schema } from "./generated/client"

// Initialize local SQLite database with Electric sync:
const config = {
  url: process.env.ELECTRIC_SERVICE_URL!,
  debug: process.env.NODE_ENV === "development",
}

const conn = await ElectricDatabase.init("pkgpulse.db")
const electric = await electrify(conn, schema, config)

export { electric }

Define shapes (sync rules)

import { electric } from "./electric-config"

// Subscribe to sync specific data to the client:
const { synced } = await electric.db.packages.sync({
  // Shape: which rows to sync
  where: {
    active: true,
  },
  include: {
    tags: true,         // Include related tags
    versions: {
      where: {
        published: true,
      },
    },
  },
})

// Wait for initial sync to complete:
await synced

// Now query locally — instant, no network:
const packages = await electric.db.packages.findMany({
  orderBy: { downloads: "desc" },
  take: 20,
  include: { tags: true },
})

Local queries (offline-capable)

import { electric } from "./electric-config"

// All queries run against local SQLite — instant, works offline:

// Find packages:
const topPackages = await electric.db.packages.findMany({
  where: {
    downloads: { gte: 1000000 },
  },
  orderBy: { downloads: "desc" },
  take: 50,
})

// Full-text search (local):
const results = await electric.db.rawQuery({
  sql: `SELECT * FROM packages WHERE name LIKE ? ORDER BY downloads DESC LIMIT 20`,
  args: [`%react%`],
})

// Aggregations (local):
const stats = await electric.db.rawQuery({
  sql: `SELECT COUNT(*) as total, AVG(downloads) as avg_downloads FROM packages WHERE active = 1`,
})

// Insert (writes locally, syncs to Postgres):
await electric.db.packages.create({
  data: {
    id: crypto.randomUUID(),
    name: "new-package",
    description: "A new package",
    downloads: 0,
    version: "1.0.0",
    active: true,
    updatedAt: new Date(),
  },
})
// → Written to local SQLite immediately
// → Synced to Postgres in background
// → CRDT-based conflict resolution if concurrent edits

React integration

import { useLiveQuery } from "electric-sql/react"
import { electric } from "./electric-config"

function PackageList() {
  // Live query — re-renders when local data changes (including from sync):
  const { results: packages } = useLiveQuery(
    electric.db.packages.liveMany({
      orderBy: { downloads: "desc" },
      take: 20,
      include: { tags: true },
    })
  )

  return (
    <div>
      {packages?.map((pkg) => (
        <div key={pkg.id}>
          <h3>{pkg.name} v{pkg.version}</h3>
          <p>{pkg.downloads.toLocaleString()} downloads</p>
          <div>
            {pkg.tags.map((tag) => (
              <span key={tag.id} className="badge">{tag.name}</span>
            ))}
          </div>
        </div>
      ))}
    </div>
  )
}

// Connectivity status:
import { useConnectivityState } from "electric-sql/react"

function SyncStatus() {
  const connectivityState = useConnectivityState(electric)

  return (
    <div className={`status ${connectivityState}`}>
      {connectivityState === "connected" ? "🟢 Synced" : "🔴 Offline"}
    </div>
  )
}

// Provider:
import { ElectricProvider } from "electric-sql/react"

function App() {
  return (
    <ElectricProvider db={electric}>
      <Router />
    </ElectricProvider>
  )
}

Postgres schema and migrations

-- Postgres schema (source of truth):
CREATE TABLE packages (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL UNIQUE,
  description TEXT DEFAULT '',
  downloads INTEGER DEFAULT 0,
  version TEXT NOT NULL,
  active BOOLEAN DEFAULT true,
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE tags (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL UNIQUE
);

CREATE TABLE package_tags (
  package_id UUID REFERENCES packages(id) ON DELETE CASCADE,
  tag_id UUID REFERENCES tags(id) ON DELETE CASCADE,
  PRIMARY KEY (package_id, tag_id)
);

-- Enable Electric sync on tables:
ALTER TABLE packages ENABLE ELECTRIC;
ALTER TABLE tags ENABLE ELECTRIC;
ALTER TABLE package_tags ENABLE ELECTRIC;

Feature Comparison

FeatureConvexInstantDBElectricSQL
ArchitectureCloud backendClient-side DB + cloudPostgres → local SQLite sync
Real-time queries✅ (reactive)✅ (reactive)✅ (live queries)
Offline support✅ (optimistic)✅ (full offline)
Local-firstPartial✅ (SQLite)
Relational data✅ (document)✅ (links)✅ (Postgres)
Conflict resolutionServer winsServer authorityCRDTs
Server functions✅ (queries, mutations, actions)✅ (permissions)❌ (use Postgres)
Full-text search✅ (built-in)✅ (basic)✅ (SQLite FTS)
File storage✅ (built-in)
Scheduling/crons✅ (built-in)
Authentication✅ (Clerk, Auth0)✅ (magic code, Google)❌ (BYO)
TypeScript✅ (end-to-end)✅ (end-to-end)✅ (generated)
React hooks
Self-hosted
Underlying DBCustomCustomPostgres
Free tier1M function calls/mo10K monthly rowsOpen-source
PricingUsage-basedRow-basedSelf-hosted free

When to Use Each

Use Convex if:

  • Want a complete reactive backend (DB + functions + storage + scheduling)
  • Building real-time collaborative apps with TypeScript
  • Need server-side logic with automatic caching and reactivity
  • Want to replace your entire backend stack with one platform

Use InstantDB if:

  • Want a Firebase-like experience with relational data
  • Building apps where client-side queries with real-time sync is the priority
  • Need simple authentication and permissions built-in
  • Want optimistic updates and fast client-side operations

Use ElectricSQL if:

  • Need true offline-first / local-first architecture
  • Want to use Postgres as your source of truth with client-side sync
  • Building apps that must work without internet (field apps, mobile)
  • Need CRDT-based conflict resolution for concurrent edits

Self-Hosting and Vendor Lock-in Considerations

The three platforms have fundamentally different self-hosting stories. Convex is a fully managed cloud service with no self-hosting option — your data, server functions, and scheduling all run on Convex's infrastructure. This simplifies operations but creates strong vendor dependency: migrating away from Convex requires rewriting server functions, exporting data (Convex provides export tools), and rebuilding the reactive query infrastructure elsewhere. InstantDB is also a managed service without a supported self-hosting path as of 2026, though being a smaller company means the migration risk profile is different. ElectricSQL is the open-source standout — the sync layer (Electric) is MIT-licensed and self-hostable on any Postgres-compatible infrastructure, and the client-side data is standard SQLite. Teams with data residency requirements or regulatory constraints around cloud data processing should heavily weight ElectricSQL's self-hosting capability.

TypeScript End-to-End Type Safety

All three platforms provide TypeScript-native experiences, but with different approaches to schema-code synchronization. Convex's type system derives TypeScript types directly from your schema definition — the v.string(), v.number() validators generate corresponding TypeScript types, and calling api.packages.list from the client is fully typed with the exact return shape. InstantDB requires defining your schema separately from your application code, but the generated types ensure queries and mutations are type-checked against the defined schema. ElectricSQL generates a TypeScript client from your Postgres schema using electric-client generate, producing Prisma-like typed query functions. The key practical difference: Convex type changes propagate immediately through the development workflow (save the schema, TypeScript errors appear), while ElectricSQL requires running the code generation step after schema migrations.

Conflict Resolution and Offline Data Integrity

The approaches to conflict resolution reveal the philosophical differences between these platforms. Convex uses a single source of truth in the cloud — all mutations go through server-side transaction functions that run atomically, so conflicts simply cannot occur. This is clean and predictable but requires connectivity: you cannot write data offline. InstantDB's optimistic updates write locally first and sync to the server, with server authority winning on conflicts — a pattern familiar from Firebase. ElectricSQL's CRDT-based approach (Conflict-free Replicated Data Types) allows multiple clients to write concurrently without coordination and merges the results correctly when they sync. CRDTs are powerful for collaborative editing scenarios but add complexity: not all data models express naturally as CRDTs, and debugging CRDT merge behavior requires understanding the specific CRDT type used for each column.

Performance Characteristics and Query Optimization

Convex's reactive query system automatically optimizes subscriptions — if two components subscribe to the same query, Convex deduplicates the subscription and shares the result. Convex's server-side query functions execute with read-committed isolation and can use indexes defined in the schema for efficient lookups. For queries with complex filtering, Convex's document model is more constrained than full SQL — there is no JOIN operation, so data that would be joined in SQL must be fetched and merged in server-side query functions. InstantDB runs queries client-side after syncing data locally, so complex filtering operations execute on the client without additional network round-trips — this is fast for read-heavy dashboards but means more data is transferred to the client than strictly necessary. ElectricSQL's local SQLite enables the full SQLite query engine, including JOINs, window functions, and full-text search, all executing locally at SQLite speeds.

Pricing Models and Scale Economics

Understanding the cost structures helps teams predict infrastructure costs at different scales. Convex's free tier includes 1M function calls and 64MB storage per month, which covers most side projects. Beyond the free tier, pricing scales with function call volume — for applications with heavy reactive query subscriptions where many clients receive frequent updates, function call costs accumulate faster than for traditional request-response APIs. InstantDB's row-based pricing model scales with data volume — heavy relational data with many related rows across complex link schemas can become expensive at scale. ElectricSQL's self-hosted deployment on your existing Postgres infrastructure means you pay only the compute and storage costs you're already paying for your database, with no per-request or per-row markup from Electric itself. For teams already running Postgres at scale, ElectricSQL's economics are unambiguously advantageous over managed alternatives.

Migration Paths and Exit Strategies

Evaluating real-time sync platforms requires thinking about exit strategies before committing. Convex's proprietary query language and server function model create the deepest lock-in — migrating away means rewriting both the data access layer and the reactive subscription logic. However, Convex provides data export functionality and the underlying data model is document-oriented, making export to MongoDB or PostgreSQL feasible. InstantDB's data is more portable since its relational model maps to standard SQL tables; the main migration effort is rebuilding the real-time subscription infrastructure with a different tool. ElectricSQL has the clearest exit path since Postgres is the source of truth — you can stop using Electric's sync layer and keep your data unchanged in Postgres, then rebuild client data access with any other approach. For long-lived production applications, the self-hosted-Postgres exit strategy of ElectricSQL is a meaningful architectural advantage.

Compare database and developer tooling on PkgPulse →

See also: AVA vs Jest and How to Set Up Drizzle ORM with Next.js (2026 Guide), acorn vs @babel/parser vs espree.

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.