Daily-It

개발, AI, 인프라, 자동화와 일상 IT 제품 후기를 직접 써보며 정리하는 기술 블로그입니다.

WatermelonDB Review: How I Would Use It in My Next React Native Project

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

Original Korean version: This article is based on the Korean version and lightly adapted for English readers. Read the original Korean post.
Please show some love to Korean, too.