Skip to content

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

  1. Pre-Migration Assessment
  2. Architecture Overview
  3. Data Model Mapping
  4. Phase 1: Proof of Concept
  5. Phase 2: Full Migration
  6. Phase 3: Sync Implementation
  7. Testing Strategy
  8. Troubleshooting
  9. 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:

cd ios
pod install ObjectBox
cd ..

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:

ObjectBox native module not found

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:

Changes not syncing to server

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:

Some records missing after migration

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:

# Release with fallback enabled
npm version patch
# Commit and release to app stores

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:

  1. Planning - Assess current state thoroughly
  2. Patience - Take 8-12 weeks minimum
  3. Testing - Comprehensive test coverage
  4. Monitoring - Watch metrics closely during rollout
  5. Communication - Keep team informed throughout

The repository pattern abstraction is key—it lets you swap implementations with minimal risk.

Good luck with your migration!