diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj
index d1da43c97..ff24bed62 100644
--- a/backend/src/app/rpc/commands/files.clj
+++ b/backend/src/app/rpc/commands/files.clj
@@ -38,6 +38,11 @@
 
 ;; --- FEATURES
 
+(defn resolve-public-uri
+  [media-id]
+  (when media-id
+    (str (cf/get :public-uri) "/assets/by-id/" media-id)))
+
 (def supported-features
   #{"storage/objects-map"
     "storage/pointer-map"
@@ -413,15 +418,23 @@
           f.modified_at,
           f.name,
           f.revn,
-          f.is_shared
+          f.is_shared,
+          ft.media_id
      from file as f
+     left join file_thumbnail as ft on (ft.file_id = f.id and ft.revn = f.revn)
     where f.project_id = ?
       and f.deleted_at is null
     order by f.modified_at desc")
 
 (defn get-project-files
   [conn project-id]
-  (db/exec! conn [sql:project-files project-id]))
+  (->> (db/exec! conn [sql:project-files project-id])
+       (mapv (fn [row]
+               (if-let [media-id (:media-id row)]
+                 (-> row
+                     (dissoc :media-id)
+                     (assoc :thumbnail-uri (resolve-public-uri media-id)))
+                 (dissoc row :media-id))))))
 
 (sv/defmethod ::get-project-files
   "Get all files for the specified project."
@@ -668,9 +681,11 @@
             f.modified_at,
             f.name,
             f.is_shared,
+            ft.media_id,
             row_number() over w as row_num
        from file as f
-       join project as p on (p.id = f.project_id)
+      inner join project as p on (p.id = f.project_id)
+       left join file_thumbnail as ft on (ft.file_id = f.id and ft.revn = f.revn)
       where p.team_id = ?
         and p.deleted_at is null
         and f.deleted_at is null
@@ -681,7 +696,13 @@
 
 (defn get-team-recent-files
   [conn team-id]
-  (db/exec! conn [sql:team-recent-files team-id]))
+  (->> (db/exec! conn [sql:team-recent-files team-id])
+       (mapv (fn [row]
+               (if-let [media-id (:media-id row)]
+                 (-> row
+                     (dissoc :media-id)
+                     (assoc :thumbnail-uri (resolve-public-uri media-id)))
+                 (dissoc row :media-id))))))
 
 (s/def ::get-team-recent-files
   (s/keys :req [::rpc/profile-id]
diff --git a/backend/src/app/rpc/commands/files_thumbnails.clj b/backend/src/app/rpc/commands/files_thumbnails.clj
index 8a1f5cb72..19233969e 100644
--- a/backend/src/app/rpc/commands/files_thumbnails.clj
+++ b/backend/src/app/rpc/commands/files_thumbnails.clj
@@ -14,7 +14,6 @@
    [app.common.schema :as sm]
    [app.common.spec :as us]
    [app.common.types.shape-tree :as ctt]
-   [app.config :as cf]
    [app.db :as db]
    [app.db.sql :as sql]
    [app.loggers.audit :as-alias audit]
@@ -39,10 +38,6 @@
 
 ;; --- COMMAND QUERY: get-file-object-thumbnails
 
-(defn- get-public-uri
-  [media-id]
-  (str (cf/get :public-uri) "/assets/by-id/" media-id))
-
 (defn- get-object-thumbnails
   ([conn file-id]
    (let [sql (str/concat
@@ -52,7 +47,7 @@
          res (db/exec! conn [sql file-id])]
      (->> res
           (d/index-by :object-id (fn [row]
-                                   (or (some-> row :media-id get-public-uri)
+                                   (or (some-> row :media-id files/resolve-public-uri)
                                        (:data row))))
           (d/without-nils))))
 
@@ -65,7 +60,7 @@
          res (db/exec! conn [sql file-id ids])]
      (d/index-by :object-id
                  (fn [row]
-                   (or (some-> row :media-id get-public-uri)
+                   (or (some-> row :media-id files/resolve-public-uri)
                        (:data row)))
                  res))))
 
@@ -85,8 +80,6 @@
 
 ;; --- COMMAND QUERY: get-file-thumbnail
 
-;; FIXME: refactor to support uploading data to storage
-
 (defn get-file-thumbnail
   [conn file-id revn]
   (let [sql (sql/select :file-thumbnail
@@ -95,10 +88,15 @@
                         {:limit 1
                          :order-by [[:revn :desc]]})
         row (db/exec-one! conn sql)]
+
     (when-not row
       (ex/raise :type :not-found
                 :code :file-thumbnail-not-found))
 
+    (when-not (:data row)
+      (ex/raise :type :not-found
+                :code :file-thumbnail-not-found))
+
     {:data (:data row)
      :props (some-> (:props row) db/decode-transit-pgobject)
      :revn (:revn row)
@@ -113,20 +111,16 @@
           :opt-un [::revn]))
 
 (sv/defmethod ::get-file-thumbnail
-  "Method used in frontend for obtain the file thumbnail (used in the
-  dashboard)."
-  {::doc/added "1.17"}
+  {::doc/added "1.17"
+   ::doc/deprecated "1.19"}
   [{:keys [::db/pool]} {:keys [::rpc/profile-id file-id revn]}]
   (dm/with-open [conn (db/open pool)]
     (files/check-read-permissions! conn profile-id file-id)
     (-> (get-file-thumbnail conn file-id revn)
         (rph/with-http-cache long-cache-duration))))
 
-
 ;; --- COMMAND QUERY: get-file-data-for-thumbnail
 
-;; FIXME: performance issue, handle new media_id
-;;
 ;; We need to improve how we set frame for thumbnail in order to avoid
 ;; loading all pages into memory for find the frame set for thumbnail.
 
@@ -427,24 +421,27 @@
                                 :bucket "file-thumbnail"})]
     (db/exec-one! conn [sql:create-file-thumbnail file-id revn
                         (:id media) props
-                        (:id media) props])))
-
-(s/def ::media ::media/upload)
-(s/def ::create-file-thumbnail
-  (s/keys :req [::rpc/profile-id]
-          :req-un [::file-id ::revn ::props ::media]))
+                        (:id media) props])
+    media))
 
 (sv/defmethod ::create-file-thumbnail
   "Creates or updates the file thumbnail. Mainly used for paint the
   grid thumbnails."
   {::doc/added "1.19"
-   ::audit/skip true}
+   ::audit/skip true
+   ::sm/params [:map {:title "create-file-thumbnail"}
+                [:file-id ::sm/uuid]
+                [:revn :int]
+                [:media ::media/upload]]
+   }
+
   [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
   (db/with-atomic [conn pool]
     (files/check-edition-permissions! conn profile-id file-id)
     (when-not (db/read-only? conn)
-      (-> cfg
-          (update ::sto/storage media/configure-assets-storage)
-          (assoc ::db/conn conn)
-          (create-file-thumbnail! params))
-      nil)))
+      (let [media (-> cfg
+                      (update ::sto/storage media/configure-assets-storage)
+                      (assoc ::db/conn conn)
+                      (create-file-thumbnail! params))]
+
+        {:uri (files/resolve-public-uri (:id media))}))))
diff --git a/backend/src/app/tasks/file_gc.clj b/backend/src/app/tasks/file_gc.clj
index 9eceac13d..9b9c2134a 100644
--- a/backend/src/app/tasks/file_gc.clj
+++ b/backend/src/app/tasks/file_gc.clj
@@ -184,7 +184,7 @@
     (when (seq res)
       (doseq [media-id (into #{} (keep :media-id) res)]
         ;; Mark as deleted the storage object related with the
-        ;; photo-id field.
+        ;; media-id field.
         (l/trace :hint "mark storage object as deleted" :id media-id)
         (sto/del-object! storage media-id))
 
diff --git a/backend/test/backend_tests/rpc_file_thumbnails_test.clj b/backend/test/backend_tests/rpc_file_thumbnails_test.clj
index 84687d04f..14b0f72da 100644
--- a/backend/test/backend_tests/rpc_file_thumbnails_test.clj
+++ b/backend/test/backend_tests/rpc_file_thumbnails_test.clj
@@ -141,7 +141,7 @@
       )))
 
 
-(t/deftest upsert-file-thumbnail
+(t/deftest create-file-thumbnail
   (let [storage (::sto/storage th/*system*)
         profile (th/create-profile* 1)
         file    (th/create-file* 1 {:profile-id (:id profile)
@@ -159,7 +159,6 @@
         data2   {::th/type :create-file-thumbnail
                  ::rpc/profile-id (:id profile)
                  :file-id (:id file)
-                 :props {}
                  :revn 2
                  :media {:filename "sample.jpg"
                          :size 7923
@@ -169,7 +168,6 @@
         data3   {::th/type :create-file-thumbnail
                  ::rpc/profile-id (:id profile)
                  :file-id (:id file)
-                 :props {}
                  :revn 3
                  :media {:filename "sample.jpg"
                          :size 312043
@@ -183,11 +181,11 @@
     (let [out (th/command! data2)]
       ;; (th/print-result! out)
       (t/is (nil? (:error out)))
-      (t/is (nil? (:result out))))
+      (t/is (contains? (:result out) :uri)))
 
     (let [out (th/command! data3)]
       (t/is (nil? (:error out)))
-      (t/is (nil? (:result out))))
+      (t/is (contains? (:result out) :uri)))
 
     (let [[row1 row2 row3 :as rows] (th/db-query :file-thumbnail
                                                  {:file-id (:id file)}
diff --git a/frontend/gulpfile.js b/frontend/gulpfile.js
index f07eba915..2083b09df 100644
--- a/frontend/gulpfile.js
+++ b/frontend/gulpfile.js
@@ -131,7 +131,8 @@ function readManifest() {
       "polyfills": "js/polyfills.js",
       "main": "js/main.js",
       "shared": "js/shared.js",
-      "worker": "js/worker.js"
+      "worker": "js/worker.js",
+      "thumbnail-renderer": "js/thumbnail-renderer.js"
     };
   }
 }
@@ -242,7 +243,17 @@ gulp.task("template:render", templatePipeline({
   output: paths.output
 }));
 
-gulp.task("templates", gulp.series("svg:sprite:icons", "svg:sprite:cursors", "template:main", "template:render"));
+gulp.task("template:thumbnail-renderer", templatePipeline({
+  name: "thumbnail-renderer.html",
+  input: paths.resources + "templates/thumbnail-renderer.mustache",
+  output: paths.output
+}));
+
+gulp.task("templates", gulp.series("svg:sprite:icons",
+                                   "svg:sprite:cursors",
+                                   "template:main",
+                                   "template:render",
+                                   "template:thumbnail-renderer"));
 
 gulp.task("polyfills", function() {
   return gulp.src(paths.resources + "polyfills/*.js")
diff --git a/frontend/resources/styles/main/partials/dashboard-grid.scss b/frontend/resources/styles/main/partials/dashboard-grid.scss
index d2c9b8fe2..2108b42d3 100644
--- a/frontend/resources/styles/main/partials/dashboard-grid.scss
+++ b/frontend/resources/styles/main/partials/dashboard-grid.scss
@@ -51,6 +51,10 @@
       border-radius: $br3;
       border: 2px solid lighten($color-gray-20, 15%);
       text-align: initial;
+
+      img {
+        object-fit: contain;
+      }
     }
 
     &.dragged {
diff --git a/frontend/resources/templates/thumbnail-renderer.mustache b/frontend/resources/templates/thumbnail-renderer.mustache
new file mode 100644
index 000000000..261cd05c0
--- /dev/null
+++ b/frontend/resources/templates/thumbnail-renderer.mustache
@@ -0,0 +1,26 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8" />
+    <title>Penpot - Thumbnail Renderer</title>
+    <link rel="icon" href="images/favicon.png" />
+
+    <script>
+      window.penpotVersion = "%version%";
+      window.penpotBuildDate = "%buildDate%";
+    </script>
+
+    {{# manifest}}
+    <script>window.penpotWorkerURI="{{& worker}}"</script>
+    <script src="{{& config}}"></script>
+    <script src="{{& polyfills}}"></script>
+    {{/manifest}}
+
+  </head>
+  <body>
+    {{# manifest}}
+    <script src="{{& shared}}"></script>
+    <script src="{{& thumbnail-renderer}}"></script>
+    {{/manifest}}
+  </body>
+</html>
diff --git a/frontend/shadow-cljs.edn b/frontend/shadow-cljs.edn
index bb641ad0c..ea6faa935 100644
--- a/frontend/shadow-cljs.edn
+++ b/frontend/shadow-cljs.edn
@@ -16,17 +16,25 @@
    :modules
    {:shared {:entries []}
 
-    :main {:entries [app.main]
-           :depends-on #{:shared}
-           :init-fn app.main/init}
+    :main
+    {:entries [app.main]
+     :depends-on #{:shared}
+     :init-fn app.main/init}
 
-    :render {:entries [app.render]
-             :depends-on #{:shared}
-             :init-fn app.render/init}
+    :render
+    {:entries [app.render]
+     :depends-on #{:shared}
+     :init-fn app.render/init}
 
-    :worker {:entries [app.worker]
-             :web-worker true
-             :depends-on #{:shared}}}
+    :worker
+    {:entries [app.worker]
+     :web-worker true
+     :depends-on #{:shared}}
+
+    :thumbnail-renderer
+    {:entries [app.thumbnail-renderer]
+     :depends-on #{:shared}
+     :init-fn app.thumbnail-renderer/init}}
 
    :compiler-options
    {:output-feature-set :es2020
diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs
index 4f5191ab0..72c22713b 100644
--- a/frontend/src/app/config.cljs
+++ b/frontend/src/app/config.cljs
@@ -84,7 +84,6 @@
 (def default-theme  "default")
 (def default-language "en")
 
-(def worker-uri           (obj/get global "penpotWorkerURI" "/js/worker.js"))
 (def translations         (obj/get global "penpotTranslations"))
 (def themes               (obj/get global "penpotThemes"))
 
@@ -110,7 +109,14 @@
 (def public-uri
   (atom
    (normalize-uri (or (obj/get global "penpotPublicURI")
-                      (.-origin ^js location)))))
+                      (obj/get location "origin")))))
+
+(def thumbnail-renderer-uri
+  (or (some-> (obj/get global "penpotThumbnailRendererURI") normalize-uri)
+      (deref public-uri)))
+
+(def worker-uri
+  (obj/get global "penpotWorkerURI" "/js/worker.js"))
 
 ;; --- Helper Functions
 
diff --git a/frontend/src/app/main.cljs b/frontend/src/app/main.cljs
index 362ca0a58..ae4fa5fdd 100644
--- a/frontend/src/app/main.cljs
+++ b/frontend/src/app/main.cljs
@@ -15,6 +15,7 @@
    [app.main.errors]
    [app.main.features :as feat]
    [app.main.store :as st]
+   [app.main.thumbnail-renderer :as tr]
    [app.main.ui :as ui]
    [app.main.ui.alert]
    [app.main.ui.confirm]
@@ -80,6 +81,7 @@
   (i18n/init! cf/translations)
   (theme/init! cf/themes)
   (cur/init-styles)
+  (tr/init!)
   (init-ui)
   (st/emit! (initialize)))
 
diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs
index fdf4224cf..ce07c3289 100644
--- a/frontend/src/app/main/data/dashboard.cljs
+++ b/frontend/src/app/main/data/dashboard.cljs
@@ -782,6 +782,15 @@
         (->> (rp/cmd! :set-file-shared params)
              (rx/ignore))))))
 
+(defn set-file-thumbnail
+  [file-id thumbnail-uri]
+  (ptk/reify ::set-file-thumbnail
+    ptk/UpdateEvent
+    (update [_ state]
+      (-> state
+          (d/update-in-when [:dashboard-files file-id] assoc :thumbnail-uri thumbnail-uri)
+          (d/update-in-when [:dashboard-recent-files file-id] assoc :thumbnail-uri thumbnail-uri)))))
+
 ;; --- EVENT: create-file
 
 (declare file-created)
diff --git a/frontend/src/app/main/fonts.cljs b/frontend/src/app/main/fonts.cljs
index a9386ca2f..4a4dc8b6f 100644
--- a/frontend/src/app/main/fonts.cljs
+++ b/frontend/src/app/main/fonts.cljs
@@ -9,6 +9,7 @@
   (:require-macros [app.main.fonts :refer [preload-gfonts]])
   (:require
    [app.common.data :as d]
+   [app.common.data.macros :as dm]
    [app.common.logging :as log]
    [app.common.text :as txt]
    [app.config :as cf]
@@ -148,15 +149,13 @@
 
 ;; --- LOADER: CUSTOM
 
-(def font-css-template
+(def font-face-template
   "@font-face {
     font-family: '%(family)s';
     font-style: %(style)s;
     font-weight: %(weight)s;
     font-display: block;
-    src: url(%(woff1-uri)s) format('woff'),
-         url(%(ttf-uri)s) format('ttf'),
-         url(%(otf-uri)s) format('otf');
+    src: url(%(uri)s) format('woff');
   }")
 
 (defn- asset-id->uri
@@ -165,14 +164,11 @@
 
 (defn generate-custom-font-variant-css
   [family variant]
-  (str/fmt font-css-template
+  (str/fmt font-face-template
            {:family family
             :style (:style variant)
             :weight (:weight variant)
-            :woff2-uri (asset-id->uri (::woff2-file-id variant))
-            :woff1-uri (asset-id->uri (::woff1-file-id variant))
-            :ttf-uri (asset-id->uri (::ttf-file-id variant))
-            :otf-uri (asset-id->uri (::otf-file-id variant))}))
+            :uri (asset-id->uri (::woff1-file-id variant))}))
 
 (defn- generate-custom-font-css
   [{:keys [family variants] :as font}]
@@ -237,26 +233,19 @@
   (-> (obj/get-in js/document ["fonts" "ready"])
       (p/then cb)))
 
-(defn get-default-variant [{:keys [variants]}]
-  (or
-   (d/seek #(or (= (:id %) "regular") (= (:name %) "regular")) variants)
-   (first variants)))
+(defn get-default-variant
+  [{:keys [variants]}]
+  (or (d/seek #(or (= (:id %) "regular")
+                   (= (:name %) "regular")) variants)
+      (first variants)))
+
+(defn get-variant
+  [{:keys [variants] :as font} font-variant-id]
+  (or (d/seek #(= (:id %) font-variant-id) variants)
+      (get-default-variant font)))
 
 ;; Font embedding functions
 
-;; Template for a CSS font face
-
-(def font-face-template "
-/* latin */
-@font-face {
-  font-family: '%(family)s';
-  font-style: %(style)s;
-  font-weight: %(weight)s;
-  font-display: block;
-  src: url(%(baseurl)sfonts/%(family)s-%(suffix)s.woff) format('woff');
-}
-")
-
 (defn get-content-fonts
   "Extracts the fonts used by the content of a text shape"
   [{font-id :font-id children :children :as content}]
@@ -267,38 +256,52 @@
         children-font (->> children (mapv get-content-fonts))]
     (reduce set/union (conj children-font current-font))))
 
-
 (defn fetch-font-css
   "Given a font and the variant-id, retrieves the fontface CSS"
   [{:keys [font-id font-variant-id]
     :or   {font-variant-id "regular"}}]
 
-  (let [{:keys [backend family variants]} (get @fontsdb font-id)]
+  (let [{:keys [backend family] :as font} (get @fontsdb font-id)]
     (cond
+      (nil? font)
+      (rx/empty)
+
       (= :google backend)
-      (let [variant (d/seek #(= (:id %) font-variant-id) variants)]
+      (let [variant (get-variant font font-variant-id)]
         (-> (generate-gfonts-url
              {:family family
               :variants [variant]})
             (http/fetch-text)))
 
       (= :custom backend)
-      (let [variant (d/seek #(= (:id %) font-variant-id) variants)
+      (let [variant (get-variant font font-variant-id)
             result  (generate-custom-font-variant-css family variant)]
-        (p/resolved result))
+        (rx/of result))
 
       :else
-      (let [{:keys [weight style suffix] :as variant}
-            (d/seek #(= (:id %) font-variant-id) variants)
-            font-data {:baseurl (str @cf/public-uri)
-                       :family family
-                       :style style
-                       :suffix (or suffix font-variant-id)
-                       :weight weight}]
-        (rx/of (str/fmt font-face-template font-data))))))
+      (let [{:keys [weight style suffix]} (get-variant font font-variant-id)
+            suffix (or suffix font-variant-id)
+            params {:uri (dm/str @cf/public-uri "fonts/" family "-" suffix ".woff")
+                    :family family
+                    :style style
+                    :weight weight}]
+        (rx/of (str/fmt font-face-template params))))))
 
 (defn extract-fontface-urls
   "Parses the CSS and retrieves the font urls"
   [^string css]
   (->> (re-seq #"url\(([^)]+)\)" css)
        (mapv second)))
+
+(defn render-font-styles
+  [ids]
+  (->> (rx/from ids)
+       (rx/mapcat (fn [font-id]
+                    (let [font (get @fontsdb font-id)]
+                      (->> (:variants font [])
+                           (map :id)
+                           (map (fn [variant-id]
+                                  {:font-id font-id
+                                   :font-variant-id variant-id}))))))
+       (rx/mapcat fetch-font-css)
+       (rx/reduce (fn [acc css] (dm/str acc "\n" css)) "")))
diff --git a/frontend/src/app/main/repo.cljs b/frontend/src/app/main/repo.cljs
index ca834ce20..2221eae29 100644
--- a/frontend/src/app/main/repo.cljs
+++ b/frontend/src/app/main/repo.cljs
@@ -50,6 +50,11 @@
    :upsert-file-object-thumbnail {:query-params [:file-id :object-id]}
    :create-file-object-thumbnail {:query-params [:file-id :object-id]
                                   :form-data? true}
+
+   :create-file-thumbnail
+   {:query-params [:file-id :revn]
+    :form-data? true}
+
    :export-binfile {:response-type :blob}
    :import-binfile {:form-data? true}
    :retrieve-list-of-builtin-templates {:query-params :all}
diff --git a/frontend/src/app/main/thumbnail_renderer.cljs b/frontend/src/app/main/thumbnail_renderer.cljs
new file mode 100644
index 000000000..54c668af7
--- /dev/null
+++ b/frontend/src/app/main/thumbnail_renderer.cljs
@@ -0,0 +1,93 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) KALEIDOS INC
+
+(ns app.main.thumbnail-renderer
+  "A main entry point for the thumbnail renderer API interface.
+
+  This ns is responsible to provide an API for create thumbnail
+  renderer iframes and interact with them using asyncrhonous
+  messages."
+  (:require
+   [app.common.data.macros :as dm]
+   [app.common.uuid :as uuid]
+   [app.config :as cf]
+   [app.util.dom :as dom]
+   [beicon.core :as rx]
+   [cuerdas.core :as str]))
+
+(defonce ready? false)
+(defonce queue #js [])
+(defonce instance nil)
+(defonce msgbus (rx/subject))
+(defonce origin
+  (dm/str (assoc cf/thumbnail-renderer-uri :path "/thumbnail-renderer.html")))
+
+(declare send-message!)
+
+(defn- process-queued-messages!
+  []
+  (loop [message (.shift ^js queue)]
+    (when (some? message)
+      (send-message! message)
+      (recur (.shift ^js queue)))))
+
+(defn- on-message
+  "Handles a message from the thumbnail renderer."
+  [event]
+  (let [evorigin (unchecked-get event "origin")
+        evdata   (unchecked-get event "data")]
+
+    (when (and (object? evdata) (str/starts-with? origin evorigin))
+      (let [scope (unchecked-get evdata "scope")
+            type  (unchecked-get evdata "type")]
+        (when (= "penpot/thumbnail-renderer" scope)
+          (when (= type "ready")
+            (set! ready? true)
+            (process-queued-messages!))
+          (rx/push! msgbus evdata))))))
+
+(defn- send-message!
+  "Sends a message to the thumbnail renderer."
+  [message]
+  (let [window (.-contentWindow ^js instance)]
+    (.postMessage ^js window message origin)))
+
+(defn- queue-message!
+  "Queues a message to be sent to the thumbnail renderer when it's ready."
+  [message]
+  (.push ^js queue message))
+
+(defn render
+  "Renders a thumbnail."
+  [{:keys [data styles] :as params}]
+  (let [id      (dm/str (uuid/next))
+        payload #js {:data data :styles styles}
+        message #js {:id id
+                     :scope "penpot/thumbnail-renderer"
+                     :payload payload}]
+
+    (if ^boolean ready?
+      (send-message! message)
+      (queue-message! message))
+
+    (->> msgbus
+         (rx/filter #(= id (unchecked-get % "id")))
+         (rx/mapcat (fn [msg]
+                      (case (unchecked-get msg "type")
+                        "success" (rx/of (unchecked-get msg "payload"))
+                        "failure" (rx/throw (unchecked-get msg "payload")))))
+         (rx/take 1))))
+
+(defn init!
+  "Initializes the thumbnail renderer."
+  []
+  (let [iframe (dom/create-element "iframe")]
+    (dom/set-attribute! iframe "src" origin)
+    (dom/set-attribute! iframe "hidden" true)
+    (dom/append-child! js/document.body iframe)
+
+    (set! instance iframe)
+    (.addEventListener js/window "message" on-message)))
diff --git a/frontend/src/app/main/ui/dashboard/grid.cljs b/frontend/src/app/main/ui/dashboard/grid.cljs
index 4384e97b5..27fc812ad 100644
--- a/frontend/src/app/main/ui/dashboard/grid.cljs
+++ b/frontend/src/app/main/ui/dashboard/grid.cljs
@@ -16,7 +16,9 @@
    [app.main.fonts :as fonts]
    [app.main.refs :as refs]
    [app.main.render :refer [component-svg]]
+   [app.main.repo :as rp]
    [app.main.store :as st]
+   [app.main.thumbnail-renderer :as thr]
    [app.main.ui.components.color-bullet :as bc]
    [app.main.ui.dashboard.file-menu :refer [file-menu]]
    [app.main.ui.dashboard.import :refer [use-import-file]]
@@ -30,7 +32,6 @@
    [app.util.dom.dnd :as dnd]
    [app.util.i18n :as i18n :refer [tr]]
    [app.util.keyboard :as kbd]
-   [app.util.perf :as perf]
    [app.util.time :as dt]
    [app.util.timers :as ts]
    [beicon.core :as rx]
@@ -41,44 +42,49 @@
 
 ;; --- Grid Item Thumbnail
 
-(defn ask-for-thumbnail
+(defn- persist-thumbnail
+  [file-id revn blob]
+  (let [params {:file-id file-id :revn revn :media blob}]
+    (->> (rp/cmd! :create-file-thumbnail params)
+         (rx/map :uri))))
+
+(defn- ask-for-thumbnail
   "Creates some hooks to handle the files thumbnails cache"
-  [file]
+  [file-id revn]
   (let [features (cond-> ffeat/enabled
                    (features/active-feature? :components-v2)
                    (conj "components/v2"))]
 
-    (wrk/ask! {:cmd :thumbnails/generate-for-file
-               :revn (:revn file)
-               :file-id (:id file)
-               :file-name (:name file)
-               :features features})))
+    (->> (wrk/ask! {:cmd :thumbnails/generate-for-file
+                    :revn revn
+                    :file-id file-id
+                    :features features})
+         (rx/mapcat (fn [{:keys [fonts] :as result}]
+                      (->> (fonts/render-font-styles fonts)
+                           (rx/map (fn [styles]
+                                     (assoc result :styles styles))))))
+         (rx/mapcat thr/render)
+         (rx/mapcat (partial persist-thumbnail file-id revn)))))
 
 (mf/defc grid-item-thumbnail
-  {::mf/wrap [mf/memo]}
-  [{:keys [file] :as props}]
+  {::mf/wrap-props false}
+  [{:keys [file-id revn thumbnail-uri background-color]}]
   (let [container (mf/use-ref)
-        bgcolor   (dm/get-in file [:data :options :background])
         visible?  (h/use-visible container :once? true)]
 
-    (mf/with-effect [file visible?]
-      (when visible?
-        (let [tp (perf/tpoint)]
-          (->> (ask-for-thumbnail file)
-               (rx/subscribe-on :af)
-               (rx/subs (fn [{:keys [data fonts] :as params}]
-                          (run! fonts/ensure-loaded! fonts)
-                          (log/debug :hint "loaded thumbnail"
-                                     :file-id (dm/str (:id file))
-                                     :file-name (:name file)
-                                     :elapsed (str/ffmt "%ms" (tp)))
-                          (when-let [node (mf/ref-val container)]
-                            (dom/set-html! node data))))))))
+    (mf/with-effect [file-id revn visible? thumbnail-uri]
+      (when (and visible? (not thumbnail-uri))
+        (->> (ask-for-thumbnail file-id revn)
+             (rx/subs (fn [url]
+                        (st/emit! (dd/set-file-thumbnail file-id url)))))))
 
     [:div.grid-item-th
-     {:style {:background-color bgcolor}
+     {:style {:background-color background-color}
       :ref container}
-     i/loader-pencil]))
+     (when visible?
+       (if thumbnail-uri
+         [:img.grid-item-thumbnail-image {:src thumbnail-uri}]
+         i/loader-pencil))]))
 
 ;; --- Grid Item Library
 
@@ -312,7 +318,12 @@
       [:div.overlay]
       (if library-view?
         [:& grid-item-library {:file file}]
-        [:& grid-item-thumbnail {:file file}])
+        [:& grid-item-thumbnail
+         {:file-id (:id file)
+          :revn (:revn file)
+          :thumbnail-uri (:thumbnail-uri file)
+          :background-color (dm/get-in file [:data :options :background])}])
+
       (when (and (:is-shared file) (not library-view?))
         [:div.item-badge i/library])
       [:div.info-wrapper
diff --git a/frontend/src/app/thumbnail_renderer.cljs b/frontend/src/app/thumbnail_renderer.cljs
new file mode 100644
index 000000000..ad05d322f
--- /dev/null
+++ b/frontend/src/app/thumbnail_renderer.cljs
@@ -0,0 +1,245 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) KALEIDOS INC
+
+(ns app.thumbnail-renderer
+  "A main entry point for the thumbnail renderer process that is
+  executed on a separated iframe."
+  (:require
+   [app.common.data :as d]
+   [app.common.data.macros :as dm]
+   [app.common.exceptions :as ex]
+   [app.common.logging :as log]
+   [app.config :as cf]
+   [app.util.dom :as dom]
+   [app.util.http :as http]
+   [app.util.object :as obj]
+   [app.util.webapi :as wapi]
+   [beicon.core :as rx]
+   [cuerdas.core :as str]))
+
+(log/set-level! :trace)
+
+(declare send-success!)
+(declare send-failure!)
+
+(defonce parent-origin
+  (dm/str @cf/public-uri))
+
+(defn- get-document-element
+  [^js svg]
+  (.-documentElement svg))
+
+(defn- create-image
+  [uri]
+  (rx/create
+   (fn [subs]
+     (let [image (js/Image.)]
+       (obj/set! image "onload" #(do
+                                   (rx/push! subs image)
+                                   (rx/end! subs)))
+
+       (obj/set! image "crossOrigin" "anonymous")
+       (obj/set! image "onerror" #(rx/error! subs %))
+       (obj/set! image "onabort" #(rx/error! subs (ex/error :type :internal
+                                                            :code :abort
+                                                            :hint "operation aborted")))
+       (obj/set! image "src" uri)
+       (fn []
+         (obj/set! image "src" "")
+         (obj/set! image "onload" nil)
+         (obj/set! image "onerror" nil)
+         (obj/set! image "onabort" nil))))))
+
+(defn- svg-get-size
+  [svg max]
+  (let [doc  (get-document-element svg)
+        vbox (dom/get-attribute doc "viewBox")]
+    (when (string? vbox)
+      (let [[_ _ width height] (str/split vbox #"\s+")
+            width  (d/parse-integer width 0)
+            height (d/parse-integer height 0)
+            ratio  (/ width height)]
+        (if (> width height)
+          [max (* max (/ 1 ratio))]
+          [(* max ratio) max])))))
+
+(defn- svg-has-intrinsic-size?
+  "Returns true if the SVG has an intrinsic size."
+  [svg]
+  (let [doc    (get-document-element svg)
+        width  (dom/get-attribute doc "width")
+        height (dom/get-attribute doc "height")]
+    (d/num? width height)))
+
+(defn- svg-set-intrinsic-size!
+  "Sets the intrinsic size of an SVG to the given max size."
+  [^js svg max]
+  (when-not (svg-has-intrinsic-size? svg)
+    (let [doc   (get-document-element svg)
+          [w h] (svg-get-size svg max)]
+      (dom/set-attribute! doc "width" (dm/str w))
+      (dom/set-attribute! doc "height" (dm/str h))))
+  svg)
+
+(defn- fetch-as-data-uri
+  "Fetches a URL as a Data URI."
+  [uri]
+  (->> (http/send! {:uri uri
+                    :response-type :blob
+                    :method :get
+                    :mode :cors
+                    :omit-default-headers true})
+       (rx/map :body)
+       (rx/mapcat wapi/read-file-as-data-url)))
+
+(defn- svg-update-image!
+  "Updates an image in an SVG to a Data URI."
+  [image]
+  (when-let [href (dom/get-attribute image "href")]
+    (->> (fetch-as-data-uri href)
+         (rx/map (fn [url]
+                   (dom/set-attribute! image "href" url)
+                   image)))))
+
+(defn- svg-resolve-images!
+  "Resolves all images in an SVG to Data URIs."
+  [svg]
+  (->> (rx/from (dom/query-all svg "image"))
+       (rx/mapcat svg-update-image!)
+       (rx/ignore)))
+
+(defn- svg-add-style!
+  "Adds a <style> node to an SVG."
+  [svg styles]
+  (let [doc   (get-document-element svg)
+        style (dom/create-element svg "http://www.w3.org/2000/svg" "style")]
+    (dom/append-child! style (dom/create-text svg styles))
+    (dom/append-child! doc style)))
+
+(defn- svg-resolve-styles!
+  "Resolves all fonts in an SVG to Data URIs."
+  [svg styles]
+  (->> (rx/from (re-seq #"url\((https?://[^)]+)\)" styles))
+       (rx/map second)
+       (rx/mapcat (fn [url]
+                      (->> (fetch-as-data-uri url)
+                           (rx/map (fn [uri] [url uri])))))
+
+       (rx/reduce (fn [styles [url uri]]
+                    (str/replace styles url uri))
+                  styles)
+       (rx/tap (partial svg-add-style! svg))
+       (rx/ignore)))
+
+(defn- svg-resolve-all!
+  "Resolves all images and fonts in an SVG to Data URIs."
+  [svg styles]
+  (rx/concat
+   (svg-resolve-images! svg)
+   (svg-resolve-styles! svg styles)
+   (rx/of svg)))
+
+(defn- svg-parse
+  "Parses an SVG string into an SVG DOM."
+  [data]
+  (let [parser (js/DOMParser.)]
+    (.parseFromString ^js parser data "image/svg+xml")))
+
+(defn- svg-stringify
+  "Converts an SVG to a string."
+  [svg]
+  (let [doc        (get-document-element svg)
+        serializer (js/XMLSerializer.)]
+    (.serializeToString ^js serializer doc)))
+
+(defn- svg-prepare
+  "Prepares an SVG for rendering (resolves images to Data URIs and adds intrinsic size)."
+  [data styles]
+  (let [svg (svg-parse data)]
+    (->> (svg-resolve-all! svg styles)
+         (rx/map #(svg-set-intrinsic-size! % 300))
+         (rx/map svg-stringify))))
+
+(defn- bitmap->blob
+  "Converts an ImageBitmap to a Blob."
+  [bitmap]
+  (rx/create
+   (fn [subs]
+     (let [canvas (dom/create-element "canvas")]
+       (set! (.-width ^js canvas)  (.-width ^js bitmap))
+       (set! (.-height ^js canvas) (.-height ^js bitmap))
+       (let [context (.getContext ^js canvas "bitmaprenderer")]
+         (.transferFromImageBitmap ^js context bitmap)
+         (.toBlob canvas #(do (rx/push! subs %)
+                              (rx/end! subs))))
+
+       (constantly nil)))))
+
+(defn- render
+  "Renders a thumbnail using it's SVG and returns an ArrayBuffer of the image."
+  [payload]
+  (let [data   (unchecked-get payload "data")
+        styles (unchecked-get payload "styles")]
+    (->> (svg-prepare data styles)
+         (rx/map #(wapi/create-blob % "image/svg+xml"))
+         (rx/map wapi/create-uri)
+         (rx/mapcat (fn [uri]
+                      (->> (create-image uri)
+                           (rx/mapcat wapi/create-image-bitmap)
+                           (rx/tap #(wapi/revoke-uri uri)))))
+         (rx/mapcat bitmap->blob))))
+
+(defn- on-message
+  "Handles messages from the main thread."
+  [event]
+  (let [evdata (unchecked-get event "data")
+        evorigin (unchecked-get event "origin")]
+    (when (str/starts-with? parent-origin evorigin)
+      (let [id      (unchecked-get evdata "id")
+            payload (unchecked-get evdata "payload")
+            scope   (unchecked-get evdata "scope")]
+        (when (and (some? payload)
+                   (= scope "penpot/thumbnail-renderer"))
+          (->> (render payload)
+               (rx/subs (partial send-success! id)
+                        (partial send-failure! id))))))))
+
+(defn- listen
+  "Initializes the listener for messages from the main thread."
+  []
+  (.addEventListener js/window "message" on-message))
+
+(defn- send-answer!
+  "Sends an answer message."
+  [id type payload]
+  (let [message #js {:id id
+                     :type type
+                     :scope "penpot/thumbnail-renderer"
+                     :payload payload}]
+    (when-not (identical? js/window js/parent)
+      (.postMessage js/parent message parent-origin))))
+
+(defn- send-success!
+  "Sends a success message."
+  [id payload]
+  (send-answer! id "success" payload))
+
+(defn- send-failure!
+  "Sends a failure message."
+  [id payload]
+  (send-answer! id "failure" payload))
+
+(defn- send-ready!
+  "Sends a ready message."
+  []
+  (send-answer! nil "ready" nil))
+
+;; Initializes worker
+(defn ^:export init
+  []
+  (listen)
+  (send-ready!)
+  (log/info :hint "initialized" :public-uri @cf/public-uri))
diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs
index e8c14dcc1..b78227180 100644
--- a/frontend/src/app/util/dom.cljs
+++ b/frontend/src/app/util/dom.cljs
@@ -254,7 +254,15 @@
   ([tag]
    (.createElement globals/document tag))
   ([ns tag]
-   (.createElementNS globals/document ns tag)))
+   (.createElementNS globals/document ns tag))
+  ([document ns tag]
+   (.createElementNS document ns tag)))
+
+(defn create-text
+  ([^js text]
+   (create-text globals/document text))
+  ([document ^js text]
+   (.createTextNode document text)))
 
 (defn set-html!
   [^js el html]
diff --git a/frontend/src/app/util/webapi.cljs b/frontend/src/app/util/webapi.cljs
index da5f8d30c..96ca465bc 100644
--- a/frontend/src/app/util/webapi.cljs
+++ b/frontend/src/app/util/webapi.cljs
@@ -130,6 +130,10 @@
            (map #(.item file-list %))
            (filter #(str/starts-with? (.-type %) "image/"))))))
 
+(defn create-image-bitmap
+  [image]
+  (js/createImageBitmap image))
+
 (defn request-fullscreen
   [el]
   (cond
diff --git a/frontend/src/app/worker/thumbnails.cljs b/frontend/src/app/worker/thumbnails.cljs
index f17f4d85a..f489a0bcf 100644
--- a/frontend/src/app/worker/thumbnails.cljs
+++ b/frontend/src/app/worker/thumbnails.cljs
@@ -18,7 +18,6 @@
    [app.util.webapi :as wapi]
    [app.worker.impl :as impl]
    [beicon.core :as rx]
-   [debug :refer [debug?]]
    [promesa.core :as p]
    [rumext.v2 :as mf]))
 
@@ -45,15 +44,6 @@
                :http-status status
                :http-body body})))
 
-(defn- not-found?
-  [{:keys [type]}]
-  (= :not-found type))
-
-(defn- body-too-large?
-  [{:keys [type code]}]
-  (and (= :validation type)
-       (= :request-body-too-large code)))
-
 (defn- request-data-for-thumbnail
   [file-id revn features]
   (let [path    "api/rpc/command/get-file-data-for-thumbnail"
@@ -69,70 +59,25 @@
          (rx/map http/conditional-decode-transit)
          (rx/mapcat handle-response))))
 
-(defn- request-thumbnail
-  [file-id revn]
-  (let [path    "api/rpc/command/get-file-thumbnail"
-        params  {:file-id file-id
-                 :revn revn}
-        request {:method :get
-                 :uri (u/join @cf/public-uri path)
-                 :credentials "include"
-                 :query params}]
-    (->> (http/send! request)
-         (rx/map http/conditional-decode-transit)
-         (rx/mapcat handle-response))))
-
 (defn- render-thumbnail
   [{:keys [page file-id revn] :as params}]
-  (let [objects (:objects page)
-        frame   (some->> page :thumbnail-frame-id (get objects))
-        element (if frame
-                  (mf/element render/frame-svg #js {:objects objects :frame frame :show-thumbnails? true})
-                  (mf/element render/page-svg #js {:data page :thumbnails? true}))
-        data    (rds/renderToStaticMarkup element)]
+  (let [objects  (:objects page)
+        frame    (some->> page :thumbnail-frame-id (get objects))
+        element  (if frame
+                   (mf/element render/frame-svg #js {:objects objects :frame frame :show-thumbnails? true})
+                   (mf/element render/page-svg #js {:data page :thumbnails? true :render-embed? true}))
+        data     (rds/renderToStaticMarkup element)
+        font-ids (into @fonts/loaded (map first) @fonts/loading)]
+
     {:data data
-     :fonts (into @fonts/loaded (map first) @fonts/loading)
+     :fonts font-ids
      :file-id file-id
      :revn revn}))
 
-(defn- persist-thumbnail
-  [{:keys [file-id data revn fonts]}]
-  (let [path    "api/rpc/command/upsert-file-thumbnail"
-        params  {:file-id file-id
-                 :revn revn
-                 :props {:fonts fonts}
-                 :data data}
-        request {:method :post
-                 :uri (u/join @cf/public-uri path)
-                 :credentials "include"
-                 :body (http/transit-data params)}]
-
-    (->> (http/send! request)
-         (rx/map http/conditional-decode-transit)
-         (rx/mapcat handle-response)
-         (rx/catch body-too-large? (constantly (rx/of nil)))
-         (rx/map (constantly params)))))
-
 (defmethod impl/handler :thumbnails/generate-for-file
   [{:keys [file-id revn features] :as message} _]
-  (letfn [(on-result [{:keys [data props]}]
-            {:data data
-             :fonts (:fonts props)})
-
-          (on-cache-miss [_]
-            (log/debug :hint "request-thumbnail" :file-id file-id :revn revn :cache "miss")
-            (->> (request-data-for-thumbnail file-id revn features)
-                 (rx/map render-thumbnail)
-                 (rx/mapcat persist-thumbnail)))]
-
-    (if (debug? :disable-thumbnail-cache)
-      (->> (request-data-for-thumbnail file-id revn features)
-           (rx/map render-thumbnail))
-      (->> (request-thumbnail file-id revn)
-           (rx/tap (fn [_]
-                     (log/debug :hint "request-thumbnail" :file-id file-id :revn revn :cache "hit")))
-           (rx/catch not-found? on-cache-miss)
-           (rx/map on-result)))))
+  (->> (request-data-for-thumbnail file-id revn features)
+       (rx/map render-thumbnail)))
 
 (defmethod impl/handler :thumbnails/render-offscreen-canvas
   [_ ibpm]