diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/attachment/media/AvatarManager.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/attachment/media/AvatarManager.kt
new file mode 100644
index 0000000000000000000000000000000000000000..4975533b17011b67795ca3dff63e8135b792d5fc
--- /dev/null
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/attachment/media/AvatarManager.kt
@@ -0,0 +1,75 @@
+/*
+ * Briar Desktop
+ * Copyright (C) 2021-2022 The Briar Project
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package org.briarproject.briar.desktop.attachment.media
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.produceState
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.res.loadImageBitmap
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.swing.Swing
+import kotlinx.coroutines.withContext
+import org.briarproject.bramble.api.sync.MessageId
+import org.briarproject.briar.api.attachment.AttachmentHeader
+import org.briarproject.briar.api.attachment.AttachmentReader
+import org.briarproject.briar.api.identity.AuthorInfo
+import org.briarproject.briar.desktop.threading.BriarExecutors
+import org.briarproject.briar.desktop.ui.LocalAvatarManager
+import javax.inject.Inject
+
+class AvatarManager @Inject constructor(
+    private val attachmentReader: AttachmentReader,
+    private val executors: BriarExecutors,
+) {
+
+    // access only on Dispatchers.Swing
+    // TODO we may want to monitor cache size and evict cache entries again
+    private val cache = HashMap<MessageId, ImageBitmap>()
+
+    suspend fun loadAvatar(
+        attachmentHeader: AttachmentHeader,
+    ): ImageBitmap = withContext(Dispatchers.Swing) {
+        val imageBitmap = cache[attachmentHeader.messageId]
+        if (imageBitmap != null) return@withContext imageBitmap
+        executors.runOnDbThreadWithTransaction(true) { txn ->
+            attachmentReader.getAttachment(txn, attachmentHeader).stream.use { inputStream ->
+                loadImageBitmap(inputStream)
+            }
+        }.also { cache[attachmentHeader.messageId] = it }
+    }
+}
+
+/**
+ * Produces a state with an [ImageBitmap] representing the avatar of the given [authorInfo].
+ * While loading, the value of the state is null.
+ * When no state, but null is returned, the given [authorInfo] has no avatar.
+ */
+@Composable
+fun AvatarProducer(authorInfo: AuthorInfo): State<ImageBitmap?>? {
+    val avatarHeader = authorInfo.avatarHeader
+    return if (avatarHeader == null) {
+        null
+    } else {
+        val avatarManager = checkNotNull(LocalAvatarManager.current)
+        produceState<ImageBitmap?>(null, avatarHeader.messageId) {
+            value = avatarManager.loadAvatar(avatarHeader)
+        }
+    }
+}
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ProfileCircle.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ProfileCircle.kt
index b24a5653c82efa111b5beff9711809dde1f17495..fcbe3a672259a6bee04b945f59d750e840bf03f1 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ProfileCircle.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ProfileCircle.kt
@@ -21,6 +21,7 @@ package org.briarproject.briar.desktop.contact
 import androidx.compose.foundation.Canvas
 import androidx.compose.foundation.Image
 import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.shape.CircleShape
 import androidx.compose.material.MaterialTheme
@@ -32,6 +33,9 @@ import androidx.compose.ui.graphics.ImageBitmap
 import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
+import org.briarproject.bramble.api.identity.Author
+import org.briarproject.briar.api.identity.AuthorInfo
+import org.briarproject.briar.desktop.attachment.media.AvatarProducer
 import org.briarproject.briar.desktop.theme.outline
 import org.briarproject.briar.desktop.utils.PreviewUtils.preview
 
@@ -62,6 +66,21 @@ fun ProfileCircle(size: Dp, contactItem: ContactItem) {
         ProfileCircle(size, contactItem.avatar)
 }
 
+/**
+ * Display the given [Author]'s avatar, or if it doesn't exist, the [Identicon]
+ * as a profile image within a circle.
+ *
+ * @param size the size of the circle. In order to avoid aliasing effects, pass a multiple
+ *             of 9 here. That helps as the image is based on a 9x9 square grid.
+ */
+@Composable
+fun ProfileCircle(size: Dp, author: Author, authorInfo: AuthorInfo) {
+    val avatar = AvatarProducer(authorInfo)
+    avatar?.let { imageBitmapState ->
+        ProfileCircle(size, imageBitmapState.value)
+    } ?: ProfileCircle(size, author.id.bytes)
+}
+
 /**
  * Display an [Identicon] as a profile image within a circle based on a user's author id.
  *
@@ -81,12 +100,17 @@ fun ProfileCircle(size: Dp, input: ByteArray) {
  * @param size the size of the circle.
  */
 @Composable
-fun ProfileCircle(size: Dp, avatar: ImageBitmap) {
-    Image(
+fun ProfileCircle(size: Dp, avatar: ImageBitmap?) {
+    val modifier = Modifier.size(size).clip(CircleShape)
+        .border(1.dp, MaterialTheme.colors.outline, CircleShape)
+    // If avatar is null, it should still be loading, so show empty circle
+    if (avatar == null) Spacer(
+        modifier = modifier,
+    ) else Image(
         bitmap = avatar,
         contentDescription = null,
         contentScale = ContentScale.FillBounds,
-        modifier = Modifier.size(size).clip(CircleShape).border(1.dp, MaterialTheme.colors.outline, CircleShape),
+        modifier = modifier,
     )
 }
 
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ThreadItemView.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ThreadItemView.kt
index 4535d1536c414bc491cd0868af4b4de28e1f7ad4..ea46bb752003c4164562471540944f7f1636ab96 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ThreadItemView.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ThreadItemView.kt
@@ -128,8 +128,7 @@ fun ThreadItemContentComposable(
                     horizontalArrangement = spacedBy(8.dp),
                     verticalAlignment = CenterVertically,
                 ) {
-                    // TODO load and cache profile images, if available
-                    ProfileCircle(20.dp, item.author.id.bytes)
+                    ProfileCircle(27.dp, item.author, item.authorInfo)
                     Text(
                         modifier = Modifier.weight(1f, fill = false),
                         text = item.authorName,
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/BriarUi.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/BriarUi.kt
index 9060f6b89e33b50dedca9f3e57f9512a0e4e5113..9d3ef88e85678f05d6e2f7cca60100cba5d417bc 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/BriarUi.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/BriarUi.kt
@@ -44,6 +44,7 @@ import org.briarproject.bramble.api.event.EventListener
 import org.briarproject.bramble.api.lifecycle.LifecycleManager
 import org.briarproject.bramble.api.lifecycle.LifecycleManager.LifecycleState.RUNNING
 import org.briarproject.bramble.api.lifecycle.event.LifecycleEvent
+import org.briarproject.briar.desktop.attachment.media.AvatarManager
 import org.briarproject.briar.desktop.expiration.ExpirationBanner
 import org.briarproject.briar.desktop.login.ErrorScreen
 import org.briarproject.briar.desktop.login.StartupScreen
@@ -85,6 +86,7 @@ interface BriarUi {
 
 val LocalWindowScope = staticCompositionLocalOf<FrameWindowScope?> { null }
 val LocalViewModelProvider = staticCompositionLocalOf<ViewModelProvider?> { null }
+val LocalAvatarManager = staticCompositionLocalOf<AvatarManager?> { null }
 val LocalConfiguration = staticCompositionLocalOf<Configuration?> { null }
 
 @Immutable
@@ -95,6 +97,7 @@ constructor(
     private val lifecycleManager: LifecycleManager,
     private val eventBus: EventBus,
     private val viewModelProvider: ViewModelProvider,
+    private val avatarManager: AvatarManager,
     private val configuration: Configuration,
     private val visualNotificationProvider: VisualNotificationProvider,
     private val soundNotificationProvider: SoundNotificationProvider,
@@ -204,6 +207,7 @@ constructor(
                 LocalWindowScope provides this,
                 LocalWindowFocusState provides focusState,
                 LocalViewModelProvider provides viewModelProvider,
+                LocalAvatarManager provides avatarManager,
                 LocalConfiguration provides configuration,
                 LocalLocalization provides platformLocalization,
             ) {