diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/ChangePasswordDialog.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/ChangePasswordDialog.kt
index e972bf95884c6fa8cd4e8168cc9ff2fa657d28df..ada8c83c24bf45bd2d627af608026913572e889d 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/ChangePasswordDialog.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/ChangePasswordDialog.kt
@@ -36,6 +36,7 @@ import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.focus.FocusDirection
 import androidx.compose.ui.focus.FocusRequester
@@ -43,9 +44,13 @@ import androidx.compose.ui.focus.focusRequester
 import androidx.compose.ui.platform.LocalFocusManager
 import androidx.compose.ui.text.input.ImeAction
 import androidx.compose.ui.text.input.KeyboardType
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
 import org.briarproject.bramble.api.crypto.DecryptionResult
 import org.briarproject.bramble.api.crypto.DecryptionResult.INVALID_PASSWORD
 import org.briarproject.briar.desktop.login.PasswordForm
+import org.briarproject.briar.desktop.settings.ChangePasswordSubViewModel.DialogState
+import org.briarproject.briar.desktop.ui.ModalLoader
 import org.briarproject.briar.desktop.utils.AccessibilityUtils.description
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
 import org.briarproject.briar.desktop.utils.PreviewUtils.preview
@@ -64,17 +69,21 @@ fun main() = preview(
     val passwordTooWeakError = derivedStateOf {
         passwordStrength.value < 0.6f
     }
-    val (submitError, setSubmitError) = remember { mutableStateOf<DecryptionResult?>(null) }
+    val (dialogState, setDialogState) = remember { mutableStateOf<DialogState>(DialogState.Idle) }
+    val scope = rememberCoroutineScope()
     ChangePasswordDialog(
         isVisible = getBooleanParameter("visible"),
         close = { setBooleanParameter("visible", false) },
-        onChange = {
-            // use password with 8 or less to test failure
-            if (password.length <= 8) {
-                setSubmitError(INVALID_PASSWORD)
-                false
-            } else {
-                true
+        confirmChange = {
+            setDialogState(DialogState.Loading)
+            scope.launch {
+                // simulate loading for 1s
+                delay(1000)
+                // use old password with 8 or fewer characters to test failure
+                if (oldPassword.length <= 8)
+                    setDialogState(DialogState.Error(INVALID_PASSWORD))
+                else
+                    setDialogState(DialogState.Done)
             }
         },
         oldPassword = oldPassword,
@@ -87,7 +96,13 @@ fun main() = preview(
         passwordTooWeakError = passwordTooWeakError.value,
         passwordsDontMatchError = password != passwordConfirmation,
         buttonEnabled = !passwordTooWeakError.value && password == passwordConfirmation,
-        submitError = submitError,
+        reset = {
+            setOldPassword("")
+            setPassword("")
+            setPasswordConfirmation("")
+            setDialogState(DialogState.Idle)
+        },
+        dialogState = dialogState,
     )
 }
 
@@ -96,31 +111,30 @@ fun ChangePasswordDialog(
     isVisible: Boolean,
     close: () -> Unit,
     viewHolder: ChangePasswordSubViewModel,
-) {
-    ChangePasswordDialog(
-        isVisible = isVisible,
-        close = close,
-        onChange = viewHolder::confirmChange,
-        oldPassword = viewHolder.oldPassword.value,
-        setOldPassword = viewHolder::setOldPassword,
-        password = viewHolder.password.value,
-        setPassword = viewHolder::setPassword,
-        passwordConfirmation = viewHolder.passwordConfirmation.value,
-        setPasswordConfirmation = viewHolder::setPasswordConfirmation,
-        passwordStrength = viewHolder.passwordStrength.value,
-        passwordTooWeakError = viewHolder.passwordTooWeakError.value,
-        passwordsDontMatchError = viewHolder.passwordMatchError.value,
-        buttonEnabled = viewHolder.buttonEnabled.value,
-        submitError = viewHolder.submitError.value,
-    )
-}
+) = ChangePasswordDialog(
+    isVisible = isVisible,
+    close = close,
+    confirmChange = viewHolder::confirmChange,
+    oldPassword = viewHolder.oldPassword.value,
+    setOldPassword = viewHolder::setOldPassword,
+    password = viewHolder.password.value,
+    setPassword = viewHolder::setPassword,
+    passwordConfirmation = viewHolder.passwordConfirmation.value,
+    setPasswordConfirmation = viewHolder::setPasswordConfirmation,
+    passwordStrength = viewHolder.passwordStrength.value,
+    passwordTooWeakError = viewHolder.passwordTooWeakError.value,
+    passwordsDontMatchError = viewHolder.passwordMatchError.value,
+    buttonEnabled = viewHolder.buttonEnabled.value,
+    reset = viewHolder::reset,
+    dialogState = viewHolder.dialogState.value,
+)
 
 @OptIn(ExperimentalMaterialApi::class)
 @Composable
 fun ChangePasswordDialog(
     isVisible: Boolean,
     close: () -> Unit,
-    onChange: () -> Boolean,
+    confirmChange: () -> Unit,
     oldPassword: String,
     setOldPassword: (String) -> Unit,
     password: String,
@@ -131,21 +145,20 @@ fun ChangePasswordDialog(
     passwordTooWeakError: Boolean,
     passwordsDontMatchError: Boolean,
     buttonEnabled: Boolean,
-    submitError: DecryptionResult?,
+    reset: () -> Unit,
+    dialogState: DialogState,
 ) {
     if (!isVisible) return
 
-    val onClose = {
-        setOldPassword("")
-        setPassword("")
-        setPasswordConfirmation("")
-        close()
+    val onClose = remember {
+        {
+            reset()
+            close()
+        }
     }
 
-    val onSubmit = {
-        if (onChange()) {
-            onClose()
-        }
+    LaunchedEffect(dialogState) {
+        if (dialogState is DialogState.Done) onClose()
     }
 
     AlertDialog(
@@ -168,8 +181,8 @@ fun ChangePasswordDialog(
                 passwordStrength = passwordStrength,
                 passwordTooWeakError = passwordTooWeakError,
                 passwordsDontMatchError = passwordsDontMatchError,
-                onSubmit = onSubmit,
-                submitError = submitError,
+                onSubmit = confirmChange,
+                submitError = (dialogState as? DialogState.Error)?.result,
             )
         },
         dismissButton = {
@@ -181,13 +194,17 @@ fun ChangePasswordDialog(
         },
         confirmButton = {
             DialogButton(
-                onClick = onSubmit,
+                onClick = confirmChange,
                 text = i18n("change"),
                 type = ButtonType.NEUTRAL,
                 enabled = buttonEnabled,
             )
         },
     )
+
+    if (dialogState is DialogState.Loading) {
+        ModalLoader(i18n("settings.security.password.changing"))
+    }
 }
 
 @Composable
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/ChangePasswordSubViewModel.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/ChangePasswordSubViewModel.kt
index c836584c6c66e6dd73dfb0d03688127a06a1558c..39d03a085fd78de8970634fd9d59172fd87b5d72 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/ChangePasswordSubViewModel.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/ChangePasswordSubViewModel.kt
@@ -25,17 +25,29 @@ import org.briarproject.bramble.api.crypto.DecryptionException
 import org.briarproject.bramble.api.crypto.DecryptionResult
 import org.briarproject.bramble.api.crypto.PasswordStrengthEstimator
 import org.briarproject.bramble.api.crypto.PasswordStrengthEstimator.QUITE_WEAK
+import org.briarproject.briar.desktop.threading.BriarExecutors
 import org.briarproject.briar.desktop.viewmodel.asState
+import javax.inject.Inject
 
-class ChangePasswordSubViewModel(
+class ChangePasswordSubViewModel
+@Inject
+constructor(
+    private val briarExecutors: BriarExecutors,
     private val accountManager: AccountManager,
     private val passwordStrengthEstimator: PasswordStrengthEstimator,
 ) {
 
+    sealed class DialogState {
+        object Idle : DialogState()
+        object Loading : DialogState()
+        object Done : DialogState()
+        class Error(val result: DecryptionResult) : DialogState()
+    }
+
     private val _oldPassword = mutableStateOf("")
     private val _password = mutableStateOf("")
     private val _passwordConfirmation = mutableStateOf("")
-    private val _submitError = mutableStateOf<DecryptionResult?>(null)
+    private val _dialogState = mutableStateOf<DialogState>(DialogState.Idle)
 
     val oldPassword = _oldPassword.asState()
     val password = _password.asState()
@@ -57,7 +69,7 @@ class ChangePasswordSubViewModel(
             !passwordTooWeakError.value && !passwordMatchError.value
     }
 
-    val submitError = _submitError.asState()
+    val dialogState = _dialogState.asState()
 
     fun setOldPassword(password: String) {
         _oldPassword.value = password
@@ -71,15 +83,24 @@ class ChangePasswordSubViewModel(
         _passwordConfirmation.value = passwordConfirmation
     }
 
-    fun confirmChange(): Boolean {
-        if (!buttonEnabled.value) return false
-        return try {
-            accountManager.changePassword(oldPassword.value, _password.value)
-            _submitError.value = null
-            true
-        } catch (e: DecryptionException) {
-            _submitError.value = e.decryptionResult
-            false
+    fun confirmChange() {
+        if (!buttonEnabled.value) return
+
+        _dialogState.value = DialogState.Loading
+        briarExecutors.onIoThread {
+            try {
+                accountManager.changePassword(_oldPassword.value, _password.value)
+                _dialogState.value = DialogState.Done
+            } catch (e: DecryptionException) {
+                _dialogState.value = DialogState.Error(e.decryptionResult)
+            }
         }
     }
+
+    fun reset() {
+        setOldPassword("")
+        setPassword("")
+        setPasswordConfirmation("")
+        _dialogState.value = DialogState.Idle
+    }
 }
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/SettingsViewModel.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/SettingsViewModel.kt
index 382fa37eca4e1d4100ce64206561c4d8ce62c404..54882686a48455e4a43eef64eb81c870f971dde1 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/SettingsViewModel.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/SettingsViewModel.kt
@@ -19,8 +19,6 @@
 package org.briarproject.briar.desktop.settings
 
 import androidx.compose.runtime.mutableStateOf
-import org.briarproject.bramble.api.account.AccountManager
-import org.briarproject.bramble.api.crypto.PasswordStrengthEstimator
 import org.briarproject.bramble.api.db.TransactionManager
 import org.briarproject.bramble.api.lifecycle.LifecycleManager
 import org.briarproject.briar.desktop.notification.VisualNotificationProvider
@@ -43,13 +41,12 @@ enum class SettingCategory {
 class SettingsViewModel
 @Inject
 constructor(
+    val changePasswordSubViewModel: ChangePasswordSubViewModel,
     private val briarExecutors: BriarExecutors,
     lifecycleManager: LifecycleManager,
     db: TransactionManager,
     private val unencryptedSettings: UnencryptedSettings,
     private val encryptedSettings: EncryptedSettings,
-    private val accountManager: AccountManager,
-    private val passwordStrengthEstimator: PasswordStrengthEstimator,
     private val visualNotificationProvider: VisualNotificationProvider,
 ) : DbViewModel(briarExecutors, lifecycleManager, db) {
     private val _selectedSetting = mutableStateOf(SettingCategory.DISPLAY)
@@ -67,8 +64,6 @@ constructor(
     private val _changePasswordDialogVisible = mutableStateOf(false)
     val changePasswordDialogVisible = _changePasswordDialogVisible.asState()
 
-    val changePasswordSubViewModel = ChangePasswordSubViewModel(accountManager, passwordStrengthEstimator)
-
     private val _visualNotifications = mutableStateOf(encryptedSettings.visualNotifications)
     val visualNotifications = _visualNotifications.asState()
 
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/Loader.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/Loader.kt
index 6328571a4322dd9ee30e9d3da52dcc09554caa1b..084b116bba9f40cadbd7771b81042762254fba04 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/Loader.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/Loader.kt
@@ -19,22 +19,47 @@
 package org.briarproject.briar.desktop.ui
 
 import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement.spacedBy
 import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.material.AlertDialog
 import androidx.compose.material.CircularProgressIndicator
+import androidx.compose.material.ExperimentalMaterialApi
 import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Text
 import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
+import androidx.compose.ui.Alignment.Companion.Center
+import androidx.compose.ui.Alignment.Companion.CenterHorizontally
+import androidx.compose.ui.Alignment.Companion.CenterVertically
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.unit.dp
 
 @Composable
-fun Loader() {
+fun Loader() =
     Box(
-        contentAlignment = Alignment.Center,
+        contentAlignment = Center,
         modifier = Modifier.fillMaxSize().background(MaterialTheme.colors.background).padding(20.dp)
     ) {
         CircularProgressIndicator()
     }
-}
+
+@OptIn(ExperimentalMaterialApi::class)
+@Composable
+fun ModalLoader(text: String? = null) = AlertDialog(
+    modifier = Modifier.padding(top = 16.dp),
+    onDismissRequest = {},
+    buttons = {},
+    text = {
+        Row(
+            horizontalArrangement = spacedBy(16.dp, alignment = CenterHorizontally),
+            verticalAlignment = CenterVertically,
+            modifier = Modifier.widthIn(min = 200.dp)
+        ) {
+            CircularProgressIndicator()
+            text?.let { Text(text) }
+        }
+    },
+)
diff --git a/briar-desktop/src/main/resources/strings/BriarDesktop.properties b/briar-desktop/src/main/resources/strings/BriarDesktop.properties
index 0ce9d2ea98168a3a8ee7cb7198ccab277599d181..350ba0586c61e7c9e92f55e62b6d20d2ac4d32e5 100644
--- a/briar-desktop/src/main/resources/strings/BriarDesktop.properties
+++ b/briar-desktop/src/main/resources/strings/BriarDesktop.properties
@@ -323,6 +323,7 @@ settings.security.password.change=Change password
 settings.security.password.current=Current password
 settings.security.password.choose=New password
 settings.security.password.confirm=Confirm new password
+settings.security.password.changing=Changing password…
 settings.security.password.changed=Password has been changed.
 
 # Settings Notifications