Convex vs InstantDB vs ElectricSQL: Real-Time Sync Databases (2026)
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
| Feature | Convex | InstantDB | ElectricSQL |
|---|---|---|---|
| Architecture | Cloud backend | Client-side DB + cloud | Postgres → local SQLite sync |
| Real-time queries | ✅ (reactive) | ✅ (reactive) | ✅ (live queries) |
| Offline support | ❌ | ✅ (optimistic) | ✅ (full offline) |
| Local-first | ❌ | Partial | ✅ (SQLite) |
| Relational data | ✅ (document) | ✅ (links) | ✅ (Postgres) |
| Conflict resolution | Server wins | Server authority | CRDTs |
| 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 DB | Custom | Custom | Postgres |
| Free tier | 1M function calls/mo | 10K monthly rows | Open-source |
| Pricing | Usage-based | Row-based | Self-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.