diff --git a/src/components/chat/chat.js b/src/components/chat/chat.js
index c0c9ad6c..2887afb5 100644
--- a/src/components/chat/chat.js
+++ b/src/components/chat/chat.js
@@ -12,6 +12,7 @@ import {
   faChevronDown,
   faChevronLeft
 } from '@fortawesome/free-solid-svg-icons'
+import { buildFakeMessage } from '../../services/chat_utils/chat_utils.js'
 
 library.add(
   faChevronDown,
@@ -22,6 +23,7 @@ const BOTTOMED_OUT_OFFSET = 10
 const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 150
 const SAFE_RESIZE_TIME_OFFSET = 100
 const MARK_AS_READ_DELAY = 1500
+const MAX_RETRIES = 10
 
 const Chat = {
   components: {
@@ -35,7 +37,8 @@ const Chat = {
       hoveredMessageChainId: undefined,
       lastScrollPosition: {},
       scrollableContainerHeight: '100%',
-      errorLoadingChat: false
+      errorLoadingChat: false,
+      messageRetriers: {}
     }
   },
   created () {
@@ -219,7 +222,10 @@ const Chat = {
       if (!(this.currentChatMessageService && this.currentChatMessageService.maxId)) { return }
       if (document.hidden) { return }
       const lastReadId = this.currentChatMessageService.maxId
-      this.$store.dispatch('readChat', { id: this.currentChat.id, lastReadId })
+      this.$store.dispatch('readChat', {
+        id: this.currentChat.id,
+        lastReadId
+      })
     },
     bottomedOut (offset) {
       return isBottomedOut(this.$refs.scrollable, offset)
@@ -309,42 +315,74 @@ const Chat = {
       })
       this.fetchChat({ isFirstFetch: true })
     },
-    sendMessage ({ status, media }) {
+    handleAttachmentPosting () {
+      this.$nextTick(() => {
+        this.handleResize()
+        // When the posting form size changes because of a media attachment, we need an extra resize
+        // to account for the potential delay in the DOM update.
+        setTimeout(() => {
+          this.updateScrollableContainerHeight()
+        }, SAFE_RESIZE_TIME_OFFSET)
+        this.scrollDown({ forceRead: true })
+      })
+    },
+    sendMessage ({ status, media, idempotencyKey }) {
       const params = {
         id: this.currentChat.id,
-        content: status
+        content: status,
+        idempotencyKey
       }
 
       if (media[0]) {
         params.mediaId = media[0].id
       }
 
-      return this.backendInteractor.sendChatMessage(params)
+      const fakeMessage = buildFakeMessage({
+        attachments: media,
+        chatId: this.currentChat.id,
+        content: status,
+        userId: this.currentUser.id,
+        idempotencyKey
+      })
+
+      this.$store.dispatch('addChatMessages', {
+        chatId: this.currentChat.id,
+        messages: [fakeMessage]
+      }).then(() => {
+        this.handleAttachmentPosting()
+      })
+
+      return this.doSendMessage({ params, fakeMessage, retriesLeft: MAX_RETRIES })
+    },
+    doSendMessage ({ params, fakeMessage, retriesLeft = MAX_RETRIES }) {
+      if (retriesLeft <= 0) return
+
+      this.backendInteractor.sendChatMessage(params)
         .then(data => {
           this.$store.dispatch('addChatMessages', {
             chatId: this.currentChat.id,
-            messages: [data],
-            updateMaxId: false
-          }).then(() => {
-            this.$nextTick(() => {
-              this.handleResize()
-              // When the posting form size changes because of a media attachment, we need an extra resize
-              // to account for the potential delay in the DOM update.
-              setTimeout(() => {
-                this.updateScrollableContainerHeight()
-              }, SAFE_RESIZE_TIME_OFFSET)
-              this.scrollDown({ forceRead: true })
-            })
+            updateMaxId: false,
+            messages: [{ ...data, fakeId: fakeMessage.id }]
           })
 
           return data
         })
         .catch(error => {
           console.error('Error sending message', error)
-          return {
-            error: this.$t('chats.error_sending_message')
+          this.$store.dispatch('handleMessageError', {
+            chatId: this.currentChat.id,
+            fakeId: fakeMessage.id,
+            isRetry: retriesLeft !== MAX_RETRIES
+          })
+          if ((error.statusCode >= 500 && error.statusCode < 600) || error.message === 'Failed to fetch') {
+            this.messageRetriers[fakeMessage.id] = setTimeout(() => {
+              this.doSendMessage({ params, fakeMessage, retriesLeft: retriesLeft - 1 })
+            }, 1000 * (2 ** (MAX_RETRIES - retriesLeft)))
           }
+          return {}
         })
+
+      return Promise.resolve(fakeMessage)
     },
     goBack () {
       this.$router.push({ name: 'chats', params: { username: this.currentUser.screen_name } })
diff --git a/src/components/chat/chat.vue b/src/components/chat/chat.vue
index 5f58b9a6..94a0097c 100644
--- a/src/components/chat/chat.vue
+++ b/src/components/chat/chat.vue
@@ -80,6 +80,7 @@
               :disable-sensitivity-checkbox="true"
               :disable-submit="errorLoadingChat || !currentChat"
               :disable-preview="true"
+              :optimistic-posting="true"
               :post-handler="sendMessage"
               :submit-on-enter="!mobileLayout"
               :preserve-focus="!mobileLayout"
diff --git a/src/components/chat_message/chat_message.scss b/src/components/chat_message/chat_message.scss
index 53ca7cce..5af744a3 100644
--- a/src/components/chat_message/chat_message.scss
+++ b/src/components/chat_message/chat_message.scss
@@ -101,6 +101,19 @@
     }
   }
 
+  .pending {
+    .status-content.media-body, .created-at {
+      color: var(--faint);
+    }
+  }
+
+  .error {
+    .status-content.media-body, .created-at {
+      color: $fallback--cRed;
+      color: var(--badgeNotification, $fallback--cRed);
+    }
+  }
+
   .incoming {
     a {
       color: var(--chatMessageIncomingLink, $fallback--link);
diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue
index d5b8bb9e..3849ab6e 100644
--- a/src/components/chat_message/chat_message.vue
+++ b/src/components/chat_message/chat_message.vue
@@ -32,7 +32,7 @@
         >
           <div
             class="media status"
-            :class="{ 'without-attachment': !hasAttachment }"
+            :class="{ 'without-attachment': !hasAttachment, 'pending': chatViewItem.data.pending, 'error': chatViewItem.data.error }"
             style="position: relative"
             @mouseenter="hovered = true"
             @mouseleave="hovered = false"
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index 1bdf9833..de583269 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -75,7 +75,8 @@ const PostStatusForm = {
     'autoFocus',
     'fileLimit',
     'submitOnEnter',
-    'emojiPickerPlacement'
+    'emojiPickerPlacement',
+    'optimisticPosting'
   ],
   components: {
     MediaUpload,
@@ -272,7 +273,7 @@ const PostStatusForm = {
       if (this.preview) this.previewStatus()
     },
     async postStatus (event, newStatus, opts = {}) {
-      if (this.posting) { return }
+      if (this.posting && !this.optimisticPosting) { return }
       if (this.disableSubmit) { return }
       if (this.emojiInputShown) { return }
       if (this.submitOnEnter) {
@@ -280,6 +281,8 @@ const PostStatusForm = {
         event.preventDefault()
       }
 
+      if (this.optimisticPosting && (this.emptyStatus || this.isOverLengthLimit)) { return }
+
       if (this.emptyStatus) {
         this.error = this.$t('post_status.empty_status_error')
         return
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index 284ce8df..83b60bc7 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -124,7 +124,7 @@
             v-model="newStatus.spoilerText"
             type="text"
             :placeholder="$t('post_status.content_warning')"
-            :disabled="posting"
+            :disabled="posting && !optimisticPosting"
             size="1"
             class="form-post-subject"
           >
@@ -150,7 +150,7 @@
             :placeholder="placeholder || $t('post_status.default')"
             rows="1"
             cols="1"
-            :disabled="posting"
+            :disabled="posting && !optimisticPosting"
             class="form-post-body"
             :class="{ 'scrollable-form': !!maxHeight }"
             @keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)"
diff --git a/src/modules/api.js b/src/modules/api.js
index 0a354c3f..08485a30 100644
--- a/src/modules/api.js
+++ b/src/modules/api.js
@@ -75,12 +75,18 @@ const api = {
               } else if (message.event === 'delete') {
                 dispatch('deleteStatusById', message.id)
               } else if (message.event === 'pleroma:chat_update') {
-                dispatch('addChatMessages', {
-                  chatId: message.chatUpdate.id,
-                  messages: [message.chatUpdate.lastMessage]
-                })
-                dispatch('updateChat', { chat: message.chatUpdate })
-                maybeShowChatNotification(store, message.chatUpdate)
+                // The setTimeout wrapper is a temporary band-aid to avoid duplicates for the user's own messages when doing optimistic sending.
+                // The cause of the duplicates is the WS event arriving earlier than the HTTP response.
+                // This setTimeout wrapper can be removed once the commit `8e41baff` is in the stable Pleroma release.
+                // (`8e41baff` adds the idempotency key to the chat message entity, which PleromaFE uses when it's available, and it makes this artificial delay unnecessary).
+                setTimeout(() => {
+                  dispatch('addChatMessages', {
+                    chatId: message.chatUpdate.id,
+                    messages: [message.chatUpdate.lastMessage]
+                  })
+                  dispatch('updateChat', { chat: message.chatUpdate })
+                  maybeShowChatNotification(store, message.chatUpdate)
+                }, 100)
               }
             }
           )
diff --git a/src/modules/chats.js b/src/modules/chats.js
index 21e30933..0a373d88 100644
--- a/src/modules/chats.js
+++ b/src/modules/chats.js
@@ -16,7 +16,8 @@ const defaultState = {
   openedChats: {},
   openedChatMessageServices: {},
   fetcher: undefined,
-  currentChatId: null
+  currentChatId: null,
+  lastReadMessageId: null
 }
 
 const getChatById = (state, id) => {
@@ -92,9 +93,14 @@ const chats = {
       commit('setCurrentChatFetcher', { fetcher: undefined })
     },
     readChat ({ rootState, commit, dispatch }, { id, lastReadId }) {
+      const isNewMessage = rootState.chats.lastReadMessageId !== lastReadId
+
       dispatch('resetChatNewMessageCount')
-      commit('readChat', { id })
-      rootState.api.backendInteractor.readChat({ id, lastReadId })
+      commit('readChat', { id, lastReadId })
+
+      if (isNewMessage) {
+        rootState.api.backendInteractor.readChat({ id, lastReadId })
+      }
     },
     deleteChatMessage ({ rootState, commit }, value) {
       rootState.api.backendInteractor.deleteChatMessage(value)
@@ -106,6 +112,9 @@ const chats = {
     },
     clearOpenedChats ({ rootState, commit, dispatch, rootGetters }) {
       commit('clearOpenedChats', { commit })
+    },
+    handleMessageError ({ commit }, value) {
+      commit('handleMessageError', { commit, ...value })
     }
   },
   mutations: {
@@ -208,11 +217,16 @@ const chats = {
         }
       }
     },
-    readChat (state, { id }) {
+    readChat (state, { id, lastReadId }) {
+      state.lastReadMessageId = lastReadId
       const chat = getChatById(state, id)
       if (chat) {
         chat.unread = 0
       }
+    },
+    handleMessageError (state, { chatId, fakeId, isRetry }) {
+      const chatMessageService = state.openedChatMessageServices[chatId]
+      chatService.handleMessageError(chatMessageService, fakeId, isRetry)
     }
   }
 }
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index 1a3495d4..22b5e8ba 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -129,7 +129,11 @@ const promisedRequest = ({ method, url, params, payload, credentials, headers =
             return reject(new StatusCodeError(response.status, json, { url, options }, response))
           }
           return resolve(json)
-        }))
+        })
+        .catch((error) => {
+          return reject(new StatusCodeError(response.status, error, { url, options }, response))
+        })
+      )
     })
 }
 
@@ -1210,7 +1214,7 @@ const chatMessages = ({ id, credentials, maxId, sinceId, limit = 20 }) => {
   })
 }
 
-const sendChatMessage = ({ id, content, mediaId = null, credentials }) => {
+const sendChatMessage = ({ id, content, mediaId = null, idempotencyKey, credentials }) => {
   const payload = {
     'content': content
   }
@@ -1219,11 +1223,18 @@ const sendChatMessage = ({ id, content, mediaId = null, credentials }) => {
     payload['media_id'] = mediaId
   }
 
+  const headers = {}
+
+  if (idempotencyKey) {
+    headers['idempotency-key'] = idempotencyKey
+  }
+
   return promisedRequest({
     url: PLEROMA_CHAT_MESSAGES_URL(id),
     method: 'POST',
     payload: payload,
-    credentials
+    credentials,
+    headers
   })
 }
 
diff --git a/src/services/chat_service/chat_service.js b/src/services/chat_service/chat_service.js
index 95c69482..815af82e 100644
--- a/src/services/chat_service/chat_service.js
+++ b/src/services/chat_service/chat_service.js
@@ -3,6 +3,7 @@ import _ from 'lodash'
 const empty = (chatId) => {
   return {
     idIndex: {},
+    idempotencyKeyIndex: {},
     messages: [],
     newMessageCount: 0,
     lastSeenTimestamp: 0,
@@ -13,8 +14,18 @@ const empty = (chatId) => {
 }
 
 const clear = (storage) => {
-  storage.idIndex = {}
-  storage.messages.splice(0, storage.messages.length)
+  const failedMessageIds = []
+
+  for (const message of storage.messages) {
+    if (message.error) {
+      failedMessageIds.push(message.id)
+    } else {
+      delete storage.idIndex[message.id]
+      delete storage.idempotencyKeyIndex[message.id]
+    }
+  }
+
+  storage.messages = storage.messages.filter(m => failedMessageIds.includes(m.id))
   storage.newMessageCount = 0
   storage.lastSeenTimestamp = 0
   storage.minId = undefined
@@ -37,6 +48,25 @@ const deleteMessage = (storage, messageId) => {
   }
 }
 
+const handleMessageError = (storage, fakeId, isRetry) => {
+  if (!storage) { return }
+  const fakeMessage = storage.idIndex[fakeId]
+  if (fakeMessage) {
+    fakeMessage.error = true
+    fakeMessage.pending = false
+    if (!isRetry) {
+      // Ensure the failed message doesn't stay at the bottom of the list.
+      const lastPersistedMessage = _.orderBy(storage.messages, ['pending', 'id'], ['asc', 'desc'])[0]
+      if (lastPersistedMessage) {
+        const oldId = fakeMessage.id
+        fakeMessage.id = `${lastPersistedMessage.id}-${new Date().getTime()}`
+        storage.idIndex[fakeMessage.id] = fakeMessage
+        delete storage.idIndex[oldId]
+      }
+    }
+  }
+}
+
 const add = (storage, { messages: newMessages, updateMaxId = true }) => {
   if (!storage) { return }
   for (let i = 0; i < newMessages.length; i++) {
@@ -45,7 +75,19 @@ const add = (storage, { messages: newMessages, updateMaxId = true }) => {
     // sanity check
     if (message.chat_id !== storage.chatId) { return }
 
-    if (!storage.minId || message.id < storage.minId) {
+    if (message.fakeId) {
+      const fakeMessage = storage.idIndex[message.fakeId]
+      if (fakeMessage) {
+        Object.assign(fakeMessage, message, { error: false })
+        delete fakeMessage['fakeId']
+        storage.idIndex[fakeMessage.id] = fakeMessage
+        delete storage.idIndex[message.fakeId]
+
+        return
+      }
+    }
+
+    if (!storage.minId || (!message.pending && message.id < storage.minId)) {
       storage.minId = message.id
     }
 
@@ -55,16 +97,22 @@ const add = (storage, { messages: newMessages, updateMaxId = true }) => {
       }
     }
 
-    if (!storage.idIndex[message.id]) {
+    if (!storage.idIndex[message.id] && !isConfirmation(storage, message)) {
       if (storage.lastSeenTimestamp < message.created_at) {
         storage.newMessageCount++
       }
-      storage.messages.push(message)
       storage.idIndex[message.id] = message
+      storage.messages.push(storage.idIndex[message.id])
+      storage.idempotencyKeyIndex[message.idempotency_key] = true
     }
   }
 }
 
+const isConfirmation = (storage, message) => {
+  if (!message.idempotency_key) return
+  return storage.idempotencyKeyIndex[message.idempotency_key]
+}
+
 const resetNewMessageCount = (storage) => {
   if (!storage) { return }
   storage.newMessageCount = 0
@@ -76,7 +124,7 @@ const getView = (storage) => {
   if (!storage) { return [] }
 
   const result = []
-  const messages = _.sortBy(storage.messages, ['id', 'desc'])
+  const messages = _.orderBy(storage.messages, ['pending', 'id'], ['asc', 'asc'])
   const firstMessage = messages[0]
   let previousMessage = messages[messages.length - 1]
   let currentMessageChainId
@@ -148,7 +196,8 @@ const ChatService = {
   getView,
   deleteMessage,
   resetNewMessageCount,
-  clear
+  clear,
+  handleMessageError
 }
 
 export default ChatService
diff --git a/src/services/chat_utils/chat_utils.js b/src/services/chat_utils/chat_utils.js
index 86fe1af9..de6e0625 100644
--- a/src/services/chat_utils/chat_utils.js
+++ b/src/services/chat_utils/chat_utils.js
@@ -18,3 +18,24 @@ export const maybeShowChatNotification = (store, chat) => {
 
   showDesktopNotification(store.rootState, opts)
 }
+
+export const buildFakeMessage = ({ content, chatId, attachments, userId, idempotencyKey }) => {
+  const fakeMessage = {
+    content,
+    chat_id: chatId,
+    created_at: new Date(),
+    id: `${new Date().getTime()}`,
+    attachments: attachments,
+    account_id: userId,
+    idempotency_key: idempotencyKey,
+    emojis: [],
+    pending: true,
+    isNormalized: true
+  }
+
+  if (attachments[0]) {
+    fakeMessage.attachment = attachments[0]
+  }
+
+  return fakeMessage
+}
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index 1884478a..9d09b8d0 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -429,6 +429,9 @@ export const parseChatMessage = (message) => {
   } else {
     output.attachments = []
   }
+  output.pending = !!message.pending
+  output.error = false
+  output.idempotency_key = message.idempotency_key
   output.isNormalized = true
   return output
 }
diff --git a/test/unit/specs/services/chat_service/chat_service.spec.js b/test/unit/specs/services/chat_service/chat_service.spec.js
index 2eb89a2d..15e64bb5 100644
--- a/test/unit/specs/services/chat_service/chat_service.spec.js
+++ b/test/unit/specs/services/chat_service/chat_service.spec.js
@@ -2,17 +2,20 @@ import chatService from '../../../../../src/services/chat_service/chat_service.j
 
 const message1 = {
   id: '9wLkdcmQXD21Oy8lEX',
+  idempotency_key: '1',
   created_at: (new Date('2020-06-22T18:45:53.000Z'))
 }
 
 const message2 = {
   id: '9wLkdp6ihaOVdNj8Wu',
+  idempotency_key: '2',
   account_id: '9vmRb29zLQReckr5ay',
   created_at: (new Date('2020-06-22T18:45:56.000Z'))
 }
 
 const message3 = {
   id: '9wLke9zL4Dy4OZR2RM',
+  idempotency_key: '3',
   account_id: '9vmRb29zLQReckr5ay',
   created_at: (new Date('2020-07-22T18:45:59.000Z'))
 }