Migration Guide: WatermelonDB → ObjectBox for React Native¶
A practical, step-by-step guide for migrating from WatermelonDB to ObjectBox in a React Native application.
Table of Contents¶
- Pre-Migration Assessment
- Architecture Overview
- Data Model Mapping
- Phase 1: Proof of Concept
- Phase 2: Full Migration
- Phase 3: Sync Implementation
- Testing Strategy
- Troubleshooting
- Rollback Plan
Pre-Migration Assessment¶
Questions to Answer¶
1. Why migrate? - [ ] Performance bottlenecks - [ ] Battery life concerns - [ ] P2P sync requirements - [ ] Resource constraints - [ ] Team preference - [ ] New features needed (vector search, etc.)
2. What's your current data volume? - [ ] < 1,000 records (small) - [ ] 1,000-10,000 records (medium) - [ ] 10,000-100,000 records (large) - [ ] 100,000+ records (very large)
3. Sync complexity level? - [ ] Simple (pull/push with last-write-wins) - [ ] Medium (custom conflict resolution) - [ ] Complex (multi-device, offline-first critical)
4. Risk tolerance? - [ ] High (can accept potential issues, fast timeline) - [ ] Medium (careful testing, balanced timeline) - [ ] Low (thorough testing, longer timeline)
Timeline Estimate¶
| Data Volume | Sync Complexity | Risk Tolerance | Estimated Time |
|---|---|---|---|
| Small | Simple | High | 3-4 weeks |
| Small | Simple | Low | 5-6 weeks |
| Medium | Medium | High | 6-8 weeks |
| Medium | Medium | Low | 8-10 weeks |
| Large | Complex | Any | 10-14+ weeks |
Architecture Overview¶
WatermelonDB Architecture (Current)¶
React Native App
↓
React Components
↓
WatermelonDB Layer
↓
├─ Database (SQLite)
├─ Collections (tables)
├─ Models (decorators)
└─ Relations
↓
Custom Sync Engine
↓
Backend (REST/GraphQL)
Key Characteristics: - JavaScript abstraction layer - Reactive (RxJS) for automatic UI updates - Custom sync implementation - Flexible data access patterns
ObjectBox Architecture (Target)¶
React Native App
↓
React Native Components
↓
Objective-C/Kotlin Bridge
↓
ObjectBox Layer
↓
├─ C++ Database Engine
├─ Entities (native classes)
├─ Boxes (type-safe access)
└─ Relations
↓
Built-in Sync Engine
↓
Backend
Key Characteristics: - Native execution (C++ core) - Type-safe entity access - Built-in sync (less custom code) - Manual reactivity via observers
Data Model Mapping¶
Step 1: Inventory Current Models¶
WatermelonDB Example (Current):
// models/Task.js
import { Model } from '@nozbe/watermelondb'
import { field, relation } from '@nozbe/watermelondb/decorators'
export default class Task extends Model {
static table = 'tasks'
static associations = {
project: { type: 'belongs_to', key: 'project_id' }
}
@field('title') title
@field('description') description
@field('completed') completed
@field('priority') priority // 'low', 'medium', 'high'
@field('due_date') dueDate
@field('project_id') projectId
@field('sync_status') syncStatus // 'pending', 'synced', 'error'
@relation('project', 'project_id') project
}
Database Schema:
// db/schema.js
import { appSchema, tableSchema } from '@nozbe/watermelondb'
export const schema = appSchema({
version: 5,
tables: [
tableSchema({
name: 'projects',
columns: [
{ name: 'name', type: 'string' },
{ name: 'created_at', type: 'number' },
{ name: 'updated_at', type: 'number' }
]
}),
tableSchema({
name: 'tasks',
columns: [
{ name: 'title', type: 'string' },
{ name: 'description', type: 'string', isOptional: true },
{ name: 'completed', type: 'boolean' },
{ name: 'priority', type: 'string' },
{ name: 'due_date', type: 'number', isOptional: true },
{ name: 'project_id', type: 'string', isIndexed: true },
{ name: 'sync_status', type: 'string' },
{ name: 'created_at', type: 'number' },
{ name: 'updated_at', type: 'number' }
]
})
]
})
Step 2: Map to ObjectBox Entities¶
ObjectBox Equivalent:
// For iOS (Swift)
import ObjectBox
// MARK: - Project Entity
@Entity
final class Project {
@Id var id: UInt64 = 0
var name: String = ""
var createdAt: Date = Date()
var updatedAt: Date = Date()
@Backlink("project") var tasks: [Task] = []
}
// MARK: - Task Entity
@Entity
final class Task {
@Id var id: UInt64 = 0
var title: String = ""
var description: String? = nil
var completed: Bool = false
var priority: String = "medium" // 'low', 'medium', 'high'
var dueDate: Date? = nil
var projectId: UInt64 = 0
var syncStatus: String = "pending" // 'pending', 'synced', 'error'
var createdAt: Date = Date()
var updatedAt: Date = Date()
var project: ToOne<Project> = ToOne()
}
// For Android (Kotlin)
import io.objectbox.annotation.*
// Project entity
@Entity
data class Project(
@Id var id: Long = 0,
var name: String = "",
var createdAt: Long = System.currentTimeMillis(),
var updatedAt: Long = System.currentTimeMillis()
) {
@Backlink("project")
lateinit var tasks: List<Task>
}
// Task entity
@Entity
data class Task(
@Id var id: Long = 0,
var title: String = "",
var description: String? = null,
var completed: Boolean = false,
var priority: String = "medium",
var dueDate: Long? = null,
var projectId: Long = 0,
var syncStatus: String = "pending",
var createdAt: Long = System.currentTimeMillis(),
var updatedAt: Long = System.currentTimeMillis(),
var project: ToOne<Project> = ToOne()
)
Step 3: Create Migration Mapping¶
| Concept | WatermelonDB | ObjectBox | Notes |
|---|---|---|---|
| Table | tableSchema() |
@Entity |
Define in native code |
| Column | { name, type } |
Property with type | Type-safe in ObjectBox |
| Primary Key | Auto in schema | @Id annotation |
ObjectBox auto-generates |
| Relation 1-N | @relation() |
ToOne<> / @Backlink |
More explicit in ObjectBox |
| Timestamps | number (ms) |
Long or Date |
Choose your format |
| Sync State | Field | Field | Track in your entity |
| Null/Optional | isOptional: true |
? (nullable) |
Cleaner syntax |
Phase 1: Proof of Concept¶
Week 1: Setup ObjectBox Alongside WatermelonDB¶
Objective: Run both databases in parallel, test ObjectBox on one feature
Step 1: Install ObjectBox¶
iOS:
Android:
// android/app/build.gradle
dependencies {
implementation "io.objectbox:objectbox-kotlin:latest.release"
}
plugins {
id "io.objectbox" version "latest.release"
}
Step 2: Create ObjectBox Service (Separate from WatermelonDB)¶
// services/ObjectBoxService.js
import { NativeModules } from 'react-native'
const ObjectBox = NativeModules.ObjectBox
class ObjectBoxService {
constructor() {
this.initialized = false
}
async init() {
try {
await ObjectBox.initialize()
this.initialized = true
console.log('ObjectBox initialized')
} catch (error) {
console.error('ObjectBox init error:', error)
}
}
async getTasks(projectId) {
if (!this.initialized) await this.init()
return ObjectBox.queryTasks({ projectId })
}
async saveTask(task) {
if (!this.initialized) await this.init()
return ObjectBox.putTask(task)
}
async deleteTask(taskId) {
if (!this.initialized) await this.init()
return ObjectBox.removeTask(taskId)
}
async sync() {
if (!this.initialized) await this.init()
return ObjectBox.startSync()
}
}
export default new ObjectBoxService()
Step 3: Run POC on Single Feature¶
Create a test screen that uses ObjectBox for one feature (e.g., viewing tasks):
// screens/TasksListObjectBox.js
import React, { useState, useEffect } from 'react'
import { View, FlatList, Text } from 'react-native'
import ObjectBoxService from '../services/ObjectBoxService'
export const TasksListObjectBox = ({ projectId }) => {
const [tasks, setTasks] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const loadTasks = async () => {
setLoading(true)
try {
const data = await ObjectBoxService.getTasks(projectId)
setTasks(data)
} catch (error) {
console.error('Load error:', error)
} finally {
setLoading(false)
}
}
loadTasks()
}, [projectId])
if (loading) return <Text>Loading...</Text>
return (
<FlatList
data={tasks}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View>
<Text>{item.title}</Text>
<Text>{item.completed ? '✓' : '○'}</Text>
</View>
)}
/>
)
}
Step 4: Measure Performance¶
// utils/benchmark.js
export async function benchmarkObjectBox() {
const times = {}
// Single insert
console.time('insert-1')
await ObjectBoxService.saveTask({ title: 'Test' })
times.insert1 = console.timeEnd('insert-1')
// Bulk insert
console.time('insert-1000')
const tasks = Array.from({ length: 1000 }, (_, i) => ({
title: `Task ${i}`
}))
await Promise.all(tasks.map(t => ObjectBoxService.saveTask(t)))
times.insert1000 = console.timeEnd('insert-1000')
// Query
console.time('query')
await ObjectBoxService.getTasks(projectId)
times.query = console.timeEnd('query')
return times
}
Step 5: Decide: Go/No-Go¶
Evaluation Criteria: - [ ] Performance meets targets - [ ] No crashes or errors - [ ] Developer experience acceptable - [ ] Battery consumption acceptable - [ ] Team is confident
If No-Go: Identify blockers, iterate on POC, or reconsider choice
If Go-Go: Proceed to Phase 2
Phase 2: Full Migration¶
Week 2-3: Create ObjectBox Repository¶
Create an abstraction layer to hide database implementation:
// repositories/TaskRepository.js
class TaskRepository {
async getTasks(projectId) {
// Implementation varies by database
}
async saveTask(task) {
// Implementation varies by database
}
async deleteTask(taskId) {
// Implementation varies by database
}
observeTasks(projectId) {
// Return observable/promise
}
async syncWithBackend() {
// Sync implementation
}
}
export const taskRepository = new TaskRepository()
WatermelonDB Implementation (Current):
// repositories/WatermelonTaskRepository.js
import { taskRepository } from './TaskRepository'
import { database } from '../db/database'
class WatermelonTaskRepository extends taskRepository {
async getTasks(projectId) {
const collection = database.collections.get('tasks')
return collection
.query(where('project_id', 'eq', projectId))
.fetch()
}
async saveTask(task) {
const collection = database.collections.get('tasks')
return collection.create((newTask) => {
newTask.title = task.title
newTask.completed = task.completed
// ...other fields
})
}
observeTasks(projectId) {
return database.collections
.get('tasks')
.query(where('project_id', 'eq', projectId))
.observe()
}
}
export const taskRepository = new WatermelonTaskRepository()
ObjectBox Implementation (New):
// repositories/ObjectBoxTaskRepository.js
import { taskRepository } from './TaskRepository'
import ObjectBoxService from '../services/ObjectBoxService'
class ObjectBoxTaskRepository extends taskRepository {
async getTasks(projectId) {
return ObjectBoxService.getTasks(projectId)
}
async saveTask(task) {
return ObjectBoxService.saveTask(task)
}
async deleteTask(taskId) {
return ObjectBoxService.deleteTask(taskId)
}
observeTasks(projectId) {
// ObjectBox doesn't have built-in observables
// Implement observer pattern
return {
subscribe: (callback) => {
const pollInterval = setInterval(async () => {
const tasks = await this.getTasks(projectId)
callback(tasks)
}, 1000) // Poll every second
return () => clearInterval(pollInterval)
}
}
}
}
export const taskRepository = new ObjectBoxTaskRepository()
Week 3-4: Migrate Components¶
Replace WatermelonDB queries with repository calls:
Before (WatermelonDB):
const TasksScreen = ({ projectId }) => {
const [tasks, setTasks] = useState([])
useEffect(() => {
const subscription = database.collections
.get('tasks')
.query(where('project_id', 'eq', projectId))
.observe()
.subscribe(setTasks)
return () => subscription.unsubscribe()
}, [projectId])
return <TaskList tasks={tasks} />
}
After (Repository Pattern):
const TasksScreen = ({ projectId }) => {
const [tasks, setTasks] = useState([])
useEffect(() => {
const subscription = taskRepository
.observeTasks(projectId)
.subscribe(setTasks)
return () => subscription.unsubscribe()
}, [projectId])
return <TaskList tasks={tasks} />
}
Week 4: Data Migration¶
Option A: Export & Import (Simpler)
// migration/exportWatermelon.js
export async function exportTasksFromWatermelon() {
const tasks = await database.collections
.get('tasks')
.query()
.fetch()
const data = tasks.map(t => ({
id: t.id,
title: t.title,
completed: t.completed,
projectId: t.projectId,
dueDate: t.dueDate,
priority: t.priority,
createdAt: t.createdAt,
updatedAt: t.updatedAt,
syncStatus: t.syncStatus
}))
return data
}
// migration/importToObjectBox.js
export async function importTasksToObjectBox(tasks) {
for (const task of tasks) {
await ObjectBoxService.saveTask(task)
}
}
// migration/runMigration.js
export async function migrateData() {
console.log('Exporting from WatermelonDB...')
const tasks = await exportTasksFromWatermelon()
console.log(`Exporting ${tasks.length} tasks...`)
await importTasksToObjectBox(tasks)
console.log('Migration complete!')
}
Call during app boot (once):
// App.js
useEffect(() => {
const runMigration = async () => {
const hasMigrated = await AsyncStorage.getItem('migratedToObjectBox')
if (!hasMigrated) {
console.log('Running data migration...')
await migrateData()
await AsyncStorage.setItem('migratedToObjectBox', 'true')
}
}
runMigration()
}, [])
Option B: Dual-Write (More Complex but Safer)
// repositories/DualWriteRepository.js
class DualWriteRepository {
async saveTask(task) {
// Write to both databases
const watermelonResult = await watermelonRepo.saveTask(task)
const objectboxResult = await objectboxRepo.saveTask(task)
return objectboxResult // Use ObjectBox result
}
async getTasks(projectId) {
// Read from ObjectBox if available, fall back to WatermelonDB
try {
const objectboxTasks = await objectboxRepo.getTasks(projectId)
if (objectboxTasks.length > 0) return objectboxTasks
} catch (e) {
console.warn('ObjectBox query failed, using WatermelonDB')
}
return watermelonRepo.getTasks(projectId)
}
}
Phase 3: Sync Implementation¶
Understanding ObjectBox Sync¶
ObjectBox provides a sync protocol that: 1. Sends changes to sync server in real-time 2. Receives changes from sync server 3. Automatically handles conflicts via CRDTs 4. Queues changes when offline
Setup Sync Server¶
Option 1: Use ObjectBox Cloud (Managed)
// iOS setup
let syncClient = Sync.client(store, "wss://your-objectbox-sync-server.com")
.buildAndStart()
Option 2: Self-Hosted
// Point to your own server
let syncClient = Sync.client(store, "wss://your-backend.com:9999")
.buildAndStart()
Implement Sync Service¶
// services/SyncService.js
import { NativeModules } from 'react-native'
class SyncService {
constructor() {
this.syncClient = null
this.isOnline = true
}
async initializeSync(serverUrl) {
try {
this.syncClient = await NativeModules.ObjectBox.initializeSync(
serverUrl
)
console.log('Sync initialized')
} catch (error) {
console.error('Sync initialization failed:', error)
}
}
async startSync() {
return NativeModules.ObjectBox.startSync()
}
async stopSync() {
return NativeModules.ObjectBox.stopSync()
}
// Listen to sync events
observeSyncStatus() {
return {
subscribe: (callback) => {
const subscription = NativeModules.ObjectBox.observeSyncStatus(
(status) => {
// status: { isOnline, pendingChanges, lastSyncTime }
callback(status)
}
)
return subscription
}
}
}
}
export default new SyncService()
Remove Custom WatermelonDB Sync¶
Before (Custom):
// Remove this custom sync code
const syncDatabase = async (db) => {
// Custom pull logic
const response = await fetch('/api/sync', {
method: 'GET',
body: JSON.stringify({ lastPulledAt: db.lastPulledAt })
})
const { changes } = await response.json()
// Apply changes
await db.action(async () => {
// ... custom merge logic
})
// Custom push logic
// ... more custom code
}
After (Built-in):
// ObjectBox handles all of this automatically
// Just initialize once:
SyncService.initializeSync('wss://your-backend.com')
SyncService.startSync() // Runs in background
Handle Offline Scenarios¶
// hooks/useNetworkStatus.js
import { useEffect, useState } from 'react'
import NetInfo from '@react-native-community/netinfo'
export function useNetworkStatus() {
const [isOnline, setIsOnline] = useState(true)
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
setIsOnline(state.isConnected)
})
return unsubscribe
}, [])
return isOnline
}
// UI feedback
const TasksScreen = () => {
const isOnline = useNetworkStatus()
return (
<View>
{!isOnline && <OfflineBanner />}
<TaskList />
</View>
)
}
Testing Strategy¶
Unit Tests¶
// __tests__/TaskRepository.test.js
import { ObjectBoxTaskRepository } from '../repositories/ObjectBoxTaskRepository'
describe('ObjectBoxTaskRepository', () => {
let repo
beforeEach(() => {
repo = new ObjectBoxTaskRepository()
})
it('should save a task', async () => {
const task = {
title: 'Test Task',
completed: false,
projectId: '123'
}
const saved = await repo.saveTask(task)
expect(saved).toHaveProperty('id')
expect(saved.title).toBe('Test Task')
})
it('should query tasks by project', async () => {
const projectId = '123'
const tasks = await repo.getTasks(projectId)
expect(Array.isArray(tasks)).toBe(true)
})
it('should delete a task', async () => {
const task = await repo.saveTask({ title: 'Delete me' })
await repo.deleteTask(task.id)
const found = await repo.getTasks(task.projectId)
expect(found.find(t => t.id === task.id)).toBeUndefined()
})
})
Integration Tests¶
// __tests__/sync.integration.test.js
describe('Sync Integration', () => {
it('should sync new task to server', async () => {
// Create task offline
const task = await repo.saveTask({
title: 'Integration test task',
syncStatus: 'pending'
})
// Simulate network connection
networkMock.connect()
// Start sync
await syncService.startSync()
// Wait for sync
await waitFor(() => {
expect(serverMock.tasks).toContainEqual(
expect.objectContaining({ title: 'Integration test task' })
)
}, { timeout: 5000 })
})
it('should handle sync conflicts', async () => {
// Create task
const task = await repo.saveTask({ title: 'Conflict test' })
// Offline: User A edits
networkMock.disconnect()
await repo.saveTask({ ...task, title: 'User A edit' })
// Server change: User B edited
serverMock.updateTask({ ...task, title: 'User B edit' })
// Online: Sync runs
networkMock.connect()
await syncService.startSync()
// CRDT should resolve (which version wins?)
const synced = await repo.getTasks(task.projectId)
const updated = synced.find(t => t.id === task.id)
expect(updated.title).toBeDefined() // Should have a title
})
})
Performance Tests¶
// __tests__/performance.test.js
describe('ObjectBox Performance', () => {
it('should insert 1000 records in < 100ms', async () => {
const tasks = Array.from({ length: 1000 }, (_, i) => ({
title: `Task ${i}`
}))
const start = performance.now()
for (const task of tasks) {
await repo.saveTask(task)
}
const elapsed = performance.now() - start
expect(elapsed).toBeLessThan(100)
})
it('should query 1000 records in < 10ms', async () => {
const start = performance.now()
await repo.getTasks('123')
const elapsed = performance.now() - start
expect(elapsed).toBeLessThan(10)
})
})
Battery/Resource Tests¶
// __tests__/battery.test.js
describe('Battery Usage', () => {
it('should not drain battery significantly', async () => {
const batteryBefore = await getBatteryLevel()
// Run sync for 1 hour
await syncService.startSync()
await sleep(3600000)
await syncService.stopSync()
const batteryAfter = await getBatteryLevel()
const drain = batteryBefore - batteryAfter
expect(drain).toBeLessThan(5) // Less than 5% drain
})
})
Troubleshooting¶
Issue 1: ObjectBox Native Module Not Found¶
Symptom:
Solution:
# Clear native build cache
rm -rf node_modules/@react-native-firebase
rm -rf ios/Pods
rm -rf android/.gradle
rm -rf android/app/build
# Rebuild
npm install
cd ios && pod install && cd ..
react-native run-ios
Issue 2: Sync Not Working¶
Symptom:
Debugging:
// Check sync status
const status = await SyncService.getSyncStatus()
console.log('Sync status:', status)
// {
// isOnline: true,
// isConnected: true,
// lastSyncTime: 1234567890,
// pendingChanges: 0
// }
// Check network connectivity
const isOnline = await NetInfo.fetch()
console.log('Network:', isOnline)
// Check server URL
console.log('Sync server:', SYNC_SERVER_URL)
Issue 3: Data Loss During Migration¶
Symptom:
Prevention:
// Verify counts match
const watermelonCount = await watermelonRepo.getCount()
const objectboxCount = await objectboxRepo.getCount()
if (watermelonCount !== objectboxCount) {
throw new Error(`Count mismatch: ${watermelonCount} vs ${objectboxCount}`)
}
// Validate data integrity
const watermelonTasks = await watermelonRepo.getTasks()
const objectboxTasks = await objectboxRepo.getTasks()
const missing = watermelonTasks.filter(wt =>
!objectboxTasks.find(ot => ot.id === wt.id)
)
if (missing.length > 0) {
console.error('Missing tasks:', missing)
}
Issue 4: Performance Worse Than WatermelonDB¶
Symptoms: - Slow queries - High memory usage - Battery drain
Optimizations:
// Android: Add indexing
@Entity
data class Task(
@Id var id: Long = 0,
@Index var projectId: Long = 0,
@Index var syncStatus: String = "",
// ... other fields
)
// iOS: Optimize queries
let builder = store.boxFor(Task.self)
.query(Task.projectId == projectId)
.order(by: Task.createdAt, flags: .descending)
// Limit results
let tasks = try builder.find(limit: 100)
Rollback Plan¶
If Critical Issues Found¶
Day 1 (Immediate): 1. Stop ObjectBox sync 2. Identify issue 3. Revert to WatermelonDB on app
Code:
// Fallback logic
const useRepository = async () => {
try {
// Try ObjectBox
return ObjectBoxRepository
} catch (error) {
console.warn('ObjectBox failed, using WatermelonDB')
return WatermelonRepository
}
}
Deploy:
Day 2-3 (Investigation): 1. Debug root cause 2. Create fix 3. Test thoroughly 4. Release fixed version
Day 4+ (Recovery): 1. Migrate again with fix in place 2. Verify thoroughly 3. Monitor closely
Success Criteria¶
Before declaring migration complete:
- [ ] All features working in ObjectBox
- [ ] Performance targets met (query time, battery, etc.)
- [ ] Sync reliable and tested
- [ ] Data integrity verified
- [ ] Team trained on new system
- [ ] Documentation updated
- [ ] Monitoring in place (crash rates, sync issues)
- [ ] No regressions in user-facing features
- [ ] 1 week of production stability
Conclusion¶
Migration from WatermelonDB to ObjectBox is feasible and can provide significant benefits, but requires:
- Planning - Assess current state thoroughly
- Patience - Take 8-12 weeks minimum
- Testing - Comprehensive test coverage
- Monitoring - Watch metrics closely during rollout
- Communication - Keep team informed throughout
The repository pattern abstraction is key—it lets you swap implementations with minimal risk.
Good luck with your migration!