Welcome! This is the primary guide for triplit-tanstackdb
, a seamless, real-time collection for TanStack DB powered by the Triplit sync engine. This library provides the bridge to connect Triplit's real-time, offline-first power with the unified, reactive query engine of TanStack DB.
This document covers the core concepts, the problems this library solves, and provides a quick start guide to get you up and running with your first real-time collection.
For a full blog post introducing the concepts, and why it's beneficial to use Triplit with TanStack DB, Click Here
- What is This and Why Do I Need It?
- The Solution: A Unified Data Fabric
- Features
- Understanding the Stack: A Mental Model
- Installation & Setup
- Quick Start: Your First Real-Time Collection
- Next Steps
- Architectural Guide: Choosing Your Data Layer Strategy
- Advanced Patterns & Recipes
- API Reference
- Frequently Asked Questions (FAQ)
- Contributing
- Architectural Guide: TanStack DB, TanStack Query, and Triplit
In modern application development, we often have our traditional, transactional APIs (think REST or GraphQL), but we are increasingly moving towards a sync-engine and local-first paradigm. For this, we use tools like Triplit to power instant, offline-first data synchronization and simplify collaborative features. For our traditional APIs, TanStack Query remains the gold standard for fetching and caching server state.
However, going all-in on a single data paradigm is often infeasible for production apps, which leads to a hybrid architecture. This creates a new challenge: how do you cleanly integrate and query data from these two different worlds within your UI components?
Without a unifying layer, your component code is forced to manage two completely different data-fetching lifecycles. This leads to a tangled mess of multiple data hooks, separate loading and error states, and manual logic to stitch the data together.
Here’s what that pain looks like in practice. Notice the boilerplate required to display a task (from Triplit) with its assignee's name (from a REST API).
// The old, fragmented way:
import { useTriplitQuery } from '@triplit/react'; // Triplit's native hook
import { useQuery } from '@tanstack/react-query'; // TanStack Query's hook
function TaskCard({ taskId, userId }) {
// Hook #1: Get real-time data from Triplit's sync engine
const { results: task, fetching: isLoadingTask } = useTriplitQuery(
client.query('tasks').Id(taskId)
);
// Hook #2: Get user data from a traditional API via TanStack Query
const { data: user, isLoading: isLoadingUser } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json())
});
// Now, you must manually manage and combine their states...
if (isLoadingTask || isLoadingUser) {
return <TaskSkeleton />;
}
return (
<div>
<p>{task[0]?.title}</p>
{/* The "join" happens here, but it's implicit and brittle. */}
<p>Assignee: {user.name}</p>
</div>
);
}
This pattern creates components that are difficult to test, reuse, and reason about.
This is where our third tool comes in: TanStack DB. It's a powerful, reactive, client-side database that provides a unified query layer over any data source you give it.
This library, triplit-tanstackdb
, is the specialized adapter that teaches TanStack DB how to speak "Triplit." It allows you to pipe your real-time Triplit data into a TanStack DB Collection
. When combined with a collection for your REST API (powered by TanStack Query), you can seamlessly integrate your sync engine with your existing API.
The result is a unified query and mutation API for your entire application—almost like sewing together a Federated GraphQL graph, but for each client.
Here is the exact same component, now consuming data from a single, unified source: TanStack DB.
// The new, unified way:
import { useLiveQuery } from '@tanstack/react-db'; // The unified hook from TanStack DB
import { q, eq } from '@tanstack/db';
// Assume these collections are defined elsewhere, each powered by a different backend.
import { tasksCollection } from '../collections/tasks.collection'; // From Triplit
import { usersCollection } from '../collections/users.collection'; // From a REST API
function TaskCard({ taskId }) {
// A single, declarative query that joins data across both backends.
const { data, isLoading } = useLiveQuery(() =>
q.from({ task: tasksCollection, user: usersCollection })
.where(({ task }) => eq(task.id, taskId))
.join(({ task, user }) => eq(task.assigneeId, user.id))
.select(({ task, user }) => ({
...task,
assigneeName: user.name, // Shape data exactly as needed
}))
.first()
);
// A single, unified loading state.
if (isLoading) {
return <TaskSkeleton />;
}
return (
<div>
<p>{data.title}</p>
<p>Assignee: {data.assigneeName}</p>
</div>
);
}
The component is now simpler, more declarative, and its own API is cleaner (it only needs taskId
). It will reactively update if the task data changes in real-time or if the user data is updated from a background API fetch, all managed seamlessly through one unified hook.
- True Real-Time Reactivity: Components update instantly via server push, with no polling needed.
- Automatic Optimistic Updates: Deliver a snappy, native-like UI experience with automatic rollbacks on error.
- Conflict-Free by Default: Leverages Triplit's CRDT-based sync to automatically handle concurrent writes from multiple users.
- Unified Query API: Allows you to eventually join your real-time Triplit data with data from any other source (REST APIs, GraphQL,
localStorage
). - Robust Offline-First: Built on top of Triplit's powerful offline cache and mutation outbox.
- Seamless Integration: Get started in seconds with a single, elegant factory function.
It's helpful to think of these libraries' roles using a car analogy:
Library | Role | Analogy |
---|---|---|
TanStack Query | Server Cache & Network Manager | The robust gas engine |
Triplit Client | Real-Time Sync Engine | The high-performance electric motor |
TanStack DB | Reactive UI Data Layer | The unified cockpit & dashboard |
triplit-tanstackdb |
The Bridge | The specialized wiring harness |
This library is the wiring harness that plugs your powerful Triplit "electric motor" into the unified "dashboard" of TanStack DB.
This package has peer dependencies that need to be installed:
npm install @tanstack/db @tanstack/react-db @triplit/client triplit-tanstackdb
# or
yarn add @tanstack/db @tanstack/react-db @triplit/client triplit-tanstackdb
Note: For the hybrid patterns discussed in Part 2, you will also need @tanstack/react-query
and @tanstack/query-collection
.
Your application's root component should include the provider for the Triplit client.
// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { TriplitProvider } from '@triplit/react';
import { client } from './triplitClient'; // Your initialized Triplit client
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<TriplitProvider client={client}>
<App />
</TriplitProvider>
</React.StrictMode>,
);
This guide will walk you through creating and using a real-time todos
collection.
First, ensure your Triplit client is configured for a robust offline experience by specifying 'indexeddb'
storage. This persists the cache and mutation outbox across browser sessions.
// src/triplitClient.ts
import { TriplitClient } from '@triplit/client';
import { schema } from '../triplit/schema'; // Your Triplit schema
export const client = new TriplitClient({
schema,
serverUrl: import.meta.env.VITE_TRIPLIT_SERVER_URL,
token: import.meta.env.VITE_TRIPLIT_TOKEN,
// This is crucial for a true offline-first experience.
storage: 'indexeddb',
});
Next, use the createTriplitCollection
factory in a dedicated collections file.
// src/collections.ts
import { createTriplitCollection } from 'triplit-tanstackdb';
import { client } from './triplitClient';
import { todoSchema } from '../triplit/schemas'; // An optional Zod schema
// Define the Triplit query for the data you want to sync.
const allTodosQuery = client.query('todos').order('createdAt', 'ASC');
// Create the TanStack DB collection using the factory.
export const todosCollection = createTriplitCollection({
// --- Required Options ---
client,
query: allTodosQuery,
getKey: (todo) => todo.id,
// --- Recommended Options ---
id: 'todos',
schema: todoSchema,
// --- Optional Error Handling ---
onError: (error) => {
console.error("A sync error occurred in the todos collection:", error);
},
});
With the collection created, you can now build a fully featured component that reads and writes data with a clean, declarative API.
// src/components/TodoList.tsx
import { useLiveQuery } from '@tanstack/react-db';
import { q } from '@tanstack/db';
import { todosCollection } from '../collections';
import { client } from '../triplitClient';
export function TodoList() {
const { data: todos, isLoading } = useLiveQuery(() =>
q.from({ todo: todosCollection }).select(({ todo }) => todo)
);
const addTodo = (text: string) => {
// This UI update is immediate and optimistic.
todosCollection.insert({
id: client.id(), // Use Triplit's ID generator
text,
completed: false,
createdAt: new Date(),
});
};
const toggleTodo = (todo) => {
// Updates are also instant.
todosCollection.update(todo.id, { completed: !todo.completed });
};
const deleteTodo = (todoId: string) => {
// Deletes are also instant.
todosCollection.delete(todoId);
};
// This `isLoading` state is only true during the very initial data load.
if (isLoading) {
return <div>Loading your todos...</div>;
}
return (
<div>
{/* Your form to call addTodo */}
<ul>
{todos?.map((todo) => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo)}
/>
<span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.text}
</span>
<button onClick={() => deleteTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
}
If any mutation fails on the server, TanStack DB will automatically roll back the optimistic update, ensuring your UI always reflects the true state of your data.
The key to using this ecosystem effectively is to choose the right tool for the right job. A TanStack DB Collection
is your reactive UI store, but the way you populate it—your "adapter"—determines its behavior.
Pattern | Data Flow Model | Real-Time? | Best For... |
---|---|---|---|
A: Query + DB (@tanstack/query-collection ) |
Pull | No (Simulated via Polling) | Collections from traditional REST/GraphQL APIs. |
B: Triplit + DB (This Lib) | Push | Yes (Event-Driven & Instant) | Collections from a real-time sync engine like Triplit. |
C: Triplit in Query (Advanced) | Pull | No | Anti-Pattern for entity collections. Best Practice for one-off aggregations or computations. |
This guide will now provide recipes for these patterns.
The query
you pass to createTriplitCollection
can be as complex as Triplit allows. This enables you to create collections that act as specific, pre-filtered "views" of your data that stay in sync automatically.
// src/collections/urgent-tasks.collection.ts
// A complex query to find high-priority tasks with recent comments for a specific user.
const myUrgentTasksQuery = client.query('tasks')
.where('priority', '>', 3)
.where('assigneeId', '=', 'user-123')
.include({
comments: {
_extends: 'comments', // The relationship name
where: [
['createdAt', '>', new Date(Date.now() - 1000 * 60 * 60 * 24)] // in last 24h
],
limit: 5,
order: [['createdAt', 'DESC']],
}
});
// This collection will ONLY ever contain items that match this query.
// It will stay in sync automatically as data changes to match or un-match the criteria.
export const myUrgentTasksCollection = createTriplitCollection({
client,
query: myUrgentTasksQuery,
getKey: (task) => task.id,
id: 'myUrgentTasks',
});
This is the gold standard for a great search experience. It uses the best of both worlds: Triplit's specialized search engine for efficient computation, and TanStack DB for a live, reactive view of the results.
This is a two-step pattern:
- Use TanStack Query (
useQuery
) to execute a fast, one-time Triplit search to get back an array of entity IDs. - Use TanStack DB (
useLiveQuery
) to reactively display the full, live entities that match those IDs.
// src/components/SearchableTasks.tsx
import { useQuery } from '@tanstack/react-query';
import { useLiveQuery } from '@tanstack/react-db';
import { q, inArray } from '@tanstack/db';
import { tasksCollection } from '../collections'; // The collection from Part 1
import { client } from '../triplitClient';
function SearchableTasks() {
const [searchText, setSearchText] = useState('');
// Step 1: Use TanStack Query to fetch just the IDs from Triplit's search engine.
// This is a fast, indexed search on the server that we cache on the client.
const { data: matchingTaskIds, isLoading: isSearching } = useQuery({
queryKey: ['taskSearch', searchText],
// Assuming Triplit provides a specialized search method like 'fts' or via its HttpClient
queryFn: () => client.someSearchMethod('tasks', searchText, { select: ['id'] }),
enabled: searchText.length > 2,
});
// Step 2: Use TanStack DB to create a live, reactive "view" of the results.
// This query runs instantly on the client against the data you already have.
const { data: searchResults } = useLiveQuery({
query: (builder) =>
builder
.from({ task: tasksCollection })
.where(({ task }) => inArray(task.id, matchingTaskIds ?? [])), // Pluck items by ID
enabled: !!matchingTaskIds,
});
return (
<div>
<input
type="search" value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder="Search tasks..."
/>
{isSearching && <p>Searching...</p>}
<ul>
{/* The UI is fully reactive. If a task in the results is updated by
a collaborator, it will re-render here instantly, without a new search. */}
{searchResults?.map(task => <li key={task.id}>{task.title}</li>)}
</ul>
</div>
);
}
While aggregations (like count
or avg
) don't naturally fit into a collection of entities, you should use the "Direct Query Pattern" with TanStack Query to fetch, cache, and display them.
// src/components/ProjectStats.tsx
import { useQuery } from '@tanstack/react-query';
import { useLiveQuery } from '@tanstack/react-db';
import { q } from '@tanstack/db';
import { tasksCollection } from '../collections';
import { client } from '../triplitClient';
function ProjectStats() {
// For live, detailed data where we have all the entities, we use TanStack DB
const { data: completedTasksCount } = useLiveQuery(() =>
q.from(tasksCollection)
.where(({ task }) => task.completed === true)
.select(tasks => tasks.length) // `select` can derive any shape
);
// For heavy aggregations across the entire dataset, we use TanStack Query directly with Triplit
const { data: totalValue } = useQuery({
queryKey: ['projectTotalValue'],
// This assumes Triplit offers an aggregation method, or you could call a custom
// server endpoint that uses Triplit's HttpClient.
queryFn: () => client.aggregate('tasks', { sum: 'value' }),
refetchInterval: 60000, // Refetch total value every minute
});
return (
<div>
{/* This count updates instantly and optimistically */}
<p>Live Completed Tasks: {completedTasksCount}</p>
{/* This value updates periodically based on its refetch interval */}
<p>Total Project Value: ${totalValue}</p>
</div>
);
}
This is the ultimate expression of the data fabric, combining a real-time collection with one from a traditional API.
// src/collections/users.collection.ts (using @tanstack/query-collection)
import { queryCollectionOptions } from '@tanstack/query-collection';
export const usersCollection = createCollection(
queryCollectionOptions({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(res => res.json()),
getKey: user => user.id,
})
);
// src/components/TaskWithAssignee.tsx
import { tasksCollection } from './tasks.collection'; // From Triplit
import { usersCollection } from './users.collection'; // From REST API
function TaskWithAssignee({ taskId }) {
// A single, declarative query that joins data across two backends.
const { data: taskWithUser } = useLiveQuery((q) =>
q.from({ task: tasksCollection, user: usersCollection })
.where(({ task }) => eq(task.id, taskId))
.join(({ task, user }) => eq(task.assigneeId, user.id))
// Shape the data exactly as the component needs it
.select(({ task, user }) => ({ ...task, assignee: user }))
);
// This component will reactively update if either the task data changes in
// real-time, OR if the user data is updated after a background refetch!
}
The primary API is the createTriplitCollection
factory function.
Option | Type | Required | Description |
---|---|---|---|
client |
TriplitClient |
Yes | An instance of the configured TriplitClient. |
query |
SchemaQuery |
Yes | The Triplit query that defines the data to be synced. |
getKey |
(item) => string | number |
Yes | A function that returns a unique, stable ID for a given item. |
id |
string |
No | A unique ID for the collection, highly recommended for dev tools and debugging. |
schema |
StandardSchema |
No | A schema (e.g., from Zod) to enable full, end-to-end type safety for mutations. |
onError |
(error) => void |
No | A callback to handle errors from the sync engine or mutations. |
...others | Omit<...> |
No | All other standard CollectionConfig options (like rowUpdateMode ) are passed through. |
1. How does this compare to a server-side solution like GraphQL?
GraphQL Gateways unify APIs on the server. The TanStack DB data fabric unifies data sources on the client. They are complementary. You could use this library to consume data from a Triplit source and your company's central GraphQL API in the same useLiveQuery
join.
2. How does offline support work? This adapter fully leverages Triplit's powerful local-first architecture. When offline:
- Reads:
client.subscribe
serves data from Triplit's local cache. - Writes:
collection.insert()
resolves optimistically, and the change is queued in Triplit's outbox. - Syncing: Upon reconnection, the Triplit client automatically handles syncing the outbox. The adapter ensures this flow is seamlessly reflected in the TanStack DB collection.
3. What about performance? Is all this abstraction slow?
No. This pattern is highly performant. useLiveQuery
joins and filters operate on in-memory data that has already been synced to the client. These are sub-millisecond operations powered by a differential dataflow engine. The library does not introduce any network bottlenecks.
4. How are optimistic update race conditions handled? The adapter contains specific logic to be "optimistic-aware." It tracks pending optimistic mutations submitted to TanStack DB and ensures that an incoming server snapshot does not incorrectly discard a user's change before it's been confirmed, preventing UI flicker and ensuring a smooth, reliable experience.
We are open to contributions! Please open an issue or submit a pull request.
Welcome to the definitive guide on the modern TanStack data layer. This document will provide a clear mental model for understanding the distinct but complementary roles of TanStack DB, TanStack Query, and a real-time sync engine like Triplit.
You will learn not just what each tool does, but how and why you should use them together to build sophisticated, high-performance, and resilient web applications.
The easiest way to understand the roles of these libraries is with an analogy. Think of your application as a high-performance hybrid car, where each library is a specialized component designed to do one job perfectly.
Library | Role | Analogy |
---|---|---|
TanStack Query | Server Cache & Network Manager | The robust Gasoline Engine. It’s the workhorse for communicating with your traditional REST/GraphQL API. It handles the request/response cycle, caching fuel (data), and provides reliable power on any road (network condition). |
Triplit Client | Real-Time Sync Engine | The high-performance Electric Motor. It's a specialized, self-contained unit that provides instantaneous, silent power (real-time updates) via a direct, persistent connection (WebSockets). It has its own battery (localStorage ) and regenerative braking (offline outbox). |
TanStack DB | Reactive UI Data Layer | The unified Cockpit & Dashboard. This is the interface your driver (your UI components) interacts with. It doesn't care if the power is coming from the gas engine or the electric motor. It takes data from all sources and presents it on a single, coherent, and always up-to-date dashboard. |
@tanstack/triplit-collection |
The Bridge / Adapter | The specialized Wiring Harness & Power Controller. This library is the sophisticated controller that allows the electric motor (Triplit) to seamlessly integrate with the universal dashboard (TanStack DB), ensuring they speak the same language. |
This model makes it clear: these are not competing tools. They are specialized components of a complete system.
A TanStack DB Collection
is your reactive container. The strategy you use to fill it determines its behavior. Here are the three primary recipes.
This is the foundational pattern for working with any traditional API.
- When to Use It: Your data comes from a standard REST or GraphQL endpoint. You need best-in-class caching, background refetching, and an offline-first experience for a traditional client-server architecture.
- How It Works (The "Pull" Model):
TanStack Query
is in charge. It pulls data from your API when needed, caches it, and pipes it into the TanStack DB collection. Real-time updates are achieved by configuring Query to poll your API at an interval. - Offline & Sync: TanStack Query handles this brilliantly. Reads are served from its cache. Writes are queued as "paused mutations" and are automatically synced when the network returns.
- Performance: Excellent for request/response APIs. Caching eliminates redundant fetches. At very large scale, high-frequency polling can be less efficient than WebSockets, but for most apps, it's a perfect fit.
The Code:
import { createCollection } from '@tanstack/db';
import { queryCollectionOptions } from '@tanstack/query-collection';
export const usersCollection = createCollection(
queryCollectionOptions({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(res => res.json()),
getKey: (user) => user.id,
onInsert: async ({ transaction }) => { /* ... POST to /api/users ... */ },
})
);
This is the primary pattern for this library, designed for true real-time backends.
- When to Use It: Your data comes from Triplit, and you need instantaneous, push-based updates, along with Triplit's robust, CRDT-based offline capabilities.
- How It Works (The "Push" Model): The
createTriplitCollection
adapter establishes a persistent WebSocket connection viaclient.subscribe()
. When data changes on the backend, the server pushes a new snapshot of the results to the client. The adapter receives this push and updates the collection. - Offline & Sync: Triplit's client handles this natively. Reads are served from its local
indexeddb
cache. Writes are queued in its offline "outbox." Upon reconnection, Triplit's sync protocol intelligently reconciles the changes. Our adapter simply leverages this power. - Performance: Superior for real-time data. Pushing deltas or snapshots over a single, persistent connection is vastly more efficient than repeatedly polling a stateless HTTP endpoint.
The Code:
import { createTriplitCollection } from '@tanstack/triplit-collection';
import { client } from './my-triplit-client';
export const tasksCollection = createTriplitCollection({
client,
query: client.query('tasks').where('status', '!=', 'archived'),
getKey: (task) => task.id,
});
This is an advanced technique. Understanding when and when not to do this is crucial.
You should NOT use TanStack Query as the primary manager for a collection of live entities from Triplit.
// --- AVOID THIS PATTERN FOR ENTITY COLLECTIONS ---
export const badTasksCollection = createCollection(
queryCollectionOptions({
queryKey: ['tasksFromTriplit'],
// This uses a one-time fetch, not a live subscription!
queryFn: () => client.fetch(client.query('tasks')),
getKey: (task) => task.id,
})
);
Why it's wrong: This pattern discards the primary benefit of Triplit—its real-time, push-based architecture. It turns your live data source back into a "Pull" model that will only update if you manually refetch or poll, which is inefficient and negates the power of the WebSocket connection.
This pattern is the best practice for integrating Triplit's specialized query capabilities (like Full-Text Search or complex aggregations) into a reactive UI. It's a powerful two-step dance:
- Use TanStack Query to execute a fast, one-time Triplit computation and get back a result (like a list of IDs or a single aggregate value).
- Use TanStack DB (
useLiveQuery
) to reactively display the full, live entities that match those results.
The Code (Creating a Reactive Search "View"):
function SearchableTasks({ searchText }) {
// Step 1: Use TanStack Query to fetch just the IDs from Triplit's FTS engine.
// This is a fast, cached, one-time request.
const { data: matchingTaskIds } = useQuery({
queryKey: ['taskSearch', searchText],
queryFn: () => client.fts.search('tasks', searchText, { select: ['id'] }),
enabled: searchText.length > 2,
});
// Step 2: Use TanStack DB to create a live, reactive "view" of those results.
// This runs instantly on the client against the data you already have in your real-time collection.
const { data: searchResults } = useLiveQuery({
query: (q) => q.from(tasksCollection).where((t) => inArray(t.id, matchingTaskIds ?? [])),
enabled: !!matchingTaskIds,
});
// ... render search results
}
Why it's right: This gives you the best of both worlds: Triplit's specialized engine for heavy computation and TanStack DB's instantaneous reactivity for the UI.
The final payoff for this architecture is the ability to create a single, unified query API for your components, allowing you to harness the power of both your existing API and Triplit simultaneously.
Imagine a feature where you must display a "Payment Issue" badge (from your Postgres DB via a REST API) on a task in your real-time Triplit project board.
The Code (The Grand Finale):
// collections.ts
import { usersCollection } from './users.collection'; // Recipe 1
import { tasksCollection } from './tasks.collection'; // Recipe 2
// component.tsx
function ProjectBoard() {
const { data: atRiskTasks } = useLiveQuery((q) =>
q
.from({
tasks: tasksCollection, // From Triplit (real-time)
users: usersCollection, // From REST API (near real-time)
})
// Join the live tasks with the transactional user/payment data...
.join(({ t, u }) => eq(t.assigneeId, u.id))
// ...and filter in real-time to find only the problem cases.
.where(({ u }) => eq(u.paymentStatus, 'failed'))
.select(({ t }) => t) // Select the full task object
);
const atRiskTaskIds = new Set(atRiskTasks.map(t => t.id));
// In your main board render logic:
// <TaskCard isAtRisk={atRiskTaskIds.has(task.id)} />
}
This is the pinnacle of the modern data layer. The atRiskTasks
list is live. If a user's payment fails and the usersCollection
updates on the next background refetch, the badge will appear automatically. If a task is reassigned in real-time via Triplit, the badge will appear or disappear instantly.
This level of dynamic, cross-source reactivity, built on a foundation of simple, composable tools, is the new standard for building ambitious web applications.
- Real-time synchronization
- Offline-first capabilities
- Query abstraction and cross-source joins
- Optimistic UI updates and rollback
- Conflict resolution strategy
- Schema definition and type safety
- Partial vs. full replication
- Storage adapters and persistence
- Bundle size and dependencies
- Hosting model and vendor lock-in
- Performance, scalability, and cost
- Developer ergonomics and learning curve
Aspect | Triplit-TanStackDB | TanStack Query + REST/GraphQL | Firebase / Firestore | Convex | PouchDB / CouchDB | Supabase | Apollo Client | RxDB |
---|---|---|---|---|---|---|---|---|
Real-Time Sync | ✅ CRDT-powered push | ❌ Polling/manual invalidate | ✅ WebSocket replication | ✅ WebSocket hooks | ✅ Changefeeds + live sync | ✅ Subscriptions | ✅ GraphQL subscriptions | ✅ Multi-backend adapters |
Offline-First | ✅ IndexedDB + outbox | ❌ Manual cache | ✅ IndexedDB cache | ✅ Mutation queue | ✅ Fully offline local | ✅ Advanced offline | ||
Unified Query API | ✅ Single TanStack DB | ❌ Separate hooks per source | ❌ Firestore-only queries | ✅ Convex query builder | ❌ Manual stitching | ✅ SQL + edge functions | ✅ GraphQL cache layer | ✅ RxJS-powered queries |
Cross-Source Joins | ✅ Native joins | ❌ Custom merge logic | ❌ Not supported | ❌ Not supported | ❌ App logic only | ✅ SQL views | ❌ App logic only | ✅ With custom adapters |
Optimistic UI Updates | ✅ Built-in + rollback | ❌ Manual implementation | ✅ Built-in writes | ✅ Built-in writes | ❌ Manual conflict ops | ✅ Transactional retries | ✅ Built-in mutation | ✅ Configurable |
Conflict Resolution | ✅ CRDT, automatic | N/A | ❌ Last-write-wins | ❌ Last-write-wins | ❌ Last-write-wins | |||
Schema & Type Safety | ❌ No enforced schema | ✅ Type-safe schema | ❌ No enforcement | ✅ Auto-generated types | ||||
Partial Replication | ✅ Active-query only | N/A | ❌ Full collection fetch | N/A | ✅ Filtered replication | ❌ Full snapshots only | N/A | ✅ Configurable filters |
Storage Adapters | ✅ Memory & IndexedDB | ✅ Built-in | ✅ Local queue | ✅ Various (Web SQL, etc.) | ✅ LocalStorage, SQLite | ✅ LevelDB, IndexedDB | ||
Bundle Size & Dependencies | ✅ Minimal React Query | |||||||
Hosting & Lock-In | ✅ Self-hosted or Cloud | ✅ Any backend | ❌ Google-hosted only | ❌ Convex-hosted | ✅ Self-hosted only | ✅ Fully self-hosted | ||
Performance & Scalability | ✅ Sub-ms local queries | ✅ Google infra | ✅ Serverless scale | ✅ Postgres + edge | ✅ Reactive streams | |||
Cost & Pricing Model | ✅ Open-source + self | ✅ Free + pay per backend | ❌ Usage-based costs | ❌ Usage fees | ✅ Free OSS | ✅ Free OSS | ||
Learning Curve | ✅ Low (React Query style) | ✅ Low | ✅ Low | ✅ Low |
- Redux Toolkit Query (RTK Query) – Built-in caching & invalidation with Redux.
- Dexie.js – Lightweight IndexedDB wrapper; manual sync logic.
- WatermelonDB – React Native-focused, SQLite-backed, offline sync plugin.
- AWS AppSync – GraphQL real-time + offline via Amplify.
- Orbit.js – Data syncing and normalization for JavaScript.
- DataLoader – Batch-loading for GraphQL backends.
Triplit-TanStackDB’s offline flow leverages Triplit’s CRDT engine and an IndexedDB-backed mutation outbox:
- Local reads/writes are applied optimistically against in-memory or IndexedDB storage. ✅
- Mutations queue in an outbox; UI reflects changes instantly. ✅
- On reconnect, queued mutations send to the server in batches. ✅
- The server resolves concurrent edits using CRDT merges, then broadcasts minimal diffs. ✅
- Clients receive and merge deltas, achieving eventual consistency with zero conflict. ✅
Partial replication ensures each client only syncs data that matches its active queries, reducing bandwidth and storage.
- You need real-time collaboration (task boards, multiplayer features).
- Offline resilience and seamless reconnection are non-negotiable.
- Your UI must join data from real-time streams and traditional APIs without glue code.
- You value a unified, reactive query abstraction across all sources.
- You prefer a self-hosted or open-source sync engine over proprietary lock-in.
- You want automatic conflict resolution courtesy of CRDTs.