Skip to main content

TinyBase vs WatermelonDB vs RxDB: Offline-First Databases 2026

·PkgPulse Team

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

FeatureTinyBaseWatermelonDBRxDB
Bundle size~5 KBNative module~200 KB (depends on plugins)
Storage backendVariousSQLite (native)Dexie/SQLite/others
React Native✅ Optimized
Browser
Sync adaptersBasicManual✅ 15+ adapters
Reactive queries
TypeScript✅ First-class
Query languageFluent APIQ (custom)RxDB query (Mango)
Relations
Offline by default
Supabase syncManualManual✅ Official plugin
GitHub stars5k10k22k
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.

Comments

Stay Updated

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