diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5328,6 +5328,7 @@ dependencies = [
  "photocraft-ui-egui",
  "rfd",
  "serde_json",
+ "sha2",
  "wasm-bindgen",
  "wasm-bindgen-futures",
  "web-sys",
diff --git a/apps/photocraft-web/Cargo.toml b/apps/photocraft-web/Cargo.toml
--- a/apps/photocraft-web/Cargo.toml
+++ b/apps/photocraft-web/Cargo.toml
@@ -24,6 +24,7 @@ photocraft-io = { workspace = true }
 photocraft-codecs = { workspace = true }
 photocraft-doc = { workspace = true }
 photocraft-ui-egui = { workspace = true }
+sha2 = "0.10"
 egui = { workspace = true }
 log = { workspace = true }
 
diff --git a/apps/photocraft-web/src/browser_recent.rs b/apps/photocraft-web/src/browser_recent.rs
new file mode 100644
--- /dev/null
+++ b/apps/photocraft-web/src/browser_recent.rs
@@ -0,0 +1,115 @@
+//! Browser copies back the existing recent-file menu. A committed Blob, never a host path,
+//! is the authority for reopening. The serial queue preserves the order of accepted imports.
+use photocraft_ui_egui::{PhotocraftApp, RecentFileRequest};
+use sha2::{Digest, Sha256};
+use std::{cell::RefCell, collections::VecDeque, rc::Rc, sync::Arc};
+
+const PREFIX: &str = "browser-recent/";
+
+#[derive(Default)]
+struct Pending {
+    queue: VecDeque<(u64, RecentFileRequest)>,
+    generation: u64,
+}
+
+enum Reply {
+    Retained(String),
+    Opened(String, Vec<u8>),
+}
+
+#[derive(Default)]
+pub struct Recent {
+    pending: Rc<RefCell<Pending>>,
+    result: Rc<RefCell<Option<(u64, Result<Reply, String>)>>>,
+    busy: bool,
+}
+
+impl Recent {
+    pub fn service(&self) -> photocraft_ui_egui::RecentFilesFn {
+        let pending = self.pending.clone();
+        Box::new(move |request| {
+            let mut pending = pending.borrow_mut();
+            // Clearing/removing history must win against an already-running retention.
+            if matches!(request, RecentFileRequest::Invalidate) {
+                pending.generation = pending.generation.wrapping_add(1);
+                pending.queue.clear();
+            } else {
+                let generation = pending.generation;
+                pending.queue.push_back((generation, request));
+            }
+            Ok(())
+        })
+    }
+
+    pub fn tick(&mut self, app: &mut PhotocraftApp, ctx: &egui::Context) {
+        let result = self.result.borrow_mut().take();
+        if let Some((generation, result)) = result {
+            self.busy = false;
+            // Preferences are persisted by app.logic; schedule its next pass even when the
+            // user stops interacting immediately after the asynchronous copy commits.
+            ctx.request_repaint();
+            match result {
+                Ok(Reply::Retained(key)) if generation == self.pending.borrow().generation => app.push_recent(&key),
+                Ok(Reply::Retained(_)) => {}
+                Ok(Reply::Opened(name, bytes)) if generation == self.pending.borrow().generation => {
+                    if let Err(error) = app.open_bytes(&name, &bytes) {
+                        report(app, error);
+                    }
+                }
+                Ok(Reply::Opened(_, _)) => {}
+                Err(error) if generation == self.pending.borrow().generation => report(app, error),
+                Err(_) => {}
+            }
+        }
+        if self.busy {
+            return;
+        }
+        let next = self.pending.borrow_mut().queue.pop_front();
+        let Some((generation, task)) = next else { return };
+        self.busy = true;
+        let result = self.result.clone();
+        let wake = ctx.clone();
+        wasm_bindgen_futures::spawn_local(async move {
+            *result.borrow_mut() = Some((generation, perform(task).await));
+            wake.request_repaint();
+        });
+    }
+}
+
+fn report(app: &mut PhotocraftApp, error: String) {
+    let message = format!("Recent browser copy unavailable: {error}");
+    app.ui.status = message.clone();
+    app.ui.status_error = true;
+    photocraft_ui_egui::notices::post(app, message, Vec::new(), true, None);
+}
+
+async fn perform(task: RecentFileRequest) -> Result<Reply, String> {
+    let db = crate::indexed_presets::open("photocraft.recent", js_sys::Date::now() + 10_000.0).await?;
+    let result = match task {
+        RecentFileRequest::Remember(name, bytes) => {
+            // Names and bytes both participate: identical filenames cannot overwrite each other,
+            // and reopening the same retained version does not manufacture a duplicate.
+            let mut hash = Sha256::new();
+            hash.update((name.len() as u64).to_le_bytes());
+            hash.update(name.as_bytes());
+            hash.update(bytes.as_slice());
+            let key = format!("{PREFIX}{:x}/{name}", hash.finalize());
+            let batch = [(key.clone(), Some(Arc::<[u8]>::from(bytes.as_slice())))].into_iter().collect();
+            crate::indexed_presets::write(&db, &batch).await.map(|()| Reply::Retained(key))
+        }
+        RecentFileRequest::Open(key) => {
+            let name = key
+                .strip_prefix(PREFIX)
+                .and_then(|tail| tail.split_once('/'))
+                .filter(|(hash, name)| hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()) && !name.is_empty())
+                .map(|(_, name)| name.to_string());
+            match name {
+                Some(name) => crate::indexed_presets::read_file(&db, &key).await.map(|bytes| Reply::Opened(name, bytes)),
+                None => Err("This entry has no retained browser copy; use File → Open.".into()),
+            }
+        }
+        RecentFileRequest::Invalidate => Err("invalid recent request".into()),
+    };
+    db.close();
+    result
+}
diff --git a/apps/photocraft-web/src/indexed_presets.rs b/apps/photocraft-web/src/indexed_presets.rs
--- a/apps/photocraft-web/src/indexed_presets.rs
+++ b/apps/photocraft-web/src/indexed_presets.rs
@@ -75,7 +75,7 @@ fn request(request: &IdbRequest) -> Pending {
 }
 
 /// A single deadline bounds the whole startup read, not ten seconds per record.
-async fn before(promise: &Promise, deadline: f64) -> Result<JsValue, String> {
+pub(crate) async fn before(promise: &Promise, deadline: f64) -> Result<JsValue, String> {
     let window = web_sys::window().ok_or("no window")?;
     let mut timer = None;
     let mut callback = None;
@@ -96,9 +96,10 @@ async fn before(promise: &Promise, deadline: f64) -> Result<JsValue, String> {
     result
 }
 
-async fn open(deadline: f64) -> Result<IdbDatabase, String> {
+/// Open an isolated browser collection using the same bounded transaction machinery.
+pub(crate) async fn open(name: &str, deadline: f64) -> Result<IdbDatabase, String> {
     let factory = web_sys::window().ok_or("no window")?.indexed_db().map_err(js)?.ok_or("IndexedDB unavailable")?;
-    let req = factory.open_with_u32(DATABASE, 1).map_err(js)?;
+    let req = factory.open_with_u32(name, 1).map_err(js)?;
     let mut upgrade = Listeners::new();
     let upgrade_req = req.clone();
     upgrade
@@ -133,7 +134,7 @@ async fn open(deadline: f64) -> Result<IdbDatabase, String> {
 
 pub async fn load() -> Result<(IdbDatabase, Bridge, Vec<String>), String> {
     let deadline = js_sys::Date::now() + TIMEOUT_MS;
-    let db = open(deadline).await?;
+    let db = open(DATABASE, deadline).await?;
     let result = read(&db, deadline).await;
     match result {
         Ok((bridge, warnings)) => Ok((db, bridge, warnings)),
@@ -209,6 +210,19 @@ async fn read(db: &IdbDatabase, deadline: f64) -> Result<(Bridge, Vec<String>),
     Ok((bridge, warnings))
 }
 
+/// Read one retained document lazily. Document sizes use the browser's own quota,
+/// rather than the preset library's separate record-size limits.
+pub(crate) async fn read_file(db: &IdbDatabase, key: &str) -> Result<Vec<u8>, String> {
+    let deadline = js_sys::Date::now() + TIMEOUT_MS;
+    let tx = db.transaction_with_str_and_mode(FILES, IdbTransactionMode::Readonly).map_err(js)?;
+    let req = tx.object_store(FILES).map_err(js)?.get(&JsValue::from_str(key)).map_err(js)?;
+    let pending = request(&req);
+    let value = before(&pending.promise, deadline).await?;
+    let blob = value.dyn_into::<Blob>().map_err(|_| "The retained copy is no longer available; use File → Open.".to_string())?;
+    let buffer = before(&blob.array_buffer(), deadline).await?;
+    Ok(Uint8Array::new(&buffer).to_vec())
+}
+
 pub async fn write(db: &IdbDatabase, batch: &Batch) -> Result<(), String> {
     let tx = db.transaction_with_str_and_mode(FILES, IdbTransactionMode::Readwrite).map_err(js)?;
     let store = tx.object_store(FILES).map_err(js)?;
diff --git a/apps/photocraft-web/src/main.rs b/apps/photocraft-web/src/main.rs
--- a/apps/photocraft-web/src/main.rs
+++ b/apps/photocraft-web/src/main.rs
@@ -22,6 +22,9 @@ mod preset_bridge;
 #[cfg(target_arch = "wasm32")]
 mod indexed_presets;
 
+#[cfg(target_arch = "wasm32")]
+mod browser_recent;
+
 #[cfg(target_arch = "wasm32")]
 mod web;
 
diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs
--- a/apps/photocraft-web/src/web.rs
+++ b/apps/photocraft-web/src/web.rs
@@ -89,7 +89,10 @@ pub fn start() {
                     let (session, warnings) = crate::preset_bridge::session(presets.clone());
                     preset_warnings.extend(warnings);
                     let drag = listen_drag(&pen_target);
-                    let mut app = PhotocraftApp::new(session, services(inbox.clone(), place_inbox.clone(), drag_pos_service(pen_target.clone(), drag)));
+                    let recent = crate::browser_recent::Recent::default();
+                    let mut platform = services(inbox.clone(), place_inbox.clone(), drag_pos_service(pen_target.clone(), drag));
+                    platform.recent_files = Some(recent.service());
+                    let mut app = PhotocraftApp::new(session, platform);
                     if !preset_warnings.is_empty() {
                         photocraft_ui_egui::notices::post(&mut app, i18n::t("Some brush presets could not be loaded"), preset_warnings, true, None);
                     }
@@ -103,7 +106,7 @@ pub fn start() {
                     }
                     let unsaved = Arc::new(AtomicBool::new(false));
                     guard_unload(unsaved.clone());
-                    Ok(Box::new(WebShell { app, inbox, place_inbox, unsaved, served, database, presets }))
+                    Ok(Box::new(WebShell { app, inbox, place_inbox, unsaved, served, database, presets, recent }))
                 }),
             )
             .await;
@@ -270,6 +273,7 @@ struct WebShell {
     served: Served,
     database: Option<web_sys::IdbDatabase>,
     presets: crate::preset_bridge::Bridge,
+    recent: crate::browser_recent::Recent,
 }
 
 impl WebShell {
@@ -362,6 +366,7 @@ impl eframe::App for WebShell {
         }
         self.serve_fonts(ctx);
         self.app.logic_frame(ctx, frame);
+        self.recent.tick(&mut self.app, ctx);
         self.save_presets(ctx);
         self.app.finish_type_reset_logic(ctx);
     }
diff --git a/crates/ui-egui/src/file_open.rs b/crates/ui-egui/src/file_open.rs
--- a/crates/ui-egui/src/file_open.rs
+++ b/crates/ui-egui/src/file_open.rs
@@ -147,6 +147,12 @@ impl PhotocraftApp {
 
     #[cfg(target_arch = "wasm32")]
     pub fn open_path(&mut self, path: &str) -> Result<Vec<String>, String> {
+        if path.starts_with("browser-recent/")
+            && let Some(recent) = self.services.recent_files.as_mut()
+        {
+            recent(crate::RecentFileRequest::Open(path.to_string()))?;
+            return Ok(Vec::new());
+        }
         Err(format!("cannot open paths on the web: {path}"))
     }
 
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -339,6 +339,15 @@ impl FormatChoices {
 
 /// Decode a file: (document, warnings about anything approximated or dropped).
 pub type ImportFn = Box<dyn Fn(&str, &[u8], &photocraft_io::ImportOptions) -> Result<(Document, Vec<String>), String>>;
+
+/// Platform history requests carry browser copies, never native write-back paths.
+pub enum RecentFileRequest {
+    Remember(String, std::sync::Arc<Vec<u8>>),
+    Open(String),
+    /// Pending retention cannot undo the user's removal or clearing of the list.
+    Invalidate,
+}
+pub type RecentFilesFn = Box<dyn FnMut(RecentFileRequest) -> Result<(), String>>;
 /// Encoder settings chosen in Export As (the file format comes from the name's extension).
 #[derive(Clone, Debug, PartialEq)]
 pub struct ExportSettings {
@@ -567,6 +576,8 @@ pub type RightButtonDownFn = Box<dyn Fn() -> bool + Send + Sync>;
 /// I/O dependencies.
 #[derive(Default)]
 pub struct Services {
+    /// Asynchronous recent-file persistence supplied by browser shells.
+    pub recent_files: Option<RecentFilesFn>,
     /// User-initiated desktop/browser pixel sampling.
     pub screen_pick: Option<screen_picker::Service>,
     /// Decode a file's bytes into a document (PSD, PNG, JPEG, …).
@@ -1631,6 +1642,9 @@ impl PhotocraftApp {
 
     /// File › Open Recent › Clear Recent File List.
     pub fn clear_recent(&mut self) {
+        if let Some(recent) = self.services.recent_files.as_mut() {
+            let _ = recent(RecentFileRequest::Invalidate);
+        }
         self.ui.recent_files.clear();
         self.session.prefs.edit(|p| p.file_handling.recent_files.clear());
     }
@@ -1638,6 +1652,9 @@ impl PhotocraftApp {
     /// Take one file off the recent list (the Home screen's × or Remove from Recent, #2691). The
     /// file stays on disk, and opening it again lists it again.
     pub fn remove_recent(&mut self, path: &str) {
+        if let Some(recent) = self.services.recent_files.as_mut() {
+            let _ = recent(RecentFileRequest::Invalidate);
+        }
         self.ui.recent_files.retain(|p| p != path);
         self.session.prefs.edit(|p| p.file_handling.recent_files.retain(|r| r != path));
     }
@@ -1700,6 +1717,8 @@ impl PhotocraftApp {
         let developed = warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE));
         let question = self.session.color.open_question(&doc).filter(|_| !developed);
         let wants_raw = camera_raw_ui::wants_open_dialog(self, &warnings);
+        // Hold the source only when the platform needs a retained browser copy.
+        let keep_bytes = (wants_raw || self.services.recent_files.is_some()) && path.is_none();
         open_prompt::arrive(
             self,
             open_prompt::Held {
@@ -1709,7 +1728,9 @@ impl PhotocraftApp {
                 name: name.to_string(),
                 path: path.map(str::to_string),
                 warnings: warnings.clone(),
-                bytes: (wants_raw && path.is_none()).then(|| std::sync::Arc::new(bytes.to_vec())),
+                // Keep bytes until the open prompt actually accepts the document. Preset files,
+                // failed imports, and cancelled profile prompts never enter browser history.
+                bytes: keep_bytes.then(|| std::sync::Arc::new(bytes.to_vec())),
                 slot: None,
                 question,
                 policy_applied: false,
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -278,18 +278,10 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
             Ok(Value::Null)
         }
         id if id.starts_with("file.openRecent.") => {
-            #[cfg(not(target_arch = "wasm32"))]
-            {
-                match id.rsplit('.').next().and_then(|s| s.parse::<usize>().ok()).and_then(|i| app.ui.recent_files.get(i).cloned()) {
-                    Some(p) => open_path(app, &p),
-                    None => Err("no such recent file".to_string()),
-                }
-            }
-            #[cfg(target_arch = "wasm32")]
-            {
-                let _ = app;
-                let message = "Open Recent is unavailable on the web";
-                Err(tl!(message).to_string())
+            // The platform loader resolves either a native path or a retained browser copy.
+            match id.rsplit('.').next().and_then(|s| s.parse::<usize>().ok()).and_then(|i| app.ui.recent_files.get(i).cloned()) {
+                Some(p) => open_path(app, &p),
+                None => Err("no such recent file".to_string()),
             }
         }
         "file.saveAs" => {
@@ -1056,7 +1048,13 @@ pub fn menu_items(app: &PhotocraftApp) -> Vec<MenuItem> {
             .iter()
             .enumerate()
             .map(|(i, p)| {
-                let label = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone());
+                let mut label = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone());
+                // Same-name browser imports remain distinguishable while retaining their names.
+                if let Some((hash, _)) = p.strip_prefix("browser-recent/").and_then(|p| p.split_once('/'))
+                    && let Some(short) = hash.get(..8)
+                {
+                    label.push_str(&format!(" [{short}]"));
+                }
                 MenuItem { id: format!("file.openRecent.{i}"), label, path: rp.clone(), shortcut: None, enabled: true, checked: None, color: None }
             })
             .collect();
diff --git a/crates/ui-egui/src/open_prompt.rs b/crates/ui-egui/src/open_prompt.rs
--- a/crates/ui-egui/src/open_prompt.rs
+++ b/crates/ui-egui/src/open_prompt.rs
@@ -265,5 +265,12 @@ fn open(app: &mut PhotocraftApp, held: Held, action: Option<&str>) -> Result<Val
     };
     app.session.set_active(index);
     crate::jobs_ui::after_open(app, document, &name, path.as_deref(), &warnings, bytes.as_deref().map(Vec::as_slice))?;
+    if path.is_none()
+        && let Some(bytes) = bytes
+        && let Some(recent) = app.services.recent_files.as_mut()
+    {
+        // This is the accepted-open boundary shared by picker, drop and browser control.
+        let _ = recent(crate::RecentFileRequest::Remember(name, bytes));
+    }
     Ok(json!({"document": index, "action": action}))
 }
