diff --git a/src/main/kotlin/org/briarproject/briar/desktop/DesktopModule.kt b/src/main/kotlin/org/briarproject/briar/desktop/DesktopModule.kt
index 634780f9c938b0e62cdcee96d2b11d093474e63f..9501212b6c92e77f1d8d40e012dd44af79a6503c 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/DesktopModule.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/DesktopModule.kt
@@ -32,6 +32,7 @@ import org.briarproject.bramble.util.OsUtils.isLinux
 import org.briarproject.bramble.util.OsUtils.isMac
 import org.briarproject.briar.desktop.ui.BriarUi
 import org.briarproject.briar.desktop.ui.BriarUiImpl
+import org.briarproject.briar.desktop.viewmodel.UiExecutor
 import org.briarproject.briar.desktop.viewmodel.ViewModelModule
 import java.io.File
 import java.nio.file.Path
@@ -75,9 +76,12 @@ internal class DesktopModule(
     @Provides
     @Singleton
     @EventExecutor
-    fun provideEventExecutor(): Executor {
-        return Dispatchers.Swing.asExecutor()
-    }
+    fun provideEventExecutor(): Executor = provideUiExecutor()
+
+    @Provides
+    @Singleton
+    @UiExecutor
+    fun provideUiExecutor(): Executor = Dispatchers.Swing.asExecutor()
 
     @Provides
     @TorDirectory
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactListViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactListViewModel.kt
index cf26aea936405ab5111b443068d6462939197ceb..3e9063e88c16213b63a53f0956477e697788470d 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactListViewModel.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactListViewModel.kt
@@ -7,11 +7,16 @@ import org.briarproject.bramble.api.connection.ConnectionRegistry
 import org.briarproject.bramble.api.contact.ContactId
 import org.briarproject.bramble.api.contact.ContactManager
 import org.briarproject.bramble.api.contact.event.ContactAliasChangedEvent
+import org.briarproject.bramble.api.db.DatabaseExecutor
+import org.briarproject.bramble.api.db.TransactionManager
 import org.briarproject.bramble.api.event.Event
 import org.briarproject.bramble.api.event.EventBus
+import org.briarproject.bramble.api.lifecycle.LifecycleManager
 import org.briarproject.briar.api.conversation.ConversationManager
 import org.briarproject.briar.api.conversation.event.ConversationMessageTrackedEvent
 import org.briarproject.briar.desktop.conversation.ConversationMessagesReadEvent
+import org.briarproject.briar.desktop.viewmodel.UiExecutor
+import java.util.concurrent.Executor
 import javax.inject.Inject
 
 class ContactListViewModel
@@ -20,8 +25,14 @@ constructor(
     contactManager: ContactManager,
     conversationManager: ConversationManager,
     connectionRegistry: ConnectionRegistry,
+    @UiExecutor uiExecutor: Executor,
+    @DatabaseExecutor dbExecutor: Executor,
+    lifecycleManager: LifecycleManager,
+    db: TransactionManager,
     eventBus: EventBus,
-) : ContactsViewModel(contactManager, conversationManager, connectionRegistry, eventBus) {
+) : ContactsViewModel(
+    contactManager, conversationManager, connectionRegistry, uiExecutor, dbExecutor, lifecycleManager, db, eventBus
+) {
 
     companion object {
         private val LOG = KotlinLogging.logger {}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactsViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactsViewModel.kt
index 46a6c68684cdc70754470313ec1659a4c83a2a2c..edff9df35df560a8d13e145651ab2fda83f75007 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactsViewModel.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactsViewModel.kt
@@ -7,21 +7,30 @@ import org.briarproject.bramble.api.contact.ContactId
 import org.briarproject.bramble.api.contact.ContactManager
 import org.briarproject.bramble.api.contact.event.ContactAddedEvent
 import org.briarproject.bramble.api.contact.event.ContactRemovedEvent
+import org.briarproject.bramble.api.db.DatabaseExecutor
+import org.briarproject.bramble.api.db.TransactionManager
 import org.briarproject.bramble.api.event.Event
 import org.briarproject.bramble.api.event.EventBus
+import org.briarproject.bramble.api.lifecycle.LifecycleManager
 import org.briarproject.bramble.api.plugin.event.ContactConnectedEvent
 import org.briarproject.bramble.api.plugin.event.ContactDisconnectedEvent
 import org.briarproject.briar.api.conversation.ConversationManager
 import org.briarproject.briar.desktop.utils.removeFirst
 import org.briarproject.briar.desktop.utils.replaceFirst
-import org.briarproject.briar.desktop.viewmodel.BriarEventListenerViewModel
+import org.briarproject.briar.desktop.viewmodel.EventListenerDbViewModel
+import org.briarproject.briar.desktop.viewmodel.UiExecutor
+import java.util.concurrent.Executor
 
 abstract class ContactsViewModel(
     protected val contactManager: ContactManager,
     private val conversationManager: ConversationManager,
     private val connectionRegistry: ConnectionRegistry,
+    @UiExecutor uiExecutor: Executor,
+    @DatabaseExecutor dbExecutor: Executor,
+    lifecycleManager: LifecycleManager,
+    db: TransactionManager,
     eventBus: EventBus,
-) : BriarEventListenerViewModel(eventBus) {
+) : EventListenerDbViewModel(uiExecutor, dbExecutor, lifecycleManager, db, eventBus) {
 
     companion object {
         private val LOG = KotlinLogging.logger {}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt
index 79e76b2c13219fd237d0c5af753091aa839a2908..e8fd64aad690255b0631d4f4db29cc10faa9822a 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt
@@ -10,10 +10,13 @@ import org.briarproject.bramble.api.connection.ConnectionRegistry
 import org.briarproject.bramble.api.contact.ContactId
 import org.briarproject.bramble.api.contact.ContactManager
 import org.briarproject.bramble.api.contact.event.ContactRemovedEvent
+import org.briarproject.bramble.api.db.DatabaseExecutor
 import org.briarproject.bramble.api.db.DbException
 import org.briarproject.bramble.api.db.NoSuchContactException
+import org.briarproject.bramble.api.db.TransactionManager
 import org.briarproject.bramble.api.event.Event
 import org.briarproject.bramble.api.event.EventBus
+import org.briarproject.bramble.api.lifecycle.LifecycleManager
 import org.briarproject.bramble.api.plugin.event.ContactConnectedEvent
 import org.briarproject.bramble.api.plugin.event.ContactDisconnectedEvent
 import org.briarproject.bramble.api.sync.MessageId
@@ -33,8 +36,10 @@ import org.briarproject.briar.desktop.contact.ContactItem
 import org.briarproject.briar.desktop.utils.KLoggerUtils.logDuration
 import org.briarproject.briar.desktop.utils.replaceIf
 import org.briarproject.briar.desktop.utils.replaceIfIndexed
-import org.briarproject.briar.desktop.viewmodel.BriarEventListenerViewModel
+import org.briarproject.briar.desktop.viewmodel.EventListenerDbViewModel
+import org.briarproject.briar.desktop.viewmodel.UiExecutor
 import java.util.Date
+import java.util.concurrent.Executor
 import javax.inject.Inject
 
 class ConversationViewModel
@@ -45,8 +50,12 @@ constructor(
     private val conversationManager: ConversationManager,
     private val messagingManager: MessagingManager,
     private val privateMessageFactory: PrivateMessageFactory,
+    @UiExecutor uiExecutor: Executor,
+    @DatabaseExecutor dbExecutor: Executor,
+    lifecycleManager: LifecycleManager,
+    db: TransactionManager,
     private val eventBus: EventBus,
-) : BriarEventListenerViewModel(eventBus) {
+) : EventListenerDbViewModel(uiExecutor, dbExecutor, lifecycleManager, db, eventBus) {
 
     companion object {
         private val LOG = KotlinLogging.logger {}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/introduction/IntroductionViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/introduction/IntroductionViewModel.kt
index c57773fb6156f3273b9e5da884c63d987a2cae93..4dbbc93e42e0e5ab2712030cffa9a09613636b1e 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/introduction/IntroductionViewModel.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/introduction/IntroductionViewModel.kt
@@ -4,10 +4,15 @@ import androidx.compose.runtime.State
 import androidx.compose.runtime.mutableStateOf
 import org.briarproject.bramble.api.connection.ConnectionRegistry
 import org.briarproject.bramble.api.contact.ContactManager
+import org.briarproject.bramble.api.db.DatabaseExecutor
+import org.briarproject.bramble.api.db.TransactionManager
 import org.briarproject.bramble.api.event.EventBus
+import org.briarproject.bramble.api.lifecycle.LifecycleManager
 import org.briarproject.briar.api.conversation.ConversationManager
 import org.briarproject.briar.desktop.contact.ContactItem
 import org.briarproject.briar.desktop.contact.ContactsViewModel
+import org.briarproject.briar.desktop.viewmodel.UiExecutor
+import java.util.concurrent.Executor
 import javax.inject.Inject
 
 class IntroductionViewModel
@@ -16,8 +21,14 @@ constructor(
     contactManager: ContactManager,
     conversationManager: ConversationManager,
     connectionRegistry: ConnectionRegistry,
+    @UiExecutor uiExecutor: Executor,
+    @DatabaseExecutor dbExecutor: Executor,
+    lifecycleManager: LifecycleManager,
+    db: TransactionManager,
     eventBus: EventBus,
-) : ContactsViewModel(contactManager, conversationManager, connectionRegistry, eventBus) {
+) : ContactsViewModel(
+    contactManager, conversationManager, connectionRegistry, uiExecutor, dbExecutor, lifecycleManager, db, eventBus
+) {
 
     private val _firstContact = mutableStateOf<ContactItem?>(null)
     private val _secondContact = mutableStateOf<ContactItem?>(null)
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/BriarEventListenerViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/BriarEventListenerViewModel.kt
deleted file mode 100644
index 7c7b2908fd97c9d4e399959c0d2bf21874f4ac00..0000000000000000000000000000000000000000
--- a/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/BriarEventListenerViewModel.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package org.briarproject.briar.desktop.viewmodel
-
-import org.briarproject.bramble.api.event.EventBus
-import org.briarproject.bramble.api.event.EventListener
-
-abstract class BriarEventListenerViewModel(
-    private val eventBus: EventBus
-) : ViewModel, EventListener {
-
-    override fun onInit() {
-        eventBus.addListener(this)
-    }
-
-    override fun onCleared() {
-        eventBus.removeListener(this)
-    }
-}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/DbViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/DbViewModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..8c4c5d361d893d22cdcf3fb8c98b833796ccb20a
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/DbViewModel.kt
@@ -0,0 +1,94 @@
+package org.briarproject.briar.desktop.viewmodel
+
+import mu.KotlinLogging
+import org.briarproject.bramble.api.db.DatabaseExecutor
+import org.briarproject.bramble.api.db.DbCallable
+import org.briarproject.bramble.api.db.DbException
+import org.briarproject.bramble.api.db.DbRunnable
+import org.briarproject.bramble.api.db.TransactionManager
+import org.briarproject.bramble.api.event.EventListener
+import org.briarproject.bramble.api.lifecycle.LifecycleManager
+import java.util.concurrent.Executor
+
+abstract class DbViewModel(
+    @UiExecutor private val uiExecutor: Executor,
+    @DatabaseExecutor private val dbExecutor: Executor,
+    private val lifecycleManager: LifecycleManager,
+    private val db: TransactionManager
+) : ViewModel, EventListener {
+
+    companion object {
+        private val LOG = KotlinLogging.logger {}
+    }
+
+    /**
+     * Waits for the DB to open and runs the given task on the [DatabaseExecutor].
+     *
+     * The [Runnable] has to handle all potential exceptions, e.g. [DbException]s,
+     * in a thread-safe way itself.
+     * For convenience, consider using [runOnDbThreadWithTransaction] instead.
+     */
+    protected fun runOnDbThread(task: Runnable) {
+        dbExecutor.execute {
+            try {
+                lifecycleManager.waitForDatabase()
+                task.run()
+            } catch (e: InterruptedException) {
+                LOG.warn("Interrupted while waiting for database")
+                Thread.currentThread().interrupt()
+            }
+        }
+    }
+
+    /**
+     * Waits for the DB to open and runs the given task on the [DatabaseExecutor].
+     *
+     * All exceptions thrown inside the [DbRunnable] are passed to the [UiExecutor]
+     * using the [onError] callback.
+     */
+    protected fun runOnDbThreadWithTransaction(
+        readOnly: Boolean,
+        task: DbRunnable<Exception>,
+        @UiExecutor onError: (Exception) -> Unit
+    ) {
+        dbExecutor.execute {
+            try {
+                lifecycleManager.waitForDatabase()
+                db.transaction(readOnly, task)
+            } catch (e: InterruptedException) {
+                LOG.warn("Interrupted while waiting for database")
+                Thread.currentThread().interrupt()
+            } catch (e: Exception) {
+                uiExecutor.execute { onError(e) }
+            }
+        }
+    }
+
+    /**
+     * Waits for the DB to open and runs the given task on the [DatabaseExecutor],
+     * providing the result to the [onResult] callback in the UI thread.
+     *
+     * All exceptions thrown inside the [DbRunnable] are passed to the [UiExecutor]
+     * using the [onError] callback.
+     */
+    protected fun <T> loadOnDbThreadWithTransaction(
+        task: DbCallable<T, Exception>,
+        @UiExecutor onResult: (T) -> Unit,
+        @UiExecutor onError: (Exception) -> Unit
+    ) {
+        dbExecutor.execute {
+            try {
+                lifecycleManager.waitForDatabase()
+                db.transaction<Exception>(true) { txn ->
+                    val t = task.call(txn)
+                    txn.attach { onResult(t) }
+                }
+            } catch (e: InterruptedException) {
+                LOG.warn("Interrupted while waiting for database")
+                Thread.currentThread().interrupt()
+            } catch (e: Exception) {
+                uiExecutor.execute { onError(e) }
+            }
+        }
+    }
+}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/EventListenerDbViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/EventListenerDbViewModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..166d6ff3ff4167e4c159abf08120e01caf1f6954
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/EventListenerDbViewModel.kt
@@ -0,0 +1,25 @@
+package org.briarproject.briar.desktop.viewmodel
+
+import org.briarproject.bramble.api.db.DatabaseExecutor
+import org.briarproject.bramble.api.db.TransactionManager
+import org.briarproject.bramble.api.event.EventBus
+import org.briarproject.bramble.api.event.EventListener
+import org.briarproject.bramble.api.lifecycle.LifecycleManager
+import java.util.concurrent.Executor
+
+abstract class EventListenerDbViewModel(
+    @UiExecutor uiExecutor: Executor,
+    @DatabaseExecutor dbExecutor: Executor,
+    lifecycleManager: LifecycleManager,
+    db: TransactionManager,
+    private val eventBus: EventBus
+) : EventListener, DbViewModel(uiExecutor, dbExecutor, lifecycleManager, db) {
+
+    override fun onInit() {
+        eventBus.addListener(this)
+    }
+
+    override fun onCleared() {
+        eventBus.removeListener(this)
+    }
+}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/UiExecutor.kt b/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/UiExecutor.kt
new file mode 100644
index 0000000000000000000000000000000000000000..e8e19ef8374bf92c07d5bd9b8e1ee19962e3c7d4
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/UiExecutor.kt
@@ -0,0 +1,18 @@
+package org.briarproject.briar.desktop.viewmodel
+
+import javax.inject.Qualifier
+
+/**
+ * Annotation for injecting the executor for tasks that should be run on the UI thread.
+ * Also used for annotating methods that should run on the UI executor.
+ */
+@Qualifier
+@Target(
+    AnnotationTarget.FIELD,
+    AnnotationTarget.FUNCTION,
+    AnnotationTarget.PROPERTY_GETTER,
+    AnnotationTarget.PROPERTY_SETTER,
+    AnnotationTarget.VALUE_PARAMETER
+)
+@Retention(AnnotationRetention.RUNTIME)
+annotation class UiExecutor
diff --git a/src/test/kotlin/org/briarproject/briar/desktop/DesktopTestModule.kt b/src/test/kotlin/org/briarproject/briar/desktop/DesktopTestModule.kt
index cad4b989717dd9430d352a158b39542f4a37ad97..121ed1a98396050c0f5a66de6dc76688c8301d55 100644
--- a/src/test/kotlin/org/briarproject/briar/desktop/DesktopTestModule.kt
+++ b/src/test/kotlin/org/briarproject/briar/desktop/DesktopTestModule.kt
@@ -2,9 +2,13 @@ package org.briarproject.briar.desktop
 
 import dagger.Module
 import dagger.Provides
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.asExecutor
+import kotlinx.coroutines.swing.Swing
 import org.briarproject.bramble.account.AccountModule
 import org.briarproject.bramble.api.FeatureFlags
 import org.briarproject.bramble.api.db.DatabaseConfig
+import org.briarproject.bramble.api.event.EventExecutor
 import org.briarproject.bramble.api.plugin.PluginConfig
 import org.briarproject.bramble.api.plugin.TorConstants.DEFAULT_CONTROL_PORT
 import org.briarproject.bramble.api.plugin.TorConstants.DEFAULT_SOCKS_PORT
@@ -15,7 +19,6 @@ import org.briarproject.bramble.api.plugin.TransportId
 import org.briarproject.bramble.api.plugin.duplex.DuplexPluginFactory
 import org.briarproject.bramble.api.plugin.simplex.SimplexPluginFactory
 import org.briarproject.bramble.battery.DefaultBatteryManagerModule
-import org.briarproject.bramble.event.DefaultEventExecutorModule
 import org.briarproject.bramble.network.JavaNetworkModule
 import org.briarproject.bramble.plugin.tor.CircumventionModule
 import org.briarproject.bramble.plugin.tor.UnixTorPluginFactory
@@ -32,11 +35,13 @@ import org.briarproject.briar.desktop.testdata.DeterministicTestDataCreator
 import org.briarproject.briar.desktop.testdata.DeterministicTestDataCreatorImpl
 import org.briarproject.briar.desktop.ui.BriarUi
 import org.briarproject.briar.desktop.ui.BriarUiImpl
+import org.briarproject.briar.desktop.viewmodel.UiExecutor
 import org.briarproject.briar.desktop.viewmodel.ViewModelModule
 import org.briarproject.briar.test.TestModule
 import java.io.File
 import java.nio.file.Path
 import java.util.Collections.emptyList
+import java.util.concurrent.Executor
 import javax.inject.Singleton
 
 @Module(
@@ -45,7 +50,6 @@ import javax.inject.Singleton
         CircumventionModule::class,
         ClockModule::class,
         DefaultBatteryManagerModule::class,
-        DefaultEventExecutorModule::class,
         DefaultTaskSchedulerModule::class,
         DefaultWakefulIoExecutorModule::class,
         DesktopSecureRandomModule::class,
@@ -74,6 +78,16 @@ internal class DesktopTestModule(
         return DesktopDatabaseConfig(dbDir, keyDir)
     }
 
+    @Provides
+    @Singleton
+    @EventExecutor
+    fun provideEventExecutor(): Executor = provideUiExecutor()
+
+    @Provides
+    @Singleton
+    @UiExecutor
+    fun provideUiExecutor(): Executor = Dispatchers.Swing.asExecutor()
+
     @Provides
     @TorDirectory
     internal fun provideTorDirectory(): File {