diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5320,8 +5320,11 @@ dependencies = [
  "js-sys",
  "log",
  "photocraft-codecs",
+ "photocraft-color",
  "photocraft-doc",
  "photocraft-engine",
+ "photocraft-format",
+ "photocraft-geom",
  "photocraft-io",
  "photocraft-paint",
  "photocraft-psd",
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
@@ -23,6 +23,8 @@ photocraft-engine = { workspace = true }
 photocraft-io = { workspace = true }
 photocraft-codecs = { workspace = true }
 photocraft-doc = { workspace = true }
+photocraft-format = { workspace = true }
+serde_json = { workspace = true }
 photocraft-ui-egui = { workspace = true }
 egui = { workspace = true }
 log = { workspace = true }
@@ -34,9 +36,10 @@ rfd = { workspace = true }
 wasm-bindgen = "0.2"
 wasm-bindgen-futures = "0.4"
 js-sys = "0.3"
-web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "DomRect", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "RequestMode", "Navigator"] }
+web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "DomRect", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "RequestMode", "Navigator", "VisibilityState"] }
 
 [dev-dependencies]
-serde_json = { workspace = true }
+photocraft-color = { workspace = true }
+photocraft-geom = { workspace = true }
 photocraft-paint = { workspace = true }
 photocraft-psd = { workspace = true }
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
@@ -9,10 +9,10 @@ use crate::preset_bridge::{Batch, Bridge, MAX_RECORDS, record_limit};
 use photocraft_engine::preset_store::MAX_STORE_BYTES;
 
 const DATABASE: &str = "photocraft.presets";
-const FILES: &str = "files";
-const TIMEOUT_MS: f64 = 10_000.0;
+pub const FILES: &str = "files";
+pub const TIMEOUT_MS: f64 = 10_000.0;
 
-fn js(e: JsValue) -> String {
+pub fn js(e: JsValue) -> String {
     if let Some(exception) = e.dyn_ref::<web_sys::DomException>() {
         format!("{}: {}", exception.name(), exception.message())
     } else {
@@ -42,12 +42,12 @@ impl Drop for Listeners {
     }
 }
 
-struct Pending {
-    promise: Promise,
+pub struct Pending {
+    pub promise: Promise,
     _listeners: Listeners,
 }
 
-fn request(request: &IdbRequest) -> Pending {
+pub fn request(request: &IdbRequest) -> Pending {
     let mut listeners = Listeners::new();
     let promise = Promise::new(&mut |resolve, reject| {
         let req = request.clone();
@@ -75,13 +75,13 @@ 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 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;
     let timeout = Promise::new(&mut |_, reject| {
         let cb = Closure::<dyn FnMut()>::new(move || {
-            let _ = reject.call1(&JsValue::NULL, &JsValue::from_str("browser preset storage timed out"));
+            let _ = reject.call1(&JsValue::NULL, &JsValue::from_str("browser storage timed out"));
         });
         timer = Some(window.set_timeout_with_callback_and_timeout_and_arguments_0(
             cb.as_ref().unchecked_ref(),
@@ -96,9 +96,9 @@ async fn before(promise: &Promise, deadline: f64) -> Result<JsValue, String> {
     result
 }
 
-async fn open(deadline: f64) -> Result<IdbDatabase, String> {
+pub async fn open(database: &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(database, 1).map_err(js)?;
     let mut upgrade = Listeners::new();
     let upgrade_req = req.clone();
     upgrade
@@ -133,7 +133,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)),
diff --git a/apps/photocraft-web/src/indexed_recovery.rs b/apps/photocraft-web/src/indexed_recovery.rs
new file mode 100644
--- /dev/null
+++ b/apps/photocraft-web/src/indexed_recovery.rs
@@ -0,0 +1,171 @@
+//! Crash recovery in the browser (#6615): IndexedDB side of [`crate::recovery_bridge`], and the
+//! `Services` the UI's existing autosave and recovery code calls.
+//!
+//! Several tabs share the database, so every page load writes under its own session id and
+//! holds a Web Lock named after it for as long as the page lives. At launch a page lists the
+//! database's keys, and for each session that is not its own tries to take that session's lock
+//! "if available": only a page whose writer is gone gets it, and only one page wins. Entries of
+//! sessions it could not lock belong to a page that may be open; they are never listed, adopted
+//! or deleted (see [`Recovery::claim`]). Without the Web Locks API (insecure contexts), no
+//! earlier entries are offered, which is the safe side.
+use js_sys::{Function, Object, Promise, Reflect, Uint8Array};
+use wasm_bindgen::{JsCast, JsValue, closure::Closure};
+use web_sys::{Blob, IdbDatabase, IdbTransactionMode};
+
+use crate::indexed_presets::{FILES, TIMEOUT_MS, before, js, open, request, write};
+use crate::recovery_bridge::{MAX_ENTRIES, MAX_SNAPSHOT_BYTES, Recovery, recover_on_launch, session_of};
+use photocraft_ui_egui::Services;
+
+const DATABASE: &str = "photocraft.recovery";
+const PREFS_KEY: &str = "photocraft.preferences";
+
+/// Open the recovery database, take this page's lock and read what earlier page loads left.
+/// Storage the browser denies (private windows, blocked site data) returns the error; the app
+/// then runs without recovery and says so when an autosave is attempted. A slow or unreadable
+/// record is skipped, not fatal.
+pub async fn load() -> Result<(IdbDatabase, Recovery), String> {
+    let deadline = js_sys::Date::now() + TIMEOUT_MS;
+    let db = open(DATABASE, deadline).await?;
+    let session = format!("{:x}{:x}", js_sys::Date::now() as u64, (js_sys::Math::random() * 4_294_967_296.0) as u64);
+    let recovery = Recovery::new(session);
+    match read(&db, &recovery, deadline).await {
+        Ok(()) => Ok((db, recovery)),
+        Err(e) => {
+            db.close();
+            Err(e)
+        }
+    }
+}
+
+fn lock_name(session: &str) -> String {
+    format!("photocraft.recovery.{session}")
+}
+
+/// Take Web Lock `name` and keep it until the page goes away (the callback's promise never
+/// settles). With `if_available`, `Ok(false)` means another page holds it.
+async fn hold(name: &str, if_available: bool) -> Result<bool, String> {
+    let navigator = web_sys::window().ok_or("no window")?.navigator();
+    let locks = Reflect::get(&navigator, &JsValue::from_str("locks")).map_err(js)?;
+    if locks.is_undefined() || locks.is_null() {
+        return Err("Web Locks are unavailable".into());
+    }
+    let request = Reflect::get(&locks, &JsValue::from_str("request")).map_err(js)?.dyn_into::<Function>().map_err(|_| "Web Locks are unavailable")?;
+    let options = Object::new();
+    Reflect::set(&options, &JsValue::from_str("ifAvailable"), &JsValue::from_bool(if_available)).map_err(js)?;
+    let mut failed = None;
+    let granted = Promise::new(&mut |resolve, reject| {
+        let callback = Closure::once_into_js(move |lock: JsValue| -> Promise {
+            let got = !lock.is_null();
+            let _ = resolve.call1(&JsValue::NULL, &JsValue::from_bool(got));
+            if got { Promise::new(&mut |_, _| {}) } else { Promise::resolve(&JsValue::UNDEFINED) }
+        });
+        if let Err(e) = request.call3(&locks, &JsValue::from_str(name), &options, &callback) {
+            failed = Some(e);
+            let _ = reject.call1(&JsValue::NULL, &JsValue::from_str("lock request failed"));
+        }
+    });
+    if let Some(e) = failed {
+        return Err(js(e));
+    }
+    Ok(before(&granted, js_sys::Date::now() + TIMEOUT_MS).await?.as_bool().unwrap_or(false))
+}
+
+/// One record, read in its own transaction (and under its own deadline) so one slow record
+/// cannot take the others, or the database, down with it.
+async fn get_blob(db: &IdbDatabase, name: &str) -> Result<Option<Blob>, String> {
+    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(name)).map_err(js)?;
+    let pending = request(&req);
+    Ok(before(&pending.promise, js_sys::Date::now() + TIMEOUT_MS).await?.dyn_into::<Blob>().ok())
+}
+
+async fn read(db: &IdbDatabase, recovery: &Recovery, deadline: f64) -> Result<(), String> {
+    let tx = db.transaction_with_str_and_mode(FILES, IdbTransactionMode::Readonly).map_err(js)?;
+    let keys = request(&tx.object_store(FILES).map_err(js)?.get_all_keys().map_err(js)?);
+    let names: Vec<String> =
+        js_sys::Array::from(&before(&keys.promise, deadline).await?).iter().filter_map(|k| k.as_string().filter(|n| n.len() <= 110)).collect();
+    // This page's lock comes before any write, so no other page can ever see its entries as
+    // abandoned. Without locks nothing is claimed.
+    let own = recovery.session();
+    let locked = match hold(&lock_name(&own), false).await {
+        Ok(got) => got,
+        Err(e) => {
+            log::warn!("Crash recovery cannot tell live tabs from closed ones, so earlier entries are not offered: {e}");
+            false
+        }
+    };
+    let stored = web_sys::window().and_then(|w| w.local_storage().ok().flatten()).and_then(|s| s.get_item(PREFS_KEY).ok().flatten());
+    let mut claimed: Vec<&str> = Vec::new();
+    if locked && recover_on_launch(stored.as_deref()) {
+        for session in names.iter().filter_map(|n| session_of(n)).filter(|s| *s != own) {
+            if claimed.len() < MAX_ENTRIES && !claimed.contains(&session) && hold(&lock_name(session), true).await.unwrap_or(false) {
+                recovery.claim(session);
+                claimed.push(session);
+            }
+        }
+    }
+    for name in names.iter().take(MAX_ENTRIES * 2) {
+        let mine = session_of(name).is_some_and(|s| claimed.contains(&s));
+        let bundle = name.ends_with(".pcraft");
+        // Only a claimed page's sidecars and bundles are read; other bundles are only measured
+        // (their size still counts against the total cap, their bytes are never copied).
+        if !(bundle || (mine && name.ends_with(".json"))) {
+            continue;
+        }
+        let blob = match get_blob(db, name).await {
+            Ok(Some(blob)) => blob,
+            Ok(None) => continue,
+            Err(e) => {
+                log::warn!("Skipping recovery record {name}: {e}");
+                continue;
+            }
+        };
+        if blob.size() > MAX_SNAPSHOT_BYTES as f64 + 4096.0 {
+            log::warn!("Skipping recovery record {name}: it is larger than the autosave limit");
+        } else if !mine {
+            recovery.note_size(name, blob.size() as u64);
+        } else {
+            match before(&blob.array_buffer(), js_sys::Date::now() + TIMEOUT_MS).await {
+                Ok(buffer) => recovery.load(name, Uint8Array::new(&buffer).to_vec()),
+                Err(e) => log::warn!("Skipping recovery record {name}: {e}"),
+            }
+        }
+    }
+    Ok(())
+}
+
+/// The UI's autosave and recovery hooks over `db`. `db` is `None` when storage was unavailable.
+pub fn services(db: Option<IdbDatabase>, recovery: Recovery) -> Services {
+    let (r1, r2, r3, r4, r5) = (recovery.clone(), recovery.clone(), recovery.clone(), recovery.clone(), recovery);
+    let (db1, db2) = (db.clone(), db);
+    Services {
+        autosave: Some(Box::new(move |doc, revision, path| {
+            let db = db1.clone().ok_or("browser storage is unavailable, so recovery data cannot be kept")?;
+            let batch = r1.snapshot(doc, revision, path, (js_sys::Date::now() / 1000.0) as u64)?;
+            let (id, done) = (doc.id.0, r1.clone());
+            wasm_bindgen_futures::spawn_local(async move {
+                // A quota error lands here, and the UI shows it as "Autosave failed".
+                let result = write(&db, &batch).await;
+                done.finish(id, revision, result);
+            });
+            Ok(())
+        })),
+        autosave_results: Some(Box::new(move || r2.take_completed())),
+        flush_autosaves: Some(Box::new(move || r3.take_completed())),
+        discard_autosave: Some(Box::new(move |id| {
+            if let (Some(db), Some(batch)) = (db2.clone(), r4.discard(id)) {
+                wasm_bindgen_futures::spawn_local(async move {
+                    if let Err(e) = write(&db, &batch).await {
+                        log::warn!("Could not remove recovery data: {e}");
+                    }
+                });
+            }
+        })),
+        recover: Some(Box::new({
+            let r = r5.clone();
+            move || r.recoverable()
+        })),
+        adopt_autosave: Some(Box::new(move |id, key| r5.adopt(id, key))),
+        ..Default::default()
+    }
+}
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
@@ -19,9 +19,15 @@
 #[cfg(any(target_arch = "wasm32", test))]
 mod preset_bridge;
 
+#[cfg(any(target_arch = "wasm32", test))]
+mod recovery_bridge;
+
 #[cfg(target_arch = "wasm32")]
 mod indexed_presets;
 
+#[cfg(target_arch = "wasm32")]
+mod indexed_recovery;
+
 #[cfg(target_arch = "wasm32")]
 mod web;
 
diff --git a/apps/photocraft-web/src/recovery_bridge.rs b/apps/photocraft-web/src/recovery_bridge.rs
new file mode 100644
--- /dev/null
+++ b/apps/photocraft-web/src/recovery_bridge.rs
@@ -0,0 +1,448 @@
+//! Crash recovery for the browser build (#6615): the platform-agnostic half.
+//!
+//! The UI already schedules autosaves (timer, dirty tracking, one write in flight per document)
+//! and recovers at launch through `Services::{autosave, autosave_results, discard_autosave,
+//! recover, adopt_autosave}`. The desktop backs them with a recovery folder (`RecoveryStore`,
+//! threads and files). This is the same contract over browser storage: a snapshot is the
+//! document's `.pcraft` bytes from the incremental writer (unchanged tiles are not compressed
+//! again), stored as `<key>.pcraft` plus a `<key>.json` [`RecoveryInfo`] in one IndexedDB
+//! transaction (`indexed_presets::write`, shared with the presets). Nothing here touches the
+//! browser, so it is unit tested natively.
+use std::cell::RefCell;
+use std::collections::{BTreeMap, HashMap, HashSet};
+use std::rc::Rc;
+use std::sync::Arc;
+
+use photocraft_doc::Document;
+use photocraft_format::autosave::RecoveryInfo;
+use photocraft_format::{PcraftWriter, SaveOptions};
+use photocraft_ui_egui::Recoverable;
+
+use crate::preset_bridge::Batch;
+
+/// Largest single snapshot. Bigger documents are not autosaved (with a message) instead of
+/// exhausting the browser's per-origin quota or the tab's memory.
+pub const MAX_SNAPSHOT_BYTES: usize = 256 << 20;
+/// Most recovery data kept at once, across documents.
+pub const MAX_TOTAL_BYTES: u64 = 768 << 20;
+/// Most entries read back at launch.
+pub const MAX_ENTRIES: usize = 64;
+const SIDECAR_MAX: usize = 1 << 20;
+
+fn bundle_name(key: &str) -> String {
+    format!("{key}.pcraft")
+}
+fn info_name(key: &str) -> String {
+    format!("{key}.json")
+}
+fn valid_key(key: &str) -> bool {
+    !key.is_empty() && key.len() <= 96 && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
+}
+
+/// The page-load session that wrote an entry (`web-<session>-<document id>`). Sessions are
+/// alphanumeric, so the last `-` always separates the id.
+pub fn session_of(key: &str) -> Option<&str> {
+    key.strip_prefix("web-")?.rsplit_once('-').map(|(session, _)| session).filter(|s| !s.is_empty())
+}
+
+/// Whether the stored preferences (the UI's JSON text) leave "Recover on launch" on. Unreadable
+/// or missing preferences keep the default, which is on.
+pub fn recover_on_launch(prefs: Option<&str>) -> bool {
+    prefs
+        .and_then(|text| serde_json::from_str::<serde_json::Value>(text).ok())
+        .and_then(|v| v.pointer("/fileHandling/recoverOnLaunch").and_then(serde_json::Value::as_bool))
+        .unwrap_or(true)
+}
+
+struct State {
+    /// Unique to this page load: document ids restart every load, and a new document must not
+    /// overwrite an entry an earlier load left.
+    session: String,
+    max_snapshot: usize,
+    max_total: u64,
+    /// The entry each open document writes to (a recovered document adopts its entry's key).
+    keys: HashMap<u64, String>,
+    writers: HashMap<u64, PcraftWriter>,
+    /// Stored bytes per entry, for the total cap.
+    sizes: HashMap<String, u64>,
+    /// The size an entry had before each document's write in flight, so a failed write does not
+    /// leave its attempted size counted: document id -> (revision, key, previous size).
+    pending: HashMap<u64, (u64, String, Option<u64>)>,
+    /// Sessions of earlier page loads whose entries this page owns: it holds their lock (the
+    /// web layer takes it, so only one page wins). Entries of any other session belong to a
+    /// page that may still be open and are neither listed nor deleted.
+    claimed: HashSet<String>,
+    /// Records of claimed sessions read at launch, by name.
+    loaded: BTreeMap<String, Vec<u8>>,
+    done: Vec<(u64, u64, Result<(), String>)>,
+}
+
+/// Shared by the service closures and the write tasks (single-threaded, like the page).
+#[derive(Clone)]
+pub struct Recovery(Rc<RefCell<State>>);
+
+impl Recovery {
+    pub fn new(session: String) -> Self {
+        Self::with_limits(session, MAX_SNAPSHOT_BYTES, MAX_TOTAL_BYTES)
+    }
+
+    pub fn with_limits(session: String, max_snapshot: usize, max_total: u64) -> Self {
+        let session = session.chars().filter(|c| c.is_ascii_alphanumeric()).take(32).collect();
+        Recovery(Rc::new(RefCell::new(State {
+            session,
+            max_snapshot,
+            max_total,
+            keys: HashMap::new(),
+            writers: HashMap::new(),
+            sizes: HashMap::new(),
+            pending: HashMap::new(),
+            claimed: HashSet::new(),
+            loaded: BTreeMap::new(),
+            done: Vec::new(),
+        })))
+    }
+
+    fn with<R>(&self, f: impl FnOnce(&mut State) -> R) -> Result<R, String> {
+        let mut s = self.0.try_borrow_mut().map_err(|_| "crash recovery is busy".to_string())?;
+        Ok(f(&mut s))
+    }
+
+    pub fn session(&self) -> String {
+        self.with(|s| s.session.clone()).unwrap_or_default()
+    }
+
+    /// This page now owns `session`'s entries (it holds that session's lock).
+    pub fn claim(&self, session: &str) {
+        let _ = self.with(|s| s.claimed.insert(session.to_string()));
+    }
+
+    fn owns(s: &State, key: &str) -> bool {
+        session_of(key).is_some_and(|k| k == s.session || s.claimed.contains(k))
+    }
+
+    /// A record of a claimed session read from storage at launch.
+    pub fn load(&self, name: &str, bytes: Vec<u8>) {
+        let _ = self.with(|s| {
+            if s.loaded.len() < MAX_ENTRIES * 2 {
+                let key = name.rsplit_once('.').map_or(name, |(k, _)| k).to_string();
+                *s.sizes.entry(key).or_insert(0) += bytes.len() as u64;
+                s.loaded.insert(name.to_string(), bytes);
+            }
+        });
+    }
+
+    /// A record left in storage whose bytes were not read (another page's, or recovery is off):
+    /// it still counts against the total cap.
+    pub fn note_size(&self, name: &str, len: u64) {
+        let _ = self.with(|s| {
+            let key = name.rsplit_once('.').map_or(name, |(k, _)| k).to_string();
+            *s.sizes.entry(key).or_insert(0) += len;
+        });
+    }
+
+    /// Serialise `doc` as the batch that replaces its entry. Errors (too large, over the cap,
+    /// cannot encode) are shown by the UI as "Autosave failed"; nothing is written.
+    pub fn snapshot(&self, doc: &Arc<Document>, revision: u64, path: Option<&str>, saved_at: u64) -> Result<Batch, String> {
+        self.with(|s| {
+            let id = doc.id.0;
+            let key = s.keys.entry(id).or_insert_with(|| format!("web-{}-{id}", s.session)).clone();
+            let writer = s.writers.entry(id).or_insert_with(PcraftWriter::new);
+            let (bytes, _) = writer.save_zip(doc, &SaveOptions::default()).map_err(|e| e.to_string())?;
+            if bytes.len() > s.max_snapshot {
+                return Err(format!(
+                    "this document is too large to autosave in the browser ({} MB; the limit is {} MB)",
+                    bytes.len() >> 20,
+                    s.max_snapshot >> 20
+                ));
+            }
+            let others: u64 = s.sizes.iter().filter(|(k, _)| **k != key).map(|(_, n)| *n).sum();
+            if others.saturating_add(bytes.len() as u64) > s.max_total {
+                return Err("the browser's recovery storage limit is reached; save or close a document".into());
+            }
+            let info = RecoveryInfo { key: key.clone(), document_name: doc.name.clone(), original_path: path.map(str::to_string), saved_at, revision };
+            let info = serde_json::to_vec(&info).map_err(|e| e.to_string())?;
+            let before = s.sizes.insert(key.clone(), bytes.len() as u64);
+            s.pending.insert(id, (revision, key.clone(), before));
+            let mut batch = Batch::new();
+            batch.insert(bundle_name(&key), Some(bytes.into()));
+            batch.insert(info_name(&key), Some(info.into()));
+            Ok(batch)
+        })?
+    }
+
+    /// A write for `doc_id`'s `revision` finished (or failed): reported through
+    /// `Services::autosave_results`.
+    pub fn finish(&self, doc_id: u64, revision: u64, result: Result<(), String>) {
+        let _ = self.with(|s| {
+            if s.pending.get(&doc_id).is_some_and(|(r, ..)| *r == revision)
+                && let Some((_, key, before)) = s.pending.remove(&doc_id)
+                && result.is_err()
+            {
+                // The failed transaction left the old entry in storage.
+                match before {
+                    Some(n) => s.sizes.insert(key, n),
+                    None => s.sizes.remove(&key),
+                };
+            }
+            s.done.push((doc_id, revision, result));
+        });
+    }
+
+    pub fn take_completed(&self) -> Vec<(u64, u64, Result<(), String>)> {
+        self.with(|s| std::mem::take(&mut s.done)).unwrap_or_default()
+    }
+
+    /// A recovered document takes over its entry.
+    pub fn adopt(&self, doc_id: u64, key: &str) {
+        if valid_key(key) {
+            let _ = self.with(|s| if Self::owns(s, key) { s.keys.insert(doc_id, key.to_string()) } else { None });
+        }
+    }
+
+    /// The document was saved or closed: the batch deleting its entry (if it has one).
+    pub fn discard(&self, doc_id: u64) -> Option<Batch> {
+        self.with(|s| {
+            s.writers.remove(&doc_id);
+            s.pending.remove(&doc_id);
+            let key = s.keys.remove(&doc_id)?;
+            // Never delete what another page owns.
+            if !Self::owns(s, &key) {
+                return None;
+            }
+            s.sizes.remove(&key);
+            s.loaded.remove(&bundle_name(&key));
+            s.loaded.remove(&info_name(&key));
+            Some(Batch::from([(bundle_name(&key), None), (info_name(&key), None)]))
+        })
+        .ok()
+        .flatten()
+    }
+
+    /// The claimed entries read at launch, newest first. A `.pcraft` without a readable sidecar
+    /// is not offered (and stays in storage). The bytes move into each entry's loader, so they
+    /// are freed as soon as that document is decoded.
+    pub fn recoverable(&self) -> Vec<Recoverable> {
+        let mut found: Vec<(RecoveryInfo, Vec<u8>)> = self
+            .with(|s| {
+                let infos: Vec<RecoveryInfo> = s
+                    .loaded
+                    .iter()
+                    .filter(|(_, b)| b.len() <= SIDECAR_MAX)
+                    .filter_map(|(name, bytes)| {
+                        let key = name.strip_suffix(".json")?;
+                        let info = serde_json::from_slice::<RecoveryInfo>(bytes).ok().filter(|i| valid_key(key) && i.key == key)?;
+                        (Self::owns(s, key) && s.loaded.contains_key(&bundle_name(key))).then_some(info)
+                    })
+                    .collect();
+                infos
+                    .into_iter()
+                    .filter_map(|info| {
+                        let bytes = s.loaded.remove(&bundle_name(&info.key))?;
+                        Some((info, bytes))
+                    })
+                    .collect()
+            })
+            .unwrap_or_default();
+        found.sort_by(|a, b| b.0.saved_at.cmp(&a.0.saved_at).then(a.0.key.cmp(&b.0.key)));
+        found
+            .into_iter()
+            .map(|(info, bytes)| Recoverable {
+                key: info.key,
+                name: info.document_name,
+                path: info.original_path,
+                load: Box::new(move || photocraft_format::load_from_bytes(&bytes).map_err(|e| e.to_string())),
+            })
+            .collect()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use photocraft_color::{Color, ColorMode, SampleType};
+    use photocraft_geom::Size;
+
+    fn doc(name: &str) -> Arc<Document> {
+        Arc::new(Document::with_background(name, Size::new(64, 48), ColorMode::Rgb, SampleType::U8, Color::WHITE))
+    }
+
+    /// Stores a batch like the IndexedDB write does, and hands it to a fresh page load that
+    /// owns the writing session (as when its lock was free).
+    fn reload(batch: &Batch) -> Recovery {
+        let next = Recovery::new("next".into());
+        for (name, bytes) in batch {
+            if let Some(b) = bytes {
+                next.claim(session_of(name.rsplit_once('.').map_or(name, |(k, _)| k)).unwrap_or_default());
+                next.load(name, b.to_vec());
+            }
+        }
+        next
+    }
+
+    #[test]
+    fn snapshot_round_trips_through_a_new_page_load() {
+        let r = Recovery::new("a1".into());
+        let d = doc("Sketch");
+        let batch = r.snapshot(&d, 7, Some("sketch.pcraft"), 1234).unwrap();
+        let mut found = reload(&batch).recoverable();
+        assert_eq!(found.len(), 1);
+        let e = found.remove(0);
+        assert_eq!((e.name.as_str(), e.path.as_deref()), ("Sketch", Some("sketch.pcraft")));
+        assert_eq!(e.key, format!("web-a1-{}", d.id.0));
+        assert_eq!((e.load)().unwrap(), *d);
+    }
+
+    #[test]
+    fn a_second_snapshot_replaces_the_same_entry() {
+        let r = Recovery::new("s".into());
+        let d = doc("Doc");
+        let a = r.snapshot(&d, 1, None, 1).unwrap();
+        let b = r.snapshot(&d, 2, None, 2).unwrap();
+        assert_eq!(a.keys().collect::<Vec<_>>(), b.keys().collect::<Vec<_>>());
+    }
+
+    #[test]
+    fn recovered_document_adopts_its_entry_and_discard_deletes_it() {
+        let old = Recovery::new("old".into());
+        let batch = old.snapshot(&doc("Lost"), 1, None, 5).unwrap();
+        let now = reload(&batch);
+        assert_eq!(now.session(), "next");
+        let key = now.recoverable().remove(0).key;
+        let open = doc("Lost");
+        now.adopt(open.id.0, &key);
+        let again = now.snapshot(&open, 2, None, 6).unwrap();
+        assert!(again.contains_key(&bundle_name(&key)), "autosaves replace the recovered entry");
+        let del = now.discard(open.id.0).unwrap();
+        assert!(del.values().all(Option::is_none) && del.len() == 2);
+        assert!(now.recoverable().is_empty());
+        assert!(now.discard(open.id.0).is_none(), "nothing left to delete");
+    }
+
+    #[test]
+    fn oversize_snapshot_is_refused_with_a_message() {
+        let r = Recovery::with_limits("s".into(), 16, MAX_TOTAL_BYTES);
+        let e = r.snapshot(&doc("Big"), 1, None, 1).unwrap_err();
+        assert!(e.contains("too large"), "{e}");
+    }
+
+    #[test]
+    fn total_cap_counts_other_documents_but_not_the_one_being_replaced() {
+        let probe = Recovery::new("s".into()).snapshot(&doc("A"), 1, None, 1).unwrap();
+        let size = probe.values().flatten().map(|b| b.len() as u64).max().unwrap_or(1);
+        let r = Recovery::with_limits("s".into(), MAX_SNAPSHOT_BYTES, size + size / 2);
+        let (a, b) = (doc("A"), doc("B"));
+        r.snapshot(&a, 1, None, 1).unwrap();
+        r.snapshot(&a, 2, None, 2).unwrap();
+        assert!(r.snapshot(&b, 1, None, 3).unwrap_err().contains("limit"));
+        r.discard(a.id.0);
+        r.snapshot(&b, 1, None, 4).unwrap();
+    }
+
+    #[test]
+    fn a_failed_write_no_longer_counts_against_the_total_cap() {
+        let probe = Recovery::new("s".into()).snapshot(&doc("A"), 1, None, 1).unwrap();
+        let size = probe.values().flatten().map(|b| b.len() as u64).max().unwrap_or(1);
+        let r = Recovery::with_limits("s".into(), MAX_SNAPSHOT_BYTES, size + size / 2);
+        let (a, b) = (doc("A"), doc("B"));
+        r.snapshot(&a, 1, None, 1).unwrap();
+        assert!(r.snapshot(&b, 1, None, 2).is_err(), "A's attempt fills the cap");
+        // A different revision finishing does not touch A's pending write.
+        r.finish(a.id.0, 0, Err("old".into()));
+        assert!(r.snapshot(&b, 1, None, 2).is_err());
+        r.finish(a.id.0, 1, Err("QuotaExceededError".into()));
+        r.snapshot(&b, 1, None, 3).unwrap();
+    }
+
+    #[test]
+    fn a_failed_replacement_restores_the_previous_size() {
+        let r = Recovery::with_limits("s".into(), MAX_SNAPSHOT_BYTES, MAX_TOTAL_BYTES);
+        let a = doc("A");
+        r.snapshot(&a, 1, None, 1).unwrap();
+        r.finish(a.id.0, 1, Ok(()));
+        let stored = r.with(|s| s.sizes.values().sum::<u64>()).unwrap();
+        r.snapshot(&a, 2, None, 2).unwrap();
+        r.finish(a.id.0, 2, Err("fail".into()));
+        assert_eq!(r.with(|s| s.sizes.values().sum::<u64>()).unwrap(), stored);
+        r.snapshot(&a, 3, None, 3).unwrap();
+        r.discard(a.id.0);
+        r.finish(a.id.0, 3, Err("late".into()));
+        assert_eq!(r.with(|s| s.sizes.len()).unwrap(), 0, "a discarded entry stays uncounted");
+    }
+
+    #[test]
+    fn entries_of_another_live_page_are_not_offered_or_deleted() {
+        let theirs = Recovery::new("tabA".into());
+        let d = doc("Live");
+        let batch = theirs.snapshot(&d, 1, None, 5).unwrap();
+        let key = format!("web-tabA-{}", d.id.0);
+        assert_eq!(session_of(&key), Some("tabA"));
+        // This page read the records but could not take tabA's lock: nothing is claimed.
+        let me = Recovery::new("tabB".into());
+        for (name, bytes) in &batch {
+            me.load(name, bytes.as_ref().map(|b| b.to_vec()).unwrap_or_default());
+        }
+        assert!(me.recoverable().is_empty(), "a live page's entries are not offered");
+        // Adopting a foreign key is ignored, so closing the document deletes nothing.
+        let open = doc("Adopted");
+        me.adopt(open.id.0, &key);
+        assert!(me.discard(open.id.0).is_none());
+        // Once the other page is gone and its lock is taken, the entry is offered and adoptable.
+        me.claim("tabA");
+        me.load(&info_name(&key), batch[&info_name(&key)].as_ref().map(|b| b.to_vec()).unwrap_or_default());
+        me.load(&bundle_name(&key), batch[&bundle_name(&key)].as_ref().map(|b| b.to_vec()).unwrap_or_default());
+        assert_eq!(me.recoverable().len(), 1);
+        me.adopt(open.id.0, &key);
+        assert!(me.discard(open.id.0).is_some());
+    }
+
+    #[test]
+    fn unread_entries_still_count_against_the_total_cap() {
+        let r = Recovery::with_limits("s".into(), MAX_SNAPSHOT_BYTES, 1000);
+        r.note_size("web-other-1.pcraft", 990);
+        assert!(r.snapshot(&doc("A"), 1, None, 1).unwrap_err().contains("limit"));
+    }
+
+    #[test]
+    fn recovered_bytes_are_handed_over_once() {
+        let batch = Recovery::new("old".into()).snapshot(&doc("Lost"), 1, None, 5).unwrap();
+        let now = reload(&batch);
+        assert_eq!(now.recoverable().len(), 1);
+        assert!(now.recoverable().is_empty(), "the loader owns the only copy of the bytes");
+    }
+
+    #[test]
+    fn recover_on_launch_reads_the_stored_preference() {
+        assert!(recover_on_launch(None));
+        assert!(recover_on_launch(Some("not json")));
+        assert!(recover_on_launch(Some(r#"{"fileHandling":{"autosave":true}}"#)));
+        assert!(!recover_on_launch(Some(r#"{"fileHandling":{"recoverOnLaunch":false}}"#)));
+    }
+
+    #[test]
+    fn write_outcomes_are_reported_once() {
+        let r = Recovery::new("s".into());
+        r.finish(3, 9, Ok(()));
+        r.finish(3, 10, Err("QuotaExceededError".into()));
+        assert_eq!(r.take_completed(), vec![(3, 9, Ok(())), (3, 10, Err("QuotaExceededError".into()))]);
+        assert!(r.take_completed().is_empty());
+    }
+
+    #[test]
+    fn damaged_or_foreign_records_are_not_offered() {
+        let r = Recovery::new("s".into());
+        r.claim("x");
+        r.claim("y");
+        r.load("web-x-1.json", b"not json".to_vec());
+        r.load("web-x-1.pcraft", vec![1, 2, 3]);
+        r.load("../evil.json", br#"{"key":"../evil","document_name":"x","original_path":null,"saved_at":1,"revision":1}"#.to_vec());
+        r.load("../evil.pcraft", vec![0]);
+        r.load("orphan.pcraft", vec![0]);
+        assert!(r.recoverable().is_empty());
+        // A sidecar whose bundle is garbage is offered, and fails to load without a panic.
+        r.load("web-y-2.json", br#"{"key":"web-y-2","document_name":"Y","original_path":null,"saved_at":1,"revision":1}"#.to_vec());
+        r.load("web-y-2.pcraft", vec![1, 2, 3]);
+        let mut found = r.recoverable();
+        assert_eq!(found.len(), 1);
+        assert!((found.remove(0).load)().is_err());
+    }
+}
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
@@ -74,8 +74,17 @@ pub fn start() {
                 (None, bridge, Vec::new())
             }
         };
+        let (recovery_db, recovery) = match crate::indexed_recovery::load().await {
+            Ok((db, recovery)) => (Some(db), recovery),
+            Err(e) => {
+                log::warn!("Crash recovery unavailable: {e}");
+                (None, crate::recovery_bridge::Recovery::new(String::new()))
+            }
+        };
         let pen_target = canvas.clone();
-        let result = eframe::WebRunner::new()
+        let runner = Rc::new(eframe::WebRunner::new());
+        save_when_hidden(runner.clone());
+        let result = runner
             .start(
                 canvas,
                 options,
@@ -89,7 +98,13 @@ 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 mut services = services(inbox.clone(), place_inbox.clone(), drag_pos_service(pen_target.clone(), drag));
+                    // Crash recovery (#6615): the UI's autosave timer and launch recovery, over IndexedDB.
+                    let stored = crate::indexed_recovery::services(recovery_db, recovery);
+                    (services.autosave, services.autosave_results, services.flush_autosaves) =
+                        (stored.autosave, stored.autosave_results, stored.flush_autosaves);
+                    (services.discard_autosave, services.recover, services.adopt_autosave) = (stored.discard_autosave, stored.recover, stored.adopt_autosave);
+                    let mut app = PhotocraftApp::new(session, services);
                     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);
                     }
@@ -178,6 +193,32 @@ fn drag_pos_service(canvas: web_sys::HtmlCanvasElement, pos: DragPos) -> photocr
     })
 }
 
+/// Becoming hidden (tab switch, minimise, mobile app switch) is the last dependable moment to
+/// save: browsers discard background tabs without a `beforeunload`. A hidden page paints no
+/// frames, so the UI's frame-driven autosave timer cannot run then; this saves unsaved
+/// documents directly. `pagehide` does the same, though a write may not finish there.
+fn save_when_hidden(runner: Rc<eframe::WebRunner>) {
+    use wasm_bindgen::closure::Closure;
+    let Some(window) = web_sys::window() else { return };
+    let Some(document) = window.document() else { return };
+    let targets = [(web_sys::EventTarget::from(document.clone()), "visibilitychange"), (web_sys::EventTarget::from(window.clone()), "pagehide")];
+    for (target, event) in targets {
+        let (runner, document) = (runner.clone(), document.clone());
+        let cb = Closure::<dyn FnMut()>::new(move || {
+            if event == "visibilitychange" && document.visibility_state() != web_sys::VisibilityState::Hidden {
+                return;
+            }
+            // `None` while a frame is running: the frame's own autosave covers it.
+            if let Some(mut shell) = runner.app_mut::<WebShell>() {
+                photocraft_ui_egui::prefs_ui::autosave_dirty_now(&mut shell.app);
+            }
+        });
+        if target.add_event_listener_with_callback(event, cb.as_ref().unchecked_ref()).is_ok() {
+            cb.forget();
+        }
+    }
+}
+
 /// Closing or reloading the tab while a document has unsaved changes asks first, as closing the
 /// desktop window does: the browser shows its own "Leave site?" prompt (#1380). `unsaved` is
 /// refreshed every frame by [`WebShell`].
@@ -325,6 +366,10 @@ impl WebShell {
 }
 
 impl eframe::App for WebShell {
+    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
+        Some(self)
+    }
+
     fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
         // A tap reaches eframe as Pointer Events and Touch Events: keep one press per tap, or the
         // twin press closes the menu the first one opened (#2095).
diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs
--- a/crates/ui-egui/src/prefs_ui.rs
+++ b/crates/ui-egui/src/prefs_ui.rs
@@ -812,6 +812,14 @@ pub fn autosave_now(app: &mut PhotocraftApp) {
     app.prefs_rt.next_autosave_ms = f64::MIN_POSITIVE;
 }
 
+/// Autosave unsaved documents right now, outside the frame loop (the web build, when the page is
+/// hidden and no frames run). Honors the Autosave preference; documents already saved at their
+/// current revision are skipped.
+pub fn autosave_dirty_now(app: &mut PhotocraftApp) {
+    autosave_now(app);
+    autosave(app);
+}
+
 /// History Log preference: append executed commands to a text file.
 fn history_log(app: &mut PhotocraftApp) {
     let n = app.session.journal.len();
@@ -4617,6 +4625,33 @@ mod tests {
         assert!(!items.iter().any(|i| i.id == "edit.fade"));
     }
 
+    /// The web build saves when the page is hidden, where no frame (and so no timer) runs.
+    #[test]
+    fn autosave_dirty_now_saves_without_a_frame_and_honors_the_preference() {
+        let saved: Arc<Mutex<Vec<u64>>> = Arc::default();
+        let s2 = saved.clone();
+        let services = crate::Services {
+            autosave: Some(Box::new(move |_doc: &Arc<photocraft_doc::Document>, rev: u64, _path: Option<&str>| {
+                s2.lock().unwrap().push(rev);
+                Ok(())
+            })),
+            ..Default::default()
+        };
+        let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services);
+        app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
+        autosave_dirty_now(&mut app);
+        assert!(saved.lock().unwrap().is_empty(), "clean documents are not saved");
+        app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
+        autosave_dirty_now(&mut app);
+        assert_eq!(saved.lock().unwrap().len(), 1);
+        autosave_dirty_now(&mut app);
+        assert_eq!(saved.lock().unwrap().len(), 1, "unchanged since the last save");
+        app.run("prefs.set", json!({"path": "fileHandling.autosave", "value": false})).unwrap();
+        app.run("edit.fill", json!({"color": "#00ff00"})).unwrap();
+        autosave_dirty_now(&mut app);
+        assert_eq!(saved.lock().unwrap().len(), 1, "autosave off");
+    }
+
     #[test]
     fn autosave_runs_for_dirty_documents() {
         type Saved = Arc<Mutex<Vec<(u64, u64)>>>;
diff --git a/docs/development.md b/docs/development.md
--- a/docs/development.md
+++ b/docs/development.md
@@ -244,7 +244,7 @@ cargo run -p photocraft-cli --example man-and-completions -- out/       # man pa
 - `thumb.png` and `composite/preview.png`: previews.
 - `save.lock` (directory bundles only): an empty file a save holds an OS file lock on, so two writers or processes saving one bundle take turns (#4957).
 
-Keep one `PcraftWriter` per open document: re-saving then only compresses and writes tiles that changed. Directory bundles verify objects on first encounter in a folder; later saves reuse them while their file size and modification time are unchanged, and re-verify changed objects, repair missing or damaged objects, and garbage-collect unreferenced ones. `format::Autosaver` writes snapshots into a recovery directory on a background thread. `list_recovery` / `recover` / `discard_recovery` implement crash recovery, and `format::RecoveryStore` is the lifecycle the desktop app uses: new documents autosave under per-launch keys (document ids restart every launch, so they never overwrite an older entry), and a recovered document adopts the entry it came from. That entry is replaced in place by the next autosave and removed only when the document is saved or closed, never just because it was recovered, so a second crash loses nothing. The web build has no crash recovery (no autosave services).
+Keep one `PcraftWriter` per open document: re-saving then only compresses and writes tiles that changed. Directory bundles verify objects on first encounter in a folder; later saves reuse them while their file size and modification time are unchanged, and re-verify changed objects, repair missing or damaged objects, and garbage-collect unreferenced ones. `format::Autosaver` writes snapshots into a recovery directory on a background thread. `list_recovery` / `recover` / `discard_recovery` implement crash recovery, and `format::RecoveryStore` is the lifecycle the desktop app uses: new documents autosave under per-launch keys (document ids restart every launch, so they never overwrite an older entry), and a recovered document adopts the entry it came from. That entry is replaced in place by the next autosave and removed only when the document is saved or closed, never just because it was recovered, so a second crash loses nothing. The web build has the same recovery over IndexedDB (`apps/photocraft-web/src/recovery_bridge.rs`, `indexed_recovery.rs`, database `photocraft.recovery`): the UI's autosave timer and launch recovery run unchanged, each snapshot is the document's `.pcraft` bytes from a per-document `PcraftWriter` (capped at 256 MB per snapshot, 768 MB in total), and a tab becoming hidden (`visibilitychange`, `pagehide`) saves unsaved documents at once because a hidden page paints no frames.
 
 At startup the desktop lists recovery metadata, then decodes one document at a time through the existing background-job system. The window stays usable, with recovery progress and Cancel in the status bar. Cancel skips the rest of that launch's recovery queue and prevents the current result from opening; the decoder may finish in the background. Failed or cancelled loads leave their recovery files intact for a later launch. Turning off Preferences › File Handling › Recover on launch skips discovery altogether.
 
