diff --git a/keyserver/src/fetchers/upload-fetchers.js b/keyserver/src/fetchers/upload-fetchers.js
--- a/keyserver/src/fetchers/upload-fetchers.js
+++ b/keyserver/src/fetchers/upload-fetchers.js
@@ -123,7 +123,7 @@
 
 function imagesFromRow(row: Object): Image | EncryptedImage {
   const uploadExtra = JSON.parse(row.uploadExtra);
-  const { width, height, blobHolder } = uploadExtra;
+  const { width, height, blobHolder, thumbHash } = uploadExtra;
 
   const { uploadType: type, uploadSecret: secret } = row;
   const id = row.uploadID.toString();
@@ -134,13 +134,14 @@
     throw new ServerError('invalid_parameters');
   }
   if (!isEncrypted) {
-    return { id, type: 'photo', uri, dimensions };
+    return { id, type: 'photo', uri, dimensions, thumbHash };
   }
   return {
     id,
     type: 'encrypted_photo',
     holder: uri,
     dimensions,
+    thumbHash,
     encryptionKey: uploadExtra.encryptionKey,
   };
 }
@@ -212,7 +213,7 @@
 
   const media = uploads.map(upload => {
     const { uploadID, uploadType, uploadSecret, uploadExtra } = upload;
-    const { width, height, encryptionKey, blobHolder } =
+    const { width, height, encryptionKey, blobHolder, thumbHash } =
       JSON.parse(uploadExtra);
     const dimensions = { width, height };
     const uri = makeUploadURI(blobHolder, uploadID, uploadSecret);
@@ -225,6 +226,7 @@
           holder: uri,
           encryptionKey,
           dimensions,
+          thumbHash,
         };
       }
       return {
@@ -232,6 +234,7 @@
         id: uploadID.toString(),
         uri,
         dimensions,
+        thumbHash,
       };
     }
 
@@ -239,6 +242,7 @@
     const {
       encryptionKey: thumbnailEncryptionKey,
       blobHolder: thumbnailBlobHolder,
+      thumbHash: thumbnailThumbHash,
     } = JSON.parse(thumbnailUploadExtra);
     const thumbnailURI = makeUploadURI(
       thumbnailBlobHolder,
@@ -256,6 +260,7 @@
         thumbnailID,
         thumbnailHolder: thumbnailURI,
         thumbnailEncryptionKey,
+        thumbnailThumbHash,
       };
     }
 
@@ -266,6 +271,7 @@
       dimensions,
       thumbnailID,
       thumbnailURI,
+      thumbnailThumbHash,
     };
   });
 
@@ -313,7 +319,8 @@
     const primaryUpload = uploadMap[primaryUploadID];
 
     const uploadExtra = JSON.parse(primaryUpload.uploadExtra);
-    const { width, height, loop, blobHolder, encryptionKey } = uploadExtra;
+    const { width, height, loop, blobHolder, encryptionKey, thumbHash } =
+      uploadExtra;
     const dimensions = { width, height };
 
     const primaryUploadURI = makeUploadURI(
@@ -330,6 +337,7 @@
           holder: primaryUploadURI,
           encryptionKey,
           dimensions,
+          thumbHash,
         });
       } else {
         media.push({
@@ -337,6 +345,7 @@
           id: primaryUploadID,
           uri: primaryUploadURI,
           dimensions,
+          thumbHash,
         });
       }
       continue;
@@ -346,7 +355,8 @@
     const thumbnailUpload = uploadMap[thumbnailUploadID];
 
     const thumbnailUploadExtra = JSON.parse(thumbnailUpload.uploadExtra);
-    const { blobHolder: thumbnailBlobHolder } = thumbnailUploadExtra;
+    const { blobHolder: thumbnailBlobHolder, thumbHash: thumbnailThumbHash } =
+      thumbnailUploadExtra;
     const thumbnailUploadURI = makeUploadURI(
       thumbnailBlobHolder,
       thumbnailUploadID,
@@ -363,6 +373,7 @@
         thumbnailID: thumbnailUploadID,
         thumbnailHolder: thumbnailUploadURI,
         thumbnailEncryptionKey: thumbnailUploadExtra.encryptionKey,
+        thumbnailThumbHash,
       };
       media.push(loop ? { ...video, loop } : video);
     } else {
@@ -373,6 +384,7 @@
         dimensions,
         thumbnailID: thumbnailUploadID,
         thumbnailURI: thumbnailUploadURI,
+        thumbnailThumbHash,
       };
       media.push(loop ? { ...video, loop } : video);
     }
diff --git a/lib/reducers/message-reducer.js b/lib/reducers/message-reducer.js
--- a/lib/reducers/message-reducer.js
+++ b/lib/reducers/message-reducer.js
@@ -1393,6 +1393,7 @@
             type: 'photo',
             uri: mediaUpdate.uri ?? singleMedia.uri,
             dimensions: mediaUpdate.dimensions ?? singleMedia.dimensions,
+            thumbHash: mediaUpdate.thumbHash ?? singleMedia.thumbHash,
           };
 
           if (
diff --git a/lib/reducers/message-reducer.test.js b/lib/reducers/message-reducer.test.js
--- a/lib/reducers/message-reducer.test.js
+++ b/lib/reducers/message-reducer.test.js
@@ -21,6 +21,7 @@
           uri: 'assets-library://asset/asset.HEIC?id=CC95F08C-88C3-4012-9D6D-64A413D254B3&ext=HEIC',
           type: 'photo',
           dimensions: { height: 3024, width: 4032 },
+          thumbHash: 'some_thumb_hash',
           localMediaSelection: {
             step: 'photo_library',
             dimensions: { height: 3024, width: 4032 },
@@ -85,6 +86,7 @@
           type: 'photo',
           uri: 'http://localhost/comm/upload/91172/dfa9b9fe7eb03fde',
           dimensions: { height: 1440, width: 1920 },
+          thumbHash: 'some_thumb_hash',
         },
       ],
       localID: 'local1',
diff --git a/lib/types/media-types.js b/lib/types/media-types.js
--- a/lib/types/media-types.js
+++ b/lib/types/media-types.js
@@ -639,6 +639,7 @@
   +uri: string,
   +type: 'photo',
   +dimensions: Dimensions,
+  +thumbHash: ?string,
   // stored on native only during creation in case retry needed after state lost
   +localMediaSelection?: NativeMediaSelection,
 };
@@ -648,6 +649,7 @@
   uri: t.String,
   type: tString('photo'),
   dimensions: dimensionsValidator,
+  thumbHash: t.maybe(t.String),
   localMediaSelection: t.maybe(nativeMediaSelectionValidator),
 });
 
@@ -658,6 +660,7 @@
   +encryptionKey: string,
   +type: 'encrypted_photo',
   +dimensions: Dimensions,
+  +thumbHash: ?string,
 };
 
 export const encryptedImageValidator: TInterface<EncryptedImage> =
@@ -667,6 +670,7 @@
     encryptionKey: t.String,
     type: tString('encrypted_photo'),
     dimensions: dimensionsValidator,
+    thumbHash: t.maybe(t.String),
   });
 
 export type Video = {
@@ -677,6 +681,7 @@
   +loop?: boolean,
   +thumbnailID: string,
   +thumbnailURI: string,
+  +thumbnailThumbHash: ?string,
   // stored on native only during creation in case retry needed after state lost
   +localMediaSelection?: NativeMediaSelection,
 };
@@ -689,6 +694,7 @@
   loop: t.maybe(t.Boolean),
   thumbnailID: tID,
   thumbnailURI: t.String,
+  thumbnailThumbHash: t.maybe(t.String),
   localMediaSelection: t.maybe(nativeMediaSelectionValidator),
 });
 
@@ -703,6 +709,7 @@
   +thumbnailID: string,
   +thumbnailHolder: string,
   +thumbnailEncryptionKey: string,
+  +thumbnailThumbHash: ?string,
 };
 
 export const encryptedVideoValidator: TInterface<EncryptedVideo> =
@@ -716,6 +723,7 @@
     thumbnailID: tID,
     thumbnailHolder: t.String,
     thumbnailEncryptionKey: t.String,
+    thumbnailThumbHash: t.maybe(t.String),
   });
 
 export type Media = Image | Video | EncryptedImage | EncryptedVideo;
diff --git a/lib/utils/message-ops-utils.js b/lib/utils/message-ops-utils.js
--- a/lib/utils/message-ops-utils.js
+++ b/lib/utils/message-ops-utils.js
@@ -52,6 +52,7 @@
         loop: type === 'video' ? m.loop : false,
         local_media_selection: m.localMediaSelection,
         encryption_key: m.encryptionKey,
+        thumb_hash: m.thumbHash ?? undefined,
       }),
     });
     if (m.type === 'video' || m.type === 'encrypted_video') {
@@ -65,6 +66,7 @@
           dimensions: m.dimensions,
           loop: false,
           encryption_key: m.thumbnailEncryptionKey,
+          thumb_hash: m.thumbnailThumbHash ?? undefined,
         }),
       });
     }
@@ -75,7 +77,7 @@
 function translateClientDBMediaInfoToImage(
   clientDBMediaInfo: ClientDBMediaInfo,
 ): Image {
-  const { dimensions, local_media_selection } = JSON.parse(
+  const { dimensions, local_media_selection, thumb_hash } = JSON.parse(
     clientDBMediaInfo.extras,
   );
 
@@ -85,6 +87,7 @@
       uri: clientDBMediaInfo.uri,
       type: 'photo',
       dimensions: dimensions,
+      thumbHash: thumb_hash,
     };
   }
   return {
@@ -93,6 +96,7 @@
     type: 'photo',
     dimensions: dimensions,
     localMediaSelection: local_media_selection,
+    thumbHash: thumb_hash,
   };
 }
 
@@ -127,7 +131,11 @@
   for (const media of messageContent) {
     if (media.type === 'photo') {
       const extras = JSON.parse(mediaMap[media.uploadID].extras);
-      const { dimensions, encryption_key: encryptionKey } = extras;
+      const {
+        dimensions,
+        encryption_key: encryptionKey,
+        thumb_hash: thumbHash,
+      } = extras;
 
       let image;
       if (encryptionKey) {
@@ -137,6 +145,7 @@
           holder: mediaMap[media.uploadID].uri,
           dimensions,
           encryptionKey,
+          thumbHash,
         };
       } else {
         image = {
@@ -144,6 +153,7 @@
           type: 'photo',
           uri: mediaMap[media.uploadID].uri,
           dimensions,
+          thumbHash,
         };
       }
       translatedMedia.push(image);
@@ -156,10 +166,11 @@
         encryption_key: encryptionKey,
       } = extras;
 
+      const {
+        encryption_key: thumbnailEncryptionKey,
+        thumb_hash: thumbnailThumbHash,
+      } = JSON.parse(mediaMap[media.thumbnailUploadID].extras);
       if (encryptionKey) {
-        const thumbnailEncryptionKey = JSON.parse(
-          mediaMap[media.thumbnailUploadID].extras,
-        ).encryption_key;
         const video: EncryptedVideo = {
           id: media.uploadID,
           type: 'encrypted_video',
@@ -170,6 +181,7 @@
           thumbnailID: media.thumbnailUploadID,
           thumbnailHolder: mediaMap[media.thumbnailUploadID].uri,
           thumbnailEncryptionKey,
+          thumbnailThumbHash,
         };
         translatedMedia.push(video);
       } else {
@@ -181,6 +193,7 @@
           loop,
           thumbnailID: media.thumbnailUploadID,
           thumbnailURI: mediaMap[media.thumbnailUploadID].uri,
+          thumbnailThumbHash,
         };
         translatedMedia.push(
           localMediaSelection ? { ...video, localMediaSelection } : video,
diff --git a/lib/utils/message-ops-utils.test.js b/lib/utils/message-ops-utils.test.js
--- a/lib/utils/message-ops-utils.test.js
+++ b/lib/utils/message-ops-utils.test.js
@@ -279,6 +279,7 @@
           width: 1920,
           height: 1281,
         },
+        thumbHash: 'some_thumb_hash',
       },
     ],
     id: '85505',
@@ -305,6 +306,7 @@
           width: 1920,
           height: 1281,
         },
+        thumbHash: 'some_thumb_hash',
       },
     ],
     localID: 'local123',
@@ -433,6 +435,7 @@
           retries: 0,
         },
         loop: false,
+        thumbnailThumbHash: undefined,
         thumbnailID: 'localUpload1',
         thumbnailURI:
           'assets-library://asset/asset.mov?id=6F1BEA56-3875-474C-B3AF-B11DEDCBAFF2&ext=mov',
@@ -461,7 +464,7 @@
         uri: 'assets-library://asset/asset.jpeg?id=6F1BEA56-3875-474C-B3AF-B11DEDCBAFF2&ext=jpeg',
         type: 'photo',
         extras:
-          '{"dimensions":{"height":1010,"width":576},"loop":false,"encryption_key":"someKey"}',
+          '{"dimensions":{"height":1010,"width":576},"loop":false,"encryption_key":"someKey","thumb_hash":"thumb"}',
       },
     ],
   };
@@ -478,6 +481,7 @@
           'assets-library://asset/asset.jpeg?id=6F1BEA56-3875-474C-B3AF-B11DEDCBAFF2&ext=jpeg',
         encryptionKey: 'someKey',
         dimensions: { height: 1010, width: 576 },
+        thumbHash: 'thumb',
       },
     ],
     localID: 'local0',
@@ -511,7 +515,7 @@
         uri: 'assets-library://asset/asset.jpeg?id=6F1BEA56-3875-474C-B3AF-B11DEDCBAFF2&ext=jpeg',
         type: 'photo',
         extras:
-          '{"dimensions":{"height":1010,"width":576},"loop":false,"encryption_key":"someThumbKey"}',
+          '{"dimensions":{"height":1010,"width":576},"loop":false,"encryption_key":"someThumbKey","thumb_hash":"thumb"}',
       },
     ],
   };
@@ -533,6 +537,7 @@
         thumbnailHolder:
           'assets-library://asset/asset.jpeg?id=6F1BEA56-3875-474C-B3AF-B11DEDCBAFF2&ext=jpeg',
         thumbnailEncryptionKey: 'someThumbKey',
+        thumbnailThumbHash: 'thumb',
       },
     ],
     localID: 'local0',
diff --git a/native/input/input-state-container.react.js b/native/input/input-state-container.react.js
--- a/native/input/input-state-container.react.js
+++ b/native/input/input-state-container.react.js
@@ -635,6 +635,7 @@
           type: 'photo',
           dimensions: selection.dimensions,
           localMediaSelection: selection,
+          thumbHash: null,
         });
         ids = { type: 'photo', localMediaID };
       }
@@ -649,6 +650,7 @@
           loop: false,
           thumbnailID: localThumbnailID,
           thumbnailURI: selection.uri,
+          thumbnailThumbHash: null,
         });
         ids = { type: 'video', localMediaID, localThumbnailID };
       }
@@ -845,6 +847,10 @@
               blobHash: processedMedia.blobHash,
               encryptionKey: processedMedia.encryptionKey,
               dimensions: processedMedia.dimensions,
+              thumbHash:
+                processedMedia.mediaType === 'encrypted_photo'
+                  ? processedMedia.thumbHash
+                  : null,
             },
             {
               onProgress: (percent: number) => {
@@ -869,6 +875,7 @@
               encryptionKey: processedMedia.thumbnailEncryptionKey,
               loop: false,
               dimensions: processedMedia.dimensions,
+              thumbHash: processedMedia.thumbHash,
             }),
           );
         }
@@ -887,6 +894,11 @@
                   ? processedMedia.loop
                   : undefined,
               encryptionKey: processedMedia.encryptionKey,
+              thumbHash:
+                processedMedia.mediaType === 'photo' ||
+                processedMedia.mediaType === 'encrypted_photo'
+                  ? processedMedia.thumbHash
+                  : null,
             },
             {
               onProgress: (percent: number) =>
@@ -916,6 +928,7 @@
                 ...processedMedia.dimensions,
                 loop: false,
                 encryptionKey: processedMedia.thumbnailEncryptionKey,
+                thumbHash: processedMedia.thumbHash,
               },
               {
                 uploadBlob: this.uploadBlob,
@@ -981,7 +994,8 @@
       ) {
         invariant(uploadThumbnailResult, 'uploadThumbnailResult exists');
         const { uri: thumbnailURI, id: thumbnailID } = uploadThumbnailResult;
-        const { thumbnailEncryptionKey } = processedMedia;
+        const { thumbnailEncryptionKey, thumbHash: thumbnailThumbHash } =
+          processedMedia;
 
         if (processedMedia.mediaType === 'encrypted_video') {
           updateMediaPayload = {
@@ -991,6 +1005,7 @@
               thumbnailID,
               thumbnailHolder: thumbnailURI,
               thumbnailEncryptionKey,
+              thumbnailThumbHash,
             },
           };
         } else {
@@ -1000,9 +1015,18 @@
               ...updateMediaPayload.mediaUpdate,
               thumbnailID,
               thumbnailURI,
+              thumbnailThumbHash,
             },
           };
         }
+      } else {
+        updateMediaPayload = {
+          ...updateMediaPayload,
+          mediaUpdate: {
+            ...updateMediaPayload.mediaUpdate,
+            thumbHash: processedMedia.thumbHash,
+          },
+        };
       }
 
       // When we dispatch this action, it updates Redux and triggers the
@@ -1135,13 +1159,14 @@
 
   async blobServiceUpload(
     input: {
-      uri: string,
-      filename: string,
-      mimeType: string,
-      blobHash: string,
-      encryptionKey: string,
-      dimensions: Dimensions,
-      loop?: boolean,
+      +uri: string,
+      +filename: string,
+      +mimeType: string,
+      +blobHash: string,
+      +encryptionKey: string,
+      +dimensions: Dimensions,
+      +loop?: boolean,
+      +thumbHash: ?string,
     },
     options?: ?CallServerEndpointOptions,
   ): Promise<void> {
@@ -1227,7 +1252,8 @@
     }
 
     // 3. Send upload metadata to the keyserver, return response
-    const { filename, mimeType, loop, dimensions, encryptionKey } = input;
+    const { filename, mimeType, loop, dimensions, encryptionKey, thumbHash } =
+      input;
     return await this.props.uploadMediaMetadata({
       ...dimensions,
       filename,
@@ -1235,6 +1261,7 @@
       blobHolder: newHolder,
       encryptionKey,
       loop: loop ?? false,
+      ...(thumbHash ? { thumbHash } : null),
     });
   }
 
diff --git a/native/media/multimedia.react.js b/native/media/multimedia.react.js
--- a/native/media/multimedia.react.js
+++ b/native/media/multimedia.react.js
@@ -170,20 +170,30 @@
   // eslint-disable-next-line consistent-return
   static sourceFromMediaInfo(mediaInfo: MediaInfo | AvatarMediaInfo): Source {
     if (mediaInfo.type === 'photo') {
-      return { kind: 'uri', uri: mediaInfo.uri };
+      return {
+        kind: 'uri',
+        uri: mediaInfo.uri,
+        thumbHash: mediaInfo.thumbHash,
+      };
     } else if (mediaInfo.type === 'video') {
-      return { kind: 'uri', uri: mediaInfo.thumbnailURI };
+      return {
+        kind: 'uri',
+        uri: mediaInfo.thumbnailURI,
+        thumbHash: mediaInfo.thumbnailThumbHash,
+      };
     } else if (mediaInfo.type === 'encrypted_photo') {
       return {
         kind: 'encrypted',
         holder: mediaInfo.holder,
         encryptionKey: mediaInfo.encryptionKey,
+        thumbHash: mediaInfo.thumbHash,
       };
     } else if (mediaInfo.type === 'encrypted_video') {
       return {
         kind: 'encrypted',
         holder: mediaInfo.thumbnailHolder,
         encryptionKey: mediaInfo.thumbnailEncryptionKey,
+        thumbHash: mediaInfo.thumbnailThumbHash,
       };
     } else {
       invariant(false, 'Invalid mediaInfo type');
diff --git a/web/input/input-state-container.react.js b/web/input/input-state-container.react.js
--- a/web/input/input-state-container.react.js
+++ b/web/input/input-state-container.react.js
@@ -329,6 +329,7 @@
               uri,
               type: 'photo',
               dimensions: shimmedDimensions,
+              thumbHash: null,
             };
           }
           invariant(
@@ -341,6 +342,7 @@
             type: 'encrypted_photo',
             encryptionKey,
             dimensions: shimmedDimensions,
+            thumbHash: null,
           };
         },
       );