Data sync diagram on a developer's desk with a large red Maine Coon cat resting in the background.

RxDB: The Reactive Database That Lives Inside Your App

The Orange Cat
The Orange Cat

If you have ever built a web app and felt the friction of constant request/response cycles — fetch the data, show a spinner, re-fetch when something changes, wire up a websocket so you know it changed, then patch your cache by hand — RxDB offers a fundamentally different shape. RxDB (short for Reactive Database) is a local-first, reactive, NoSQL database that runs inside your client: the browser, a React Native app, Electron, or even Node. Your app reads and writes against local storage instantly, works offline without complaint, and optionally syncs that data to a backend in the background.

The headline trick is reactivity. You do not just query for data once — you subscribe to a query, or even to a single field of a single document, and your callback re-fires automatically whenever the underlying data changes. That makes RxDB a natural companion for UI frameworks like React, where you want the view to track database state without polling or manual cache invalidation. Under the hood the data model is document-oriented JSON with JSON-Schema collection schemas and MongoDB-style query selectors. This article walks through the core ideas, gets you running, and then explores the parts that make RxDB genuinely interesting: reactive subscriptions, replication, and React integration.

What Makes It Tick

RxDB solves three problems at once that usually require three separate libraries:

  • Local-first and offline-first. Data lives on the client. Reads and writes hit local storage immediately, so there is no spinner waiting on the network, and the app keeps working with no connection at all.
  • Reactive queries. Instead of imperatively re-fetching, you subscribe to a query's observable. When any write changes the result set, every subscriber receives the new result. A whole category of stale-UI bugs simply disappears.
  • Replication and multi-client sync. A battle-tested sync engine keeps the local database in step with a backend — and transitively with other clients — complete with revision-based conflict handling.

Beyond those pillars, RxDB ships schema validation, encryption, schema migrations, binary attachments, key compression, middleware hooks, ORM-style document methods, and cross-tab synchronization so multiple browser tabs share one consistent view of the data.

The Pluggable Storage Idea

The single most important architectural decision in RxDB is the RxStorage abstraction. The core handles queries, schemas, reactivity, replication, and conflicts, but the actual persistence is delegated to a swappable storage engine. That is why the same RxDB code can run on a browser, in Node, or inside React Native — you just pick the storage that fits the platform.

On the free, open-source core you have LocalStorage storage (the recommended starting point, small and simple), Memory storage (great for tests and server-side rendering), Dexie storage (wraps IndexedDB for larger browser datasets), and a Remote storage that runs persistence behind an async message channel. It is worth knowing up front that the highest-performance browser storages (RxDB's native IndexedDB and OPFS implementations) and the native SQLite storage are premium, commercially licensed plugins. The free tier is genuinely usable for hobby and many production apps, but if you expect top-tier IndexedDB or SQLite performance, evaluate the premium tier before you commit.

Getting It Installed

RxDB needs rxjs as a peer dependency, so install both together.

npm install rxdb rxjs --save

Or with yarn:

yarn add rxdb rxjs

Spinning Up a Database

Creating a database means choosing a name and a storage engine. Here we use the free LocalStorage storage, which is the friendliest place to begin.

import { createRxDatabase, RxDatabase } from 'rxdb';
import { getRxStorageLocalstorage } from 'rxdb/plugins/storage-localstorage';

const db: RxDatabase = await createRxDatabase({
  name: 'heroesdb',
  storage: getRxStorageLocalstorage(),
});

During development, it is also worth adding the dev-mode plugin, which gives you far more helpful error messages and schema validation. Remember to leave it out of production builds for size and performance reasons.

import { addRxPlugin } from 'rxdb';
import { RxDBDevModePlugin } from 'rxdb/plugins/dev-mode';

addRxPlugin(RxDBDevModePlugin);

Describing Your Data With Schemas

Collections in RxDB are defined by a JSON Schema. The schema declares the document shape, the primary key, and validation constraints. Versioning the schema is what later powers automatic migrations.

const heroSchema = {
  title: 'hero schema',
  version: 0,
  primaryKey: 'name',
  type: 'object',
  properties: {
    name: { type: 'string', maxLength: 100 },
    color: { type: 'string' },
    healthpoints: { type: 'number', minimum: 0, maximum: 100 },
  },
  required: ['name', 'color', 'healthpoints'],
} as const;

await db.addCollections({
  heroes: { schema: heroSchema },
});

Inserting a document is a single call, and writes are persisted locally right away:

await db.heroes.insert({
  name: 'Frodo',
  color: 'blue',
  healthpoints: 100,
});

You can read once with a Promise when you just need a snapshot. The selector syntax will look familiar to anyone who has used MongoDB:

const aliveHeroes = await db.heroes
  .find({ selector: { healthpoints: { $gt: 0 } } })
  .exec();

Subscriptions: Where RxDB Earns Its Name

The .exec() call above gives you a one-time result. The more interesting path is the observable, exposed via the .$ property. Subscribe to it once and your callback fires immediately with the current result, then again every time a write changes the result set — no re-querying, no manual diffing.

db.heroes
  .find({ selector: { healthpoints: { $gt: 0 } } })
  .$ // the observable
  .subscribe((aliveHeroes) => {
    console.log('alive heroes changed:', aliveHeroes);
  });

Reactivity goes deeper than whole queries. You can subscribe to a single field of a single document, which is perfect for binding one value in a UI without re-rendering anything else.

const doc = await db.heroes.findOne('Frodo').exec();

doc.healthpoints$.subscribe((hp) => {
  console.log('Frodo HP:', hp);
});

Because every query is a genuine RxJS observable, you can compose it with the entire RxJS operator toolbox — map, debounceTime, combineLatest, and so on — and feed it into any framework's reactive primitive, whether that is an Angular async pipe, a Svelte store, a Solid signal, or React state.

Keeping Clients in Sync With Replication

Local-first does not mean local-only. RxDB's replication engine keeps the local database in step with a backend, and through that backend with every other client. There are turnkey plugins for CouchDB, GraphQL, WebSocket, WebRTC peer-to-peer, Supabase, Firestore, NATS, and Google Drive — but the foundation is a generic HTTP protocol where you implement just three handlers: pull, push, and an optional pull-stream for live updates.

import { replicateRxCollection } from 'rxdb/plugins/replication';

const replicationState = replicateRxCollection({
  collection: db.heroes,
  replicationIdentifier: 'my-http-replication',
  pull: {
    handler: async (checkpoint, batchSize) => {
      const response = await fetch(
        `/api/pull?checkpoint=${JSON.stringify(checkpoint)}&limit=${batchSize}`
      );
      const data = await response.json();
      return { documents: data.documents, checkpoint: data.checkpoint };
    },
  },
  push: {
    handler: async (rows) => {
      await fetch('/api/push', {
        method: 'POST',
        body: JSON.stringify(rows),
      });
      return []; // return any conflicts the server detected
    },
  },
  live: true,
  retryTime: 5000,
});

replicationState.error$.subscribe((err) => console.error('replication error', err));

The replication layer handles checkpoints, retries, and revision-based conflict detection for you. When two clients edit the same document, RxDB surfaces the conflict so a customizable conflict handler can resolve it — and for collaborative scenarios there is a CRDT plugin that resolves conflicts automatically.

Wiring It Into React

RxDB's core is deliberately framework-agnostic; it speaks RxJS observables, and React can consume those in a couple of ways. The most ergonomic is the community rxdb-hooks package, which provides useRxDB, useRxCollection, and useRxQuery. These hooks subscribe to RxDB observables for you, re-render when data changes, and even bundle pagination and infinite-scroll helpers.

import { useRxCollection, useRxQuery } from 'rxdb-hooks';

function Characters() {
  const collection = useRxCollection('characters');
  const query = collection.find().where('affiliation').equals('Jedi');

  const { result: characters, isFetching, fetchMore, isExhausted } = useRxQuery(
    query,
    { pageSize: 5, pagination: 'Infinite' }
  );

  if (isFetching) return <span>loading…</span>;

  return (
    <ul>
      {characters.map((c) => (
        <li key={c.id}>{c.name}</li>
      ))}
    </ul>
  );
}

If you would rather not add a dependency, the manual pattern is just as valid: subscribe to query.$ inside a useEffect, push results into useState, and unsubscribe on cleanup.

import { useEffect, useState } from 'react';

function HeroList({ db }) {
  const [heroes, setHeroes] = useState([]);

  useEffect(() => {
    const sub = db.heroes
      .find({ selector: { healthpoints: { $gt: 0 } } })
      .$.subscribe((docs) => setHeroes(docs));

    return () => sub.unsubscribe();
  }, [db]);

  return (
    <ul>
      {heroes.map((h) => (
        <li key={h.name}>{h.name}</li>
      ))}
    </ul>
  );
}

Both approaches give you a list that updates itself the moment anything — a local insert, a sync from another device, an edit in another tab — changes the underlying data.

Things Worth Knowing Before You Commit

A few caveats deserve attention. The most significant is that RxDB's premium plugins gate its fastest features: native IndexedDB, OPFS, native SQLite, Worker offloading, sharding, the query optimizer, WebCrypto encryption, and full-text search are all paid via annual licensing. The free Apache-2.0 core remains capable, but teams chasing peak storage performance should budget for the premium tier. RxDB's dependency tree is also non-trivial — it is plugin-based and tree-shakeable, so you import only what you use, but it pays to measure your bundle with an analyzer, and to keep things lean by starting with LocalStorage storage. Finally, the API leans heavily on RxJS, which is a delight if your team knows observables and a learning curve otherwise, and RxDB ships major versions fairly often, so pin your version and read the migration guide before upgrading.

The Takeaway

RxDB shines precisely where reactivity, sync, and cross-platform support all show up as requirements at the same time. If you are building an offline-first app that needs instant local reads and writes, a UI that updates itself without manual cache wrangling, and multi-device sync against a backend of your choosing — across the browser, React Native, Electron, and Node from one abstraction — RxDB hits a sweet spot that few other libraries reach. Start on the free core with LocalStorage or Dexie storage, lean on the reactive .$ observables to keep your UI honest, and reach for replication when you are ready to make the database, rather than the network, your source of truth.