diff --git a/api/go.mod b/api/go.mod
index cde3c2c..0a02010 100644
--- a/api/go.mod
+++ b/api/go.mod
@@ -7,6 +7,7 @@ require (
 	github.com/Kagami/go-face v0.0.0-20210630145111-0c14797b4d0e
 	github.com/agnivade/levenshtein v1.1.1 // indirect
 	github.com/barasher/go-exiftool v1.7.0
+	github.com/buckket/go-blurhash v1.1.0 // indirect
 	github.com/disintegration/imaging v1.6.2
 	github.com/felixge/httpsnoop v1.0.2 // indirect
 	github.com/go-sql-driver/mysql v1.6.0
diff --git a/api/go.sum b/api/go.sum
index 7cf51d9..e095497 100644
--- a/api/go.sum
+++ b/api/go.sum
@@ -15,6 +15,8 @@ github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig
 github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
 github.com/barasher/go-exiftool v1.7.0 h1:EOGb5D6TpWXmqsnEjJ0ai6+tIW2gZFwIoS9O/33Nixs=
 github.com/barasher/go-exiftool v1.7.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo=
+github.com/buckket/go-blurhash v1.1.0 h1:X5M6r0LIvwdvKiUtiNcRL2YlmOfMzYobI3VCKCZc9Do=
+github.com/buckket/go-blurhash v1.1.0/go.mod h1:aT2iqo5W9vu9GpyoLErKfTHwgODsZp3bQfXjXJUxNb8=
 github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
 github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
 github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
diff --git a/api/graphql/models/media.go b/api/graphql/models/media.go
index 6ed4485..3a7e1cb 100644
--- a/api/graphql/models/media.go
+++ b/api/graphql/models/media.go
@@ -29,6 +29,7 @@ type Media struct {
 	SideCarPath     *string
 	SideCarHash     *string      `gorm:"unique"`
 	Faces           []*ImageFace `gorm:"constraint:OnDelete:CASCADE;"`
+	Blurhash        *string      `gorm:""`
 
 	// Only used internally
 	CounterpartPath *string `gorm:"-"`
@@ -49,6 +50,21 @@ func (m *Media) Date() time.Time {
 	return m.DateShot
 }
 
+func (m *Media) Thumbnail() (*MediaURL, error) {
+	if len(m.MediaURL) == 0 {
+		return nil, errors.New("media.MediaURL is empty")
+	}
+
+	for _, url := range m.MediaURL {
+		if url.Purpose == PhotoThumbnail || url.Purpose == VideoThumbnail {
+			url.Media = m
+			return &url, nil
+		}
+	}
+
+	return nil, nil
+}
+
 type MediaType string
 
 const (
diff --git a/api/scanner/queue.go b/api/scanner/queue.go
index 42d5304..1037f8c 100644
--- a/api/scanner/queue.go
+++ b/api/scanner/queue.go
@@ -9,6 +9,7 @@ import (
 	"github.com/photoview/photoview/api/graphql/models"
 	"github.com/photoview/photoview/api/graphql/notification"
 	"github.com/photoview/photoview/api/scanner/scanner_cache"
+	"github.com/photoview/photoview/api/scanner/scanner_utils"
 	"github.com/photoview/photoview/api/utils"
 	"github.com/pkg/errors"
 	"gorm.io/gorm"
@@ -156,8 +157,20 @@ func (queue *ScannerQueue) processQueue(notifyThrottle *utils.Throttle) {
 		notification.BroadcastNotification(&models.Notification{
 			Key:      "global-scanner-progress",
 			Type:     models.NotificationTypeMessage,
-			Header:   fmt.Sprintf("Scanner complete"),
-			Content:  fmt.Sprintf("All jobs have been scanned"),
+			Header:   "Generating blurhashes",
+			Content:  "Generating blurhashes for newly scanned media",
+			Positive: true,
+		})
+
+		if err := GenerateBlurhashes(queue.db); err != nil {
+			scanner_utils.ScannerError("Failed to generate blurhashes: %v", err)
+		}
+
+		notification.BroadcastNotification(&models.Notification{
+			Key:      "global-scanner-progress",
+			Type:     models.NotificationTypeMessage,
+			Header:   "Scanner complete",
+			Content:  "All jobs have been scanned",
 			Positive: true,
 		})
 	} else {
@@ -165,7 +178,7 @@ func (queue *ScannerQueue) processQueue(notifyThrottle *utils.Throttle) {
 			notification.BroadcastNotification(&models.Notification{
 				Key:     "global-scanner-progress",
 				Type:    models.NotificationTypeMessage,
-				Header:  fmt.Sprintf("Scanning media"),
+				Header:  "Scanning media",
 				Content: fmt.Sprintf("%d jobs in progress\n%d jobs waiting", in_progress_length, up_next_length),
 			})
 		})
@@ -191,7 +204,9 @@ func AddAllToQueue() error {
 	}
 
 	for _, user := range users {
-		AddUserToQueue(user)
+		if err := AddUserToQueue(user); err != nil {
+			return errors.Wrapf(err, "failed to add user for scanning (%d)", user.ID)
+		}
 	}
 
 	return nil
diff --git a/api/scanner/thumbnail_blurhash.go b/api/scanner/thumbnail_blurhash.go
new file mode 100644
index 0000000..066ad82
--- /dev/null
+++ b/api/scanner/thumbnail_blurhash.go
@@ -0,0 +1,98 @@
+package scanner
+
+import (
+	"fmt"
+	"image"
+	"log"
+	"os"
+
+	"github.com/buckket/go-blurhash"
+	"github.com/photoview/photoview/api/graphql/models"
+	"gorm.io/gorm"
+)
+
+// GenerateBlurhashes queries the database for media that are missing a blurhash and computes one for them.
+// This function blocks until all hashes have been computed
+func GenerateBlurhashes(db *gorm.DB) error {
+	var results []*models.Media
+
+	processErrors := make([]error, 0)
+
+	query := db.Model(&models.Media{}).
+		Preload("MediaURL").
+		Joins("INNER JOIN media_urls ON media.id = media_urls.media_id").
+		Where("blurhash IS NULL").
+		Where("media_urls.purpose = 'thumbnail' OR media_urls.purpose = 'video-thumbnail'")
+
+	err := query.FindInBatches(&results, 100, func(tx *gorm.DB, batch int) error {
+		log.Printf("generating %d blurhashes", len(results))
+
+		hashes := make([]*string, len(results))
+
+		for i, row := range results {
+
+			thumbnail, err := row.Thumbnail()
+			if err != nil {
+				log.Printf("failed to get thumbnail for media to generate blurhash (%d): %v", row.ID, err)
+				processErrors = append(processErrors, err)
+				continue
+			}
+
+			hashStr, err := GenerateBlurhashFromThumbnail(thumbnail)
+			if err != nil {
+				log.Printf("failed to generate blurhash for media (%d): %v", row.ID, err)
+				processErrors = append(processErrors, err)
+				continue
+			}
+
+			hashes[i] = &hashStr
+			results[i].Blurhash = &hashStr
+		}
+
+		tx.Save(results)
+		// if err := db.Update("blurhash", hashes).Error; err != nil {
+		// 	return err
+		// }
+
+		return nil
+	}).Error
+
+	if err != nil {
+		return err
+	}
+
+	if len(processErrors) == 0 {
+		return nil
+	} else {
+		return fmt.Errorf("failed to generate %d blurhashes", len(processErrors))
+	}
+}
+
+// GenerateBlurhashFromThumbnail generates a blurhash for a single media and stores it in the database
+func GenerateBlurhashFromThumbnail(thumbnail *models.MediaURL) (string, error) {
+	thumbnail_path, err := thumbnail.CachedPath()
+	if err != nil {
+		return "", err
+	}
+
+	imageFile, err := os.Open(thumbnail_path)
+	if err != nil {
+		return "", err
+	}
+
+	imageData, _, err := image.Decode(imageFile)
+	if err != nil {
+		return "", err
+	}
+
+	hashStr, err := blurhash.Encode(4, 3, imageData)
+	if err != nil {
+		return "", err
+	}
+
+	// if err := db.Model(&models.Media{}).Where("id = ?", thumbnail.MediaID).Update("blurhash", hashStr).Error; err != nil {
+	// 	return "", err
+	// }
+
+	return hashStr, nil
+}
