Skip to main content

Convex vs InstantDB vs ElectricSQL: Real-Time Sync Databases (2026)

·PkgPulse Team

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

Methodology

Download data from npm registry (weekly average, February 2026). Feature comparison based on convex v1.x, @instantdb/react v0.x, and electric-sql v0.x.

Compare database and developer tooling on PkgPulse →

Comments

Stay Updated

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