TinyBase vs WatermelonDB vs RxDB: Offline-First Databases 2026
TinyBase vs WatermelonDB vs RxDB: Offline-First Databases 2026
TL;DR
Offline-first apps store data locally and sync when connected. TinyBase is the most modern — a reactive, relational-ish store built for TypeScript with impressive bundle size (~5 KB), designed for both React web and React Native. WatermelonDB is the React Native specialist — built for performance on mobile with SQLite backend, used in production at Nozbe and Notion-style apps with 50k+ records. RxDB is the most comprehensive — reactive offline database with sync adapters for CouchDB, Supabase, Firestore, and more, plus full TypeScript support and a plugin architecture. For lightweight local state with optional sync: TinyBase. For React Native apps with large local datasets: WatermelonDB. For offline apps needing production sync to any backend: RxDB.
Key Takeaways
- TinyBase is ~5 KB gzipped — the lightest option with surprisingly rich features
- WatermelonDB uses SQLite via native bindings for 5-50x faster queries than AsyncStorage
- RxDB supports 15+ sync adapters — CouchDB, Supabase, Firestore, GraphQL, and custom REST
- All three provide React hooks for reactive UI updates on data changes
- TinyBase GitHub stars: ~5k — newest but fastest-growing
- WatermelonDB GitHub stars: ~10k — the proven React Native choice
- RxDB v16 supports Dexie as default storage — works in browser, Node.js, React Native, and Electron
The Offline-First Problem
Simple apps use fetch() → server → render. Offline-first apps invert this:
Offline-First Architecture:
1. Read → Local DB first (instant, no network needed)
2. Write → Local DB first (works offline)
3. Sync → Push/pull changes to server when connected
4. Conflict resolution → Merge concurrent changes
Result: App works with no internet. Syncs automatically when connected.
TinyBase: Reactive Local Store
TinyBase is a modern reactive data store with a tabular model (tables + rows + cells), TypeScript-first design, and an optional sync layer. It works in browsers, Node.js, and React Native.
Installation
npm install tinybase
# React Native
npm install tinybase react-native-mmkv # For MMKV persistent storage
Basic Store
import { createStore } from "tinybase";
// Create a store
const store = createStore();
// Set values
store.setTable("todos", {
"1": { text: "Buy groceries", completed: false, priority: 1 },
"2": { text: "Walk the dog", completed: true, priority: 2 },
"3": { text: "Read a book", completed: false, priority: 3 },
});
// Query data
const todos = store.getTable("todos");
const firstTodo = store.getRow("todos", "1");
const isCompleted = store.getCell("todos", "1", "completed");
// Update
store.setCell("todos", "1", "completed", true);
// Delete
store.delRow("todos", "2");
React Integration
import { createStore } from "tinybase";
import {
Provider,
useTable,
useRow,
useCell,
useSetCellCallback,
useSetRowCallback,
useDelRowCallback,
} from "tinybase/ui-react";
const store = createStore();
function TodoItem({ id }: { id: string }) {
const text = useCell("todos", id, "text") as string;
const completed = useCell("todos", id, "completed") as boolean;
const toggle = useSetCellCallback(
"todos",
id,
"completed",
() => !completed,
[completed]
);
const remove = useDelRowCallback("todos", id);
return (
<div>
<input type="checkbox" checked={completed} onChange={toggle} />
<span style={{ textDecoration: completed ? "line-through" : "none" }}>
{text}
</span>
<button onClick={remove}>Delete</button>
</div>
);
}
function TodoList() {
const todos = useTable("todos");
return (
<Provider store={store}>
<ul>
{Object.keys(todos).map((id) => (
<li key={id}>
<TodoItem id={id} />
</li>
))}
</ul>
</Provider>
);
}
TinyBase Relationships and Queries
import { createStore, createRelationships, createQueries } from "tinybase";
const store = createStore();
// Define relationships
const relationships = createRelationships(store);
relationships.setRelationshipDefinition(
"todoProject", // Relationship name
"todos", // Local table
"projects", // Remote table
"projectId" // Foreign key in todos
);
// Define queries (like SQL views, reactive)
const queries = createQueries(store);
queries.setQueryDefinition(
"incompleteTodos", // Query name
"todos", // Table
({ select, where, order }) => {
select("text");
select("priority");
where("completed", false);
order("priority");
}
);
// Get query results — updates reactively as data changes
const results = store.getResultTable("incompleteTodos");
Persistence (React Native with MMKV)
import { createStore } from "tinybase";
import { createMmkvPersister } from "tinybase/persisters/persister-react-native-mmkv";
import { MMKV } from "react-native-mmkv";
const mmkv = new MMKV();
const store = createStore();
const persister = createMmkvPersister(store, mmkv);
// Load from storage and start auto-saving
await persister.startAutoLoad();
await persister.startAutoSave();
// Now all store changes are persisted automatically
store.setCell("todos", "1", "completed", true); // Auto-saved
WatermelonDB: React Native SQL Performance
WatermelonDB uses SQLite via native modules, making it 5-50x faster than AsyncStorage-based solutions for large datasets. It's designed for apps with thousands of records.
Installation
npm install @nozbe/watermelondb
# Install native SQLite adapter
npx expo install expo-sqlite # For Expo
# Or for bare React Native:
npm install @nozbe/react-native-sqlite-storage
Schema Definition
// model/schema.ts
import { appSchema, tableSchema } from "@nozbe/watermelondb";
export const schema = appSchema({
version: 3, // Increment when schema changes
tables: [
tableSchema({
name: "todos",
columns: [
{ name: "text", type: "string" },
{ name: "is_completed", type: "boolean" },
{ name: "priority", type: "number" },
{ name: "project_id", type: "string", isIndexed: true },
{ name: "created_at", type: "number" },
{ name: "updated_at", type: "number" },
],
}),
tableSchema({
name: "projects",
columns: [
{ name: "name", type: "string" },
{ name: "color", type: "string", isOptional: true },
{ name: "created_at", type: "number" },
{ name: "updated_at", type: "number" },
],
}),
],
});
Model Definition
// model/Todo.ts
import { Model } from "@nozbe/watermelondb";
import { field, date, readonly, relation } from "@nozbe/watermelondb/decorators";
export class Todo extends Model {
static table = "todos";
static associations = {
projects: { type: "belongs_to" as const, key: "project_id" },
};
@field("text") text!: string;
@field("is_completed") isCompleted!: boolean;
@field("priority") priority!: number;
@readonly @date("created_at") createdAt!: Date;
@readonly @date("updated_at") updatedAt!: Date;
@relation("projects", "project_id") project!: any;
}
Database Setup and Queries
import { Database } from "@nozbe/watermelondb";
import SQLiteAdapter from "@nozbe/watermelondb/adapters/sqlite";
import { schema } from "./model/schema";
import { Todo } from "./model/Todo";
// Create database
const adapter = new SQLiteAdapter({
schema,
migrations: undefined, // Add migrations for schema updates
dbName: "myapp",
jsi: true, // Use JSI for performance
});
const database = new Database({
adapter,
modelClasses: [Todo],
});
// Query todos
const todosCollection = database.get<Todo>("todos");
// Get all incomplete todos — lazy, doesn't run until subscribed
const incompleteTodos = todosCollection.query(
Q.where("is_completed", false),
Q.sortBy("priority", Q.asc)
);
// Create a todo
await database.write(async () => {
await todosCollection.create((todo) => {
todo.text = "New task";
todo.isCompleted = false;
todo.priority = 1;
});
});
// Update a todo
await database.write(async () => {
await existingTodo.update((todo) => {
todo.isCompleted = true;
});
});
React Hooks Integration
import { withObservables } from "@nozbe/watermelondb/react";
// withObservables makes components re-render when data changes
const enhance = withObservables(["todo"], ({ todo }: { todo: Todo }) => ({
todo: todo.observe(),
}));
const TodoItem = ({ todo }: { todo: Todo }) => (
<View>
<Text>{todo.text}</Text>
<Switch value={todo.isCompleted} onValueChange={() => {
todo.database.write(() => todo.update((t) => { t.isCompleted = !t.isCompleted; }));
}} />
</View>
);
const EnhancedTodoItem = enhance(TodoItem);
// List with observable query
const TodoList = withObservables([], ({ database }) => ({
todos: database.get("todos").query(Q.where("is_completed", false)).observe(),
}))(({ todos }) => (
<FlatList
data={todos}
renderItem={({ item }) => <EnhancedTodoItem todo={item} />}
keyExtractor={(item) => item.id}
/>
));
RxDB: Full Offline Sync Database
RxDB is the most production-complete offline-first database. It has 15+ sync adapters and works across every JavaScript environment.
Installation
npm install rxdb
# For React Native
npm install rxdb react-native-sqlite-storage
Database and Collection Setup
import { createRxDatabase, addRxPlugin } from "rxdb";
import { getRxStorageDexie } from "rxdb/plugins/storage-dexie"; // Browser
// Or for React Native: import { getRxStorageSQLite } from "rxdb/plugins/storage-sqlite";
const todoSchema = {
title: "todo schema",
version: 0,
primaryKey: "id",
type: "object",
properties: {
id: { type: "string", maxLength: 100 },
text: { type: "string" },
completed: { type: "boolean" },
priority: { type: "number" },
projectId: { type: "string" },
updatedAt: { type: "number" },
},
required: ["id", "text", "completed"],
indexes: ["projectId", "updatedAt"],
};
const db = await createRxDatabase({
name: "myapp",
storage: getRxStorageDexie(),
ignoreDuplicate: true,
});
await db.addCollections({
todos: { schema: todoSchema },
});
Reactive Queries
// RxDB queries are observable — emit new results on data changes
const todos$ = db.todos
.find({
selector: { completed: { $eq: false } },
sort: [{ priority: "asc" }],
})
.$ ; // $ = Observable
// Subscribe to reactive query
todos$.subscribe((todos) => {
console.log("Todos updated:", todos.length);
});
// React hook
import { useRxQuery } from "rxdb-hooks";
function TodoList() {
const { result: todos, isFetching } = useRxQuery(
db.todos.find({ selector: { completed: false } })
);
if (isFetching) return <Loading />;
return <FlatList data={todos} renderItem={...} />;
}
Sync with Supabase
import { replicateSupabase } from "rxdb/plugins/replication-supabase";
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!);
const replicationState = await replicateSupabase({
replicationIdentifier: "todos-sync",
collection: db.todos,
supabaseClient: supabase,
table: "todos",
pull: {
batchSize: 100,
filter: `user_id=eq.${userId}`,
},
push: {
batchSize: 100,
},
});
replicationState.error$.subscribe((error) => {
console.error("Sync error:", error);
});
replicationState.active$.subscribe((active) => {
console.log("Sync active:", active);
});
Feature Comparison
| Feature | TinyBase | WatermelonDB | RxDB |
|---|---|---|---|
| Bundle size | ~5 KB | Native module | ~200 KB (depends on plugins) |
| Storage backend | Various | SQLite (native) | Dexie/SQLite/others |
| React Native | ✅ | ✅ Optimized | ✅ |
| Browser | ✅ | ❌ | ✅ |
| Sync adapters | Basic | Manual | ✅ 15+ adapters |
| Reactive queries | ✅ | ✅ | ✅ |
| TypeScript | ✅ First-class | ✅ | ✅ |
| Query language | Fluent API | Q (custom) | RxDB query (Mango) |
| Relations | ✅ | ✅ | ✅ |
| Offline by default | ✅ | ✅ | ✅ |
| Supabase sync | Manual | Manual | ✅ Official plugin |
| GitHub stars | 5k | 10k | 22k |
| Performance (large datasets) | Good | ✅ Excellent (SQLite) | Good |
When to Use Each
Choose TinyBase if:
- Bundle size matters (5 KB vs 200+ KB for alternatives)
- You're building for both web and React Native with one codebase
- Your data model is relatively simple (thousands, not millions of records)
- TypeScript-first reactive UI updates are the primary need
Choose WatermelonDB if:
- React Native is your primary platform and performance with large datasets is critical
- Your app stores 10,000+ records locally (notes, emails, documents)
- SQLite's query performance and reliability are important
- You're building apps similar to Nozbe, Notion, or email clients
Choose RxDB if:
- You need production-ready sync to a real backend (Supabase, CouchDB, Firestore)
- Your app must work in browser, React Native, Electron, and Node.js
- Advanced query features (multi-index, aggregation, complex selectors) are needed
- You want the largest plugin ecosystem for offline-first apps
Methodology
Data sourced from GitHub repositories (star counts as of February 2026), npm weekly download statistics (January 2026), official documentation and benchmark pages, and community reviews from the React Native community on Discord and Reddit. Bundle sizes measured from bundlephobia.com for browser builds.
Related: PGlite vs Electric SQL vs Triplit for browser-native database options, or Expo Router vs React Navigation vs Solito for React Native app architecture.