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
@@ -216,6 +216,13 @@
   +orientation: ?number,
 };
 
+export type GenerateThumbhashMediaMissionStep = {
+  +step: 'generate_thumbhash',
+  +success: boolean,
+  +exceptionMessage: ?string,
+  +thumbHash: ?string,
+};
+
 export type EncryptFileMediaMissionStep =
   | {
       +step: 'read_plaintext_file',
@@ -452,6 +459,7 @@
   | CopyFileMediaMissionStep
   | EncryptFileMediaMissionStep
   | GetOrientationMediaMissionStep
+  | GenerateThumbhashMediaMissionStep
   | {
       +step: 'preload_image',
       +success: boolean,
diff --git a/native/media/encryption-utils.js b/native/media/encryption-utils.js
--- a/native/media/encryption-utils.js
+++ b/native/media/encryption-utils.js
@@ -181,6 +181,12 @@
   }
 
   if (preprocessedMedia.mediaType === 'photo') {
+    const thumbHashResult = preprocessedMedia.thumbHash
+      ? encryptBase64(
+          preprocessedMedia.thumbHash,
+          hexToUintArray(encryptionResult.encryptionKey),
+        )
+      : null;
     return {
       steps,
       result: {
@@ -188,6 +194,7 @@
         mediaType: 'encrypted_photo',
         uploadURI: encryptionResult.uri,
         blobHash: encryptionResult.sha256Hash,
+        thumbHash: thumbHashResult?.base64,
         encryptionKey: encryptionResult.encryptionKey,
         shouldDisposePath: pathFromURI(encryptionResult.uri),
       },
@@ -203,6 +210,13 @@
     return { steps, result: thumbnailEncryptionResult };
   }
 
+  const thumbHashResult = preprocessedMedia.thumbHash
+    ? encryptBase64(
+        preprocessedMedia.thumbHash,
+        hexToUintArray(thumbnailEncryptionResult.encryptionKey),
+      )
+    : null;
+
   return {
     steps,
     result: {
@@ -210,6 +224,7 @@
       mediaType: 'encrypted_video',
       uploadURI: encryptionResult.uri,
       blobHash: encryptionResult.sha256Hash,
+      thumbHash: thumbHashResult?.base64,
       encryptionKey: encryptionResult.encryptionKey,
       uploadThumbnailURI: thumbnailEncryptionResult.uri,
       thumbnailBlobHash: thumbnailEncryptionResult.sha256Hash,
diff --git a/native/media/image-utils.js b/native/media/image-utils.js
--- a/native/media/image-utils.js
+++ b/native/media/image-utils.js
@@ -10,6 +10,8 @@
 } from 'lib/types/media-types.js';
 import { getMessageForException } from 'lib/utils/errors.js';
 
+import { generateThumbhashStep } from './media-utils.js';
+
 type ProcessImageInfo = {
   uri: string,
   dimensions: Dimensions,
@@ -22,6 +24,7 @@
   uri: string,
   mime: string,
   dimensions: Dimensions,
+  thumbHash: ?string,
 };
 async function processImage(input: ProcessImageInfo): Promise<{
   steps: $ReadOnlyArray<MediaMissionStep>,
@@ -38,9 +41,12 @@
     inputOrientation: orientation,
   });
   if (plan.action === 'none') {
+    const thumbhashStep = await generateThumbhashStep(uri);
+    steps.push(thumbhashStep);
+    const { thumbHash } = thumbhashStep;
     return {
       steps,
-      result: { success: true, uri, dimensions, mime },
+      result: { success: true, uri, dimensions, mime, thumbHash },
     };
   }
   const { targetMIME, compressionRatio, fitInside } = plan;
@@ -99,7 +105,14 @@
     };
   }
 
-  return { steps, result: { success: true, uri, dimensions, mime } };
+  const thumbhashStep = await generateThumbhashStep(uri);
+  steps.push(thumbhashStep);
+  const { thumbHash } = thumbhashStep;
+
+  return {
+    steps,
+    result: { success: true, uri, dimensions, mime, thumbHash },
+  };
 }
 
 export { processImage };
diff --git a/native/media/media-utils.js b/native/media/media-utils.js
--- a/native/media/media-utils.js
+++ b/native/media/media-utils.js
@@ -9,12 +9,15 @@
   MediaMissionStep,
   MediaMissionFailure,
   NativeMediaSelection,
+  GenerateThumbhashMediaMissionStep,
 } from 'lib/types/media-types.js';
+import { getMessageForException } from 'lib/utils/errors.js';
 
 import { fetchFileInfo } from './file-utils.js';
 import { processImage } from './image-utils.js';
 import { saveMedia } from './save-media.js';
 import { processVideo } from './video-utils.js';
+import { generateThumbHash } from '../utils/thumbhash-module.js';
 
 type MediaProcessConfig = {
   +hasWiFi: boolean,
@@ -29,6 +32,7 @@
   +filename: string,
   +mime: string,
   +dimensions: Dimensions,
+  +thumbHash: ?string,
 };
 export type MediaResult =
   | { +mediaType: 'photo', ...SharedMediaResult }
@@ -88,7 +92,8 @@
     mediaType = null,
     mime = null,
     loop = false,
-    resultReturned = false;
+    resultReturned = false,
+    thumbHash = null;
   const returnResult = (failure?: MediaMissionFailure) => {
     invariant(
       !resultReturned,
@@ -118,6 +123,7 @@
         mediaType,
         dimensions,
         loop,
+        thumbHash,
       });
     } else {
       sendResult({
@@ -128,6 +134,7 @@
         mime,
         mediaType,
         dimensions,
+        thumbHash,
       });
     }
   };
@@ -208,6 +215,7 @@
       mime,
       dimensions,
       loop,
+      thumbHash,
     } = videoResult);
   } else if (mediaType === 'photo') {
     const { steps: imageSteps, result: imageResult } = await processImage({
@@ -221,7 +229,7 @@
     if (!imageResult.success) {
       return await finish(imageResult);
     }
-    ({ uri: uploadURI, mime, dimensions } = imageResult);
+    ({ uri: uploadURI, mime, dimensions, thumbHash } = imageResult);
   } else {
     invariant(false, `unknown mediaType ${mediaType}`);
   }
@@ -264,4 +272,22 @@
   });
 }
 
-export { processMedia, getDimensions };
+async function generateThumbhashStep(
+  uri: string,
+): Promise<GenerateThumbhashMediaMissionStep> {
+  let thumbHash, exceptionMessage;
+  try {
+    thumbHash = await generateThumbHash(uri);
+  } catch (err) {
+    exceptionMessage = getMessageForException(err);
+  }
+
+  return {
+    step: 'generate_thumbhash',
+    success: !!thumbHash && !exceptionMessage,
+    exceptionMessage,
+    thumbHash,
+  };
+}
+
+export { processMedia, getDimensions, generateThumbhashStep };
diff --git a/native/media/video-utils.js b/native/media/video-utils.js
--- a/native/media/video-utils.js
+++ b/native/media/video-utils.js
@@ -19,6 +19,7 @@
 
 import { ffmpeg } from './ffmpeg.js';
 import { temporaryDirectoryPath } from './file-utils.js';
+import { generateThumbhashStep } from './media-utils.js';
 
 // These are some numbers I sorta kinda made up
 // We should try to calculate them on a per-device basis
@@ -48,6 +49,7 @@
   +mime: string,
   +dimensions: Dimensions,
   +loop: boolean,
+  +thumbHash: ?string,
 };
 async function processVideo(
   input: ProcessVideoInfo,
@@ -104,12 +106,17 @@
         result: { success: false, reason: 'video_generate_thumbnail_failed' },
       };
     }
+    const thumbnailURI = `file://${plan.thumbnailPath}`;
+    const thumbhashStep = await generateThumbhashStep(thumbnailURI);
+    steps.push(thumbhashStep);
+    const { thumbHash } = thumbhashStep;
     return {
       steps,
       result: {
         success: true,
         uri: input.uri,
-        thumbnailURI: `file://${plan.thumbnailPath}`,
+        thumbnailURI,
+        thumbHash,
         mime: 'video/mp4',
         dimensions: input.dimensions,
         loop: false,
@@ -165,12 +172,17 @@
     mediaConfig[input.mime].videoConfig &&
     mediaConfig[input.mime].videoConfig.loop
   );
+  const thumbnailURI = `file://${plan.thumbnailPath}`;
+  const thumbhashStep = await generateThumbhashStep(thumbnailURI);
+  steps.push(thumbhashStep);
+  const { thumbHash } = thumbhashStep;
   return {
     steps,
     result: {
       success: true,
       uri: `file://${plan.outputPath}`,
-      thumbnailURI: `file://${plan.thumbnailPath}`,
+      thumbnailURI,
+      thumbHash,
       mime: 'video/mp4',
       dimensions,
       loop,