When a React Native app starts small, AsyncStorage or a thin SQLite wrapper can feel like enough. But once the app needs offline lists, detail screens, search, and later synchronization with a server, the choice starts to matter. That is when I came back to WatermelonDB.
I had already written an introductory article about WatermelonDB before. This time I wanted to write more like a usage review: what felt good, what felt heavy, and how I would set it up in the next project.
Related posts
Why I looked at WatermelonDB again
The reason was simple. The amount of local data kept growing, network conditions could not always be trusted, and users needed to continue some work while offline. WatermelonDB describes itself as a reactive database framework for React and React Native apps that can scale from hundreds to tens of thousands of records. On React Native it uses a SQLite adapter, and on the web it can use LokiJS.
The important idea is lazy loading. Instead of loading the whole database into JavaScript memory at startup, it fetches the data that a screen actually asks for. As a backend developer, this feels natural. On the server side, we also do not load everything into memory. We query by condition, use indexes, and manage change boundaries.
What felt good
1. Lower startup pressure
The biggest advantage is that WatermelonDB is better suited for apps with many records. State management plus persistence is convenient while the data is small, but as the stored JSON grows, startup and restore costs become painful. WatermelonDB lets SQLite handle queries and lets each screen request only what it needs.
2. React screens can react to local data changes
WatermelonDB connects models and queries to screens through withObservables. The HOC style can feel old compared with modern hook-first React, but the data flow is clear. When local data changes, the relevant screen can update without manually refetching everything.
3. Offline-first design becomes more explicit
WatermelonDB is only a local database. You still have to build the backend sync API yourself. That is also a downside, but from a backend point of view, it is useful because it forces the sync contract to be explicit: pullChanges, pushChanges, lastPulledAt, schemaVersion, and migration information.
What the numbers say
I could not find an official WatermelonDB benchmark that compares WatermelonDB, SQLite wrappers, and AsyncStorage under one standardized condition. There is even a WatermelonDB GitHub issue asking for a benchmark against AsyncStorage. So the numbers below should be read as public reference points, not as an official guarantee.
React Native storage get benchmark
Marc Rousavy’s StorageBenchmark tested 1,000 single-string get operations on React Native 0.68, Hermes, iPhone 11 Pro, Debug build.
| Storage | 1,000 get operations | Note |
|---|---|---|
| react-native-mmkv | 12ms | Fastest in this test |
| WatermelonDB | 53ms | Faster than AsyncStorage, with a DB/ORM layer |
| RealmDB | 81ms | Object database |
| react-native-quick-sqlite | 82ms | SQLite wrapper |
| AsyncStorage | 242ms | Simple key-value storage |
In that specific test, WatermelonDB was about 4.5x faster than AsyncStorage and slightly faster than quick-sqlite. But the test only measures repeated single-value reads. List queries, relations, sync, bulk writes, release builds, and low-end Android devices can change the result.
Local-first web comparison
The client-side-databases project compares several local-first databases using an Angular web chat app. Its WatermelonDB result uses the LokiJS adapter, not React Native SQLite, so I would not copy the result directly into a mobile decision. Still, it shows the kind of behavior WatermelonDB can have in a local-first app.
| Metric | WatermelonDB | What stood out |
|---|---|---|
| First full render | 275ms | Fast among the compared candidates |
| Insert one message | 5ms | Very fast in this benchmark |
| Insert 20 messages one after another | 107ms | Faster than Firebase 4639ms and PouchDB 241ms |
| Message insert to list change | 4ms | Good UI reaction time |
| Message search query time | 23ms | Close to RxDB LokiJS at 22ms |
| Storage usage | 2164kb | Fast, but not the smallest storage footprint |
My takeaway is simple. AsyncStorage is still convenient for settings, flags, and small key-value data. A SQLite wrapper is good when I want full SQL control. WatermelonDB sits in the middle: it gives me local database structure, relations, observable UI updates, and a sync-friendly model.
What felt heavy
1. Setup is not lightweight
WatermelonDB is not a “install and forget” library. React Native setup includes the package, Babel decorators, schema, migrations, models, adapter, and the database object. TypeScript, decorators, React Native version, New Architecture, Expo, Hermes, and JSI all need to be checked early.
2. Sync is still your responsibility
The library provides sync primitives and a sync flow, but the backend endpoints are yours. The server has to return created, updated, and deleted records for pull, and it has to apply pushed changes transactionally. Conflict handling should not be treated as an afterthought.
3. Schema and migrations matter from day one
Local databases live on user devices. Once shipped, they are not as easy to fix as a server database. Table names, column names, server ID versus local ID, delete policy, default values, and migration failure handling should be designed before the feature code grows.
How I would start the next project
I would first decide which data WatermelonDB should own. Offline lists, details, and syncable business records are good candidates. Simple tokens, feature flags, temporary UI state, and large binary files are not.
| Data type | Fit for WatermelonDB | Reason |
|---|---|---|
| Offline list/detail data | High | Local query and fast screen loading matter |
| Syncable business data | High | Works well with pull/push change tracking |
| Settings, tokens, flags | Low | AsyncStorage or secure storage is simpler |
| Temporary UI state | Low | React state or a small state store is better |
| Large attachments | Low | Store files separately and keep only paths/metadata in DB |
Basic installation
npm install @nozbe/watermelondb
npm install -D @babel/plugin-proposal-decorators
{
"presets": ["module:metro-react-native-babel-preset"],
"plugins": [
["@babel/plugin-proposal-decorators", { "legacy": true }]
]
}
Schema and migration first
import { appSchema, tableSchema } from '@nozbe/watermelondb'
export const mySchema = appSchema({
version: 1,
tables: [
tableSchema({
name: 'projects',
columns: [
{ name: 'server_id', type: 'string', isIndexed: true },
{ name: 'name', type: 'string' },
{ name: 'updated_at', type: 'number' },
{ name: 'is_deleted', type: 'boolean' },
],
}),
],
})
import { schemaMigrations } from '@nozbe/watermelondb/Schema/migrations'
export default schemaMigrations({
migrations: [
// Add version 2+ migrations here
],
})
Database object in one place
import { Database } from '@nozbe/watermelondb'
import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite'
import { mySchema } from './schema'
import migrations from './migrations'
import Project from './Project'
const adapter = new SQLiteAdapter({
schema: mySchema,
migrations,
jsi: true,
onSetUpError: error => {
console.error('WatermelonDB setup failed', error)
},
})
export const database = new Database({
adapter,
modelClasses: [Project],
})
jsi: true can help performance, but it affects the debugging path. I would decide the debugging method together with the database setup.
Writes inside writers, queries with conditions
export async function createProject(name: string) {
return database.write(async () => {
return database.get('projects').create(project => {
project.serverId = ''
project.name = name
project.updatedAt = Date.now()
project.isDeleted = false
})
})
}
import { Q } from '@nozbe/watermelondb'
export function observeProjectTasks(projectId: string) {
return database
.get('tasks')
.query(
Q.where('project_id', projectId),
Q.where('done', false),
Q.sortBy('updated_at', Q.desc),
)
.observe()
}
I would avoid fetching everything and filtering in JavaScript. If I am choosing a local database, I should let the database do database work.
Conclusion
WatermelonDB is not just a convenient local storage library. It is closer to a small local database architecture. If the app is simple, it can be too much. But if a React Native app needs offline-first behavior, thousands of records, fast lists, and synchronization, it is still worth considering.
In the next project, I would not say “we can add sync later.” I would design schema, migration, IDs, delete policy, and sync API from the beginning. That is the only way WatermelonDB feels strong instead of heavy.
References
- WatermelonDB documentation
- WatermelonDB Installation
- WatermelonDB Setup
- WatermelonDB Schema
- WatermelonDB Model
- WatermelonDB Querying
- WatermelonDB Writers, Readers, Batching
- WatermelonDB Sync Intro
- Nozbe/WatermelonDB GitHub repository
- WatermelonDB GitHub Issue #1006
- StorageBenchmark
- client-side-databases
Please show some love to Korean, too.