Realm Reactive Queries: Automatic UI Sync Without the Boilerplate
Realm's reactive queries push database changes to your UI automatically—no polling, no manual listeners. This blog walks through the platform-specific APIs, a Kotlin Flow example, threading gotchas, and a verification test you can run today.
12 Aug 2025, 18:05 UTC

The Problem: Polling Is a Waste
You're building a chat screen. New messages arrive, users edit their own messages, and the UI needs to reflect every change instantly. The classic approach: register a listener, poll the database every few seconds, or manually refresh after every write. All three add boilerplate, risk stale reads, and complicate threading.
Realm's reactive queries solve this differently. You define a query once, observe it as a stream, and the database pushes changes to your UI automatically. No polling, no manual invalidation, no custom observer pattern.
How Reactive Queries Work
Realm's change notification engine tracks which objects match a query. When a write transaction commits, the engine evaluates whether the result set changed. If it did, it emits a new snapshot to all active observers. This happens on the thread where you started the observation—typically the Android main thread or iOS main queue.
Key terms:
- Realm: the embedded database instance holding your data.
- Query: a filter like
realm.where(Message.class).equalTo("channelId", channelId). - Observable: a Flow (Kotlin), Publisher (Swift), or RealmResults (Java) that emits each time the result set changes.
Platform Differences You'll Hit
The observation semantics are consistent, but the APIs diverge:
| Platform | Observation API | Thread Delivery |
|---|---|---|
| Realm Java 10+ | findAllAsync().asFlow() or addChangeListener() | Main thread (Looper.getMainLooper()) |
| Realm Kotlin | query.findAll().asFlow() | Configured coroutine dispatcher |
| Realm Swift | results.observe { changes in ... } | Main queue |
If you share business logic across platforms, wrap each platform's observation in a common interface. The query definition stays the same; only the subscription code changes.
Worked Example: Kotlin Flow on Android
Assume a Message object with channelId, text, and timestamp. You want a live list for one channel.
// 1. Open a Realm instance (do this on a background thread for heavy init)
val realm = Realm.getDefaultInstance()
// 2. Define the query
val query = realm.where(Message::class.java)
.equalTo("channelId", channelId)
.sort("timestamp", Sort.ASCENDING)
// 3. Observe as a Kotlin Flow
val messagesFlow = query.findAllAsync().asFlow()
// 4. Collect in a lifecycle-aware scope
lifecycleScope.launchWhenStarted {
messagesFlow.collect { realmResults -
// realmResults is a live, auto-updating RealmResults
adapter.submitList(realmResults)
}
}
Where to run: In a ViewModel or Fragment/Activity with a lifecycleScope. Permissions: None beyond Realm's normal file access. Placeholders: channelId comes from your navigation args. Expected check: Insert a Message in a write transaction; the adapter should receive the new item within the same frame. Risk: Collecting on the main thread is fine for small result sets. For thousands of objects, consider findAllAsync() with pagination or background mapping before UI submission.
Trade-offs and Limitations
- Thread affinity: Observers fire on the thread that started the observation. Blocking that thread (e.g., heavy JSON parsing inside
collect) causes ANRs or UI jank. Offload withwithContext(Dispatchers.IO)before heavy work. - Memory pressure: Each active observation holds a reference to its
RealmResults. Close the Realm instance or cancel the coroutine when the screen dies. - Schema changes: Reactive queries require
isSchemaEnabledinRealmConfiguration(Java 10+). Migration scripts must handle added/removed fields or the observer will crash on schema mismatch. - Pre-10 versions: No native reactive API. You'd need
addChangeListeneronRealmResultsand manual Flow wrapping.
Verifying It Works in Your Project
Write a small instrumented test:
@Test
fun reactiveQueryEmitsOnInsert() = runTest {
val realm = Realm.getDefaultInstance()
val query = realm.where(Message::class.java).equalTo("channelId", 1)
val flow = query.findAllAsync().asFlow()
val results = mutableListOf>()
val job = launch { flow.collect { results.add(it) } }
realm.executeTransaction { r -
r.createObject(Message::class.java).apply {
channelId = 1
text = "hello"
timestamp = System.currentTimeMillis()
}
}
advanceUntilIdle()
assertEquals(1, results.last().size)
assertEquals("hello", results.last()[0].text)
job.cancel()
realm.close()
}
Run this on an emulator or device (not Robolectric) because Realm's native core needs a real filesystem. The test confirms the flow emits, the snapshot contains the inserted object, and properties match.
Closing Checklist
- Pick the observation API matching your platform and language version.
- Start observation on the UI thread (or configured dispatcher) and cancel in
onDestroy/onCleared. - Keep write transactions short; long transactions delay notifications.
- Test with a realistic dataset size—100 items behaves differently than 10,000.
- Profile main-thread time during observation callbacks; move mapping/filtering off the main thread if needed.
Reactive queries remove the polling boilerplate, but they shift responsibility to you: manage lifecycles, respect thread affinity, and test with production-scale data. Do that, and your UI stays in sync without you thinking about it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.