docs-src/docs/articles/offline-database.md
import {Faq, FaqItem} from '@site/src/components/faq'; import {CenteredImage} from '@site/src/components/centered-image';
An offline database stores your application data directly on the client device and syncs it with a server in the background. Instead of sending a request over the network for every read and write, your app talks to a local database that responds in microseconds, no matter if the device is online or not. RxDB is a local-first, NoSQL database for JavaScript applications that was built for exactly this pattern. This page explains what an offline database is, how the RxDB Sync Engine replicates data with any backend, and how you can improve your offline database setup step by step.
<RxdbLogo alt="offline database" />An offline database keeps a full copy of the relevant data on the client, inside of a storage like IndexedDB, OPFS, or SQLite. The app reads and writes against this local copy, and a replication process exchanges changes with the backend when a connection is available.
This is different from a cache. A cache holds a temporary subset of server responses and treats the server as the source of truth. An offline database makes the local data the source of truth for the client. Writes succeed locally first, the UI updates immediately, and the sync to the server happens later. This pattern is called offline-first or local-first.
Storing data on the client is not only about surviving network outages. It changes how the whole application behaves.
Applications that call a remote server for every interaction show loading spinners while the user waits. With an offline database, reads and writes happen locally and respond in under a millisecond, which enables a zero-latency user experience. The user clicks, and the UI updates in the same frame. There is nothing to wait for.
<CenteredImage src="/files/loading-spinner-not-needed.gif" alt="loading spinner not needed" width={300} />When the device is offline, in a tunnel, on a plane, or on a flaky mobile connection, the app keeps working. Users can read their data and make changes, and the Sync Engine pushes those changes to the server when the connection comes back. For field worker apps, point-of-sale systems, and mobile apps in general, this is the difference between a usable product and an error page.
RxDB queries are observable. When the underlying data changes, either through a local write or through incoming replicated data from the server, the query emits the new result set and the UI updates automatically. You get realtime behavior without building a separate push system, because the replication and the reactivity are part of the same database.
Many web apps mishandle data across multiple browser tabs. With an offline database, all tabs of the same origin share one local state. When the user completes a to-do item in one tab, every other tab reflects the change instantly. RxDB handles this out of the box, and its leader election makes sure that background work like replication runs in exactly one tab at a time.
<CenteredImage src="/files/multiwindow.gif" alt="RxDB multi tab" width={450} />In a traditional app, every user interaction triggers one or more requests to the backend. With an offline database, data is replicated once and then queried locally as often as the UI needs it. Your server load grows with the amount of changed data, not with the number of user interactions. This makes scaling cheaper and more predictable.
When the local database is the single source of truth, you no longer need a separate client-side state management layer that mirrors server state. Components subscribe to queries, writes go to the database, and the Sync Engine handles the network. Fewer REST endpoints, no manual cache invalidation, no hand-written retry logic.
RxDB (Reactive Database) is a local-first, NoSQL database for JavaScript applications. It runs in the browser, Node.js, Electron, React Native, Capacitor, Deno, and Bun.
RxDB itself does not persist data. It uses a swappable RxStorage layer, so the same database code runs on top of IndexedDB, OPFS, SQLite, in-memory, and others. Switching storages is a configuration change, not a rewrite. On top of that, RxDB provides MongoDB-style (Mango) queries, observable query results, encryption, schema migration, and replication with almost any backend.
The following code creates a database, adds a collection, inserts a document, and subscribes to a query.
import { createRxDatabase } from 'rxdb';
import { getRxStorageLocalstorage } from 'rxdb/plugins/storage-localstorage';
const db = await createRxDatabase({
name: 'myofflinedb',
storage: getRxStorageLocalstorage()
});
await db.addCollections({
tasks: {
schema: {
title: 'tasks schema',
version: 0,
type: 'object',
primaryKey: 'id',
properties: {
id: { type: 'string', maxLength: 100 },
title: { type: 'string' },
done: { type: 'boolean' }
},
required: ['id', 'title', 'done']
}
}
});
await db.tasks.insert({
id: 'task-1',
title: 'buy milk',
done: false
});
// Reactive query: emits a new array whenever a matching doc changes.
db.tasks
.find({ selector: { done: false } })
.$
.subscribe(undoneTasks => {
console.log('Currently undone tasks:', undoneTasks.length);
});
The tasks collection now stores data offline. The next step is to sync it with a backend.
RxDB replicates data with its own Sync Engine, a protocol that was designed so that the complex parts run inside of RxDB, not in your backend. The backend can stay 'dumb', which makes the Sync Engine compatible with almost any infrastructure, no matter if your server stores data in PostgreSQL, MongoDB, or anything else.
On the document level, the replication works like git. The client is a fork of the server state. Local writes pile up on the fork, and the client pushes them to the server together with the last known server state. When the server state has moved on in the meantime, the push is rejected and the client resolves the conflict locally before pushing again. All conflicts are resolved on the client, with a conflict handler you can customize per collection. The default handler drops the local state in favor of the server state, so a device that was offline for months cannot silently overwrite newer changes from other users.
On the transfer level, your backend only has to provide three endpoints:
The replication runs in two modes. On startup, or when the client comes back online, it iterates checkpoints against the pull handler until the local state has caught up with the server. Then it switches to event observation and applies live changes from the pull stream. When the connection drops and recovers, the stream emits a RESYNC event and the replication falls back to checkpoint iteration to close the gap. You do not have to manage any of these offline-online switches yourself.
All transfers happen in batches. Batching means fewer requests, better compression, and faster processing on the client, because storages like IndexedDB and OPFS are faster when writing data in bulks.
import { replicateRxCollection } from 'rxdb/plugins/replication';
const replicationState = await replicateRxCollection({
collection: db.tasks,
replicationIdentifier: 'tasks-to-https://example.com/api/sync',
live: true,
pull: {
async handler(checkpoint, batchSize) {
const updatedAt = checkpoint ? checkpoint.updatedAt : 0;
const id = checkpoint ? checkpoint.id : '';
const url = 'https://example.com/api/sync/pull' +
`?updatedAt=${updatedAt}&id=${id}&limit=${batchSize}`;
const response = await fetch(url);
const data = await response.json();
return {
documents: data.documents,
checkpoint: data.checkpoint
};
}
},
push: {
async handler(changeRows) {
const response = await fetch('https://example.com/api/sync/push', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changeRows)
});
// Must return the current server state of all conflicting documents.
return await response.json();
}
}
});
When you do not want to build the endpoints yourself, RxDB ships replication plugins for many backends:
In a multi-tab browser app, the replication respects the leader election. Only one tab runs the replication at any given time, which saves client resources and backend connections. When that tab closes, another tab takes over.
Data on a client device can be extracted when the device is lost or stolen. When you store sensitive fields, you should encrypt them at rest with one of the encryption plugins. The encryption wraps the storage, so it works with any RxStorage.
import { createRxDatabase } from 'rxdb';
import { getRxStorageLocalstorage } from 'rxdb/plugins/storage-localstorage';
import {
wrappedKeyEncryptionCryptoJsStorage
} from 'rxdb/plugins/encryption-crypto-js';
const encryptedStorage = wrappedKeyEncryptionCryptoJsStorage({
storage: getRxStorageLocalstorage()
});
const db = await createRxDatabase({
name: 'secureofflinedb',
storage: encryptedStorage,
password: 'myTopSecretPassword'
});
await db.addCollections({
usersecrets: {
schema: {
title: 'encrypted user data',
version: 0,
type: 'object',
primaryKey: 'id',
properties: {
id: { type: 'string', maxLength: 100 },
secretData: { type: 'string' }
},
required: ['id'],
encrypted: ['secretData']
}
}
});
The secretData field is now unreadable without the password, even when someone copies the raw database files from the device. For production apps, the 👑 RxDB Premium Web Crypto encryption plugin is recommended because it is faster and uses the browser's native crypto APIs.
A working setup is the start, not the end. The following steps make an offline database faster, smaller, and safer in production.
Storage performance differs a lot between environments. In the browser, IndexedDB is the default choice, while OPFS is 3x-4x faster for many workloads. On native platforms like React Native or Capacitor, the SQLite storage is the fastest option. Compare the numbers on the storage performance page and remember that switching storages later is a configuration change, not a rewrite.
Replicating the whole dataset to every client wastes bandwidth, disk space, and initial sync time. With partial sync you filter the replication so each client only receives the documents it needs, for example only the current user's documents or only data from the last month.
Replicated documents are never physically removed, they are marked with a _deleted flag so the deletion can sync to other instances. Over time these tombstones slow down queries. The cleanup plugin purges deleted documents after a safe time window.
The key compression plugin replaces field names with shorter tokens before writing to disk, which saves up to 40% storage space without changing how you query the data.
Heavy queries and writes on the main thread can block rendering. With the Worker and SharedWorker storages you run the whole database in a background thread and keep the UI responsive, while all tabs share one database process.
Shipped clients keep old data in old formats. When your schema changes, the schema migration plugin transforms existing documents on the client to the new schema version. Define migration strategies from the first release, because an offline database without a migration path corners you later.
The default conflict handler drops local changes in favor of the server state. This is a safe default, but for collaborative apps you should write a custom conflict handler that merges fields, or use the CRDT plugin for deterministic merges of concurrent changes.
Offline-first is not free. Conflicts can happen, initial replication takes time on large datasets, and client storage is limited by browser quotas. Read the downsides of offline-first before you commit, so the known limits do not bite back in production.
A cache stores temporary copies of server responses and treats the server as the source of truth. An offline database like RxDB makes the local data the source of truth for the client. Writes succeed locally without a network connection, and the Sync Engine merges local and remote changes in the background, including conflict resolution.
</FaqItem> <FaqItem question="Is IndexedDB an offline database?">No, not on its own. IndexedDB is a low-level browser storage API without sync, observable queries, or a schema. An offline database builds these features on top of a storage. RxDB uses IndexedDB as one of several RxStorage options and adds replication, reactivity, encryption, and Mango queries.
</FaqItem> <FaqItem question="How does an offline database sync data with the server?">RxDB syncs through its Sync Engine, which needs only three endpoints on the backend: a pull handler for changed documents after a checkpoint, a push handler for client writes, and an event stream for live changes. All transfers run in batches, and conflicts are resolved on the client. This works with any backend, from a REST API over GraphQL to services like Supabase or Firestore.
</FaqItem> <FaqItem question="What happens when two users edit the same document while offline?">The first push to the server wins. The second client's push is rejected, and RxDB calls the collection's conflict handler on that client to produce a merged state, which is then pushed again. By default the server state wins, but a custom conflict handler or the CRDT plugin can merge both changes field by field.
</FaqItem> <FaqItem question="Can I encrypt data in an offline database?">Yes. RxDB provides encryption plugins that encrypt marked fields at rest, on top of any storage. The data is only readable with the database password. Encrypted fields cannot be used inside of query selectors, so keep fields you need to query against unencrypted.
</FaqItem> <FaqItem question="Which offline database should I use for web and mobile apps?">RxDB is a strong choice when you need one codebase across platforms, because it runs in the browser, React Native, Capacitor, Electron, and Node.js, and it can sync with any backend. Firebase fits when you want a fully managed cloud backend and accept vendor lock-in. Plain SQLite fits native apps that need SQL but no automatic sync.
</FaqItem> </Faq>