diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -380,6 +380,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
 
 Newest first.
 
+- **2026-10-27** · Automatic recovery writes off the interface thread, straight to disk, and keeps placed files that are linked as links instead of copying them into every snapshot. On a 190-page catalogue with 4.2 GB of links an autosave was 3.6 GB deflated on the interface thread, which stopped the window answering for minutes after an edit and left a 3.6 GB file behind per crash; it is now a ~1 MB write in the background, and recovering re-reads the links as opening the document does (`file.recovery.save {background}`, `file.recovery.progress`). No readiness percentage change.
+- **2026-10-27** · The Pages panel draws page and parent thumbnails on a background thread, only for the rows on screen, and keeps the previous revision's picture until the new one arrives. On the same 190-page catalogue switching to the Pages tab went from 43 s to 0.2 s, and editing no longer froze the window for over a minute. No readiness percentage change.
 - **2026-10-27** · An iconic panel's flyout stays open while you work in the canvas or a text field: Auto-Collapse Iconic Panels is off by default, and preferences saved with it on start with it off. Preferences › Interface › Panels holds the setting next to the column bar's menu item. No readiness percentage change.
 - **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change.
 - **2026-10-19** · Import InDesign Set… runs on craft-keymap's importer: a set's Window menu panel items (Layers F7, Pages F12, Swatches F5) and Tools items set the panel and tool shortcuts, all of an item's Default keys apply, and the report adds numeric keypad keys read as main keys and likely commands for unmatched items (`window.importShortcuts`). On InDesign's Windows defaults it matches 366 of 1,629 items (was 233) and changes 23 commands. No readiness percentage change.
diff --git a/crates/engine/src/cmd/file/registry.rs b/crates/engine/src/cmd/file/registry.rs
--- a/crates/engine/src/cmd/file/registry.rs
+++ b/crates/engine/src/cmd/file/registry.rs
@@ -65,10 +65,20 @@ pub fn specs() -> Vec<CommandSpec> {
             let dir = recovery_dir(s, p, FolderAccess::Read)?;
             Ok(Value::Array(crate::recovery::list(&dir).into_iter().map(|(uid, mut m)| { m["uid"] = json!(uid); m }).collect()))
         }),
-        cmd!(noundo "file.recovery.save", "Save Recovery Data", [], None, "{dir?} — write every unsaved document to the recovery folder (the app does this on a timer)", always, |s, p| {
+        cmd!(noundo "file.recovery.save", "Save Recovery Data", [], None,
+            "{dir?, background?} — write every unsaved document to the recovery folder (the app does this on a timer; `background` queues the writes and returns at once) → {saved}` or {queued}",
+            always, |s, p| {
             let dir = recovery_dir(s, p, FolderAccess::Write)?;
+            if p.get("background").and_then(Value::as_bool).unwrap_or(false) {
+                return Ok(json!({"queued": crate::recovery::start_tracked(s, &dir)?}));
+            }
             Ok(json!({"saved": crate::recovery::save_tracked(s, &dir)?}))
         }),
+        cmd!(query "file.recovery.progress", "Recovery Progress", [], None,
+            "{} → {written, busy, errors} recovery data queued in the background has been written", always, |s, _| {
+            let done = crate::recovery::finished(s);
+            Ok(json!({"written": done.written, "busy": done.busy, "errors": done.errors}))
+        }),
         cmd!(noundo "file.recovery.open", "Recover Documents", [], None, "{dir?} — reopen documents left in the recovery folder (unsaved)", always, |s, p| {
             // Reopening removes the entries it read.
             let dir = recovery_dir(s, p, FolderAccess::ReadWrite)?;
diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs
--- a/crates/engine/src/cmd/interchange.rs
+++ b/crates/engine/src/cmd/interchange.rs
@@ -369,7 +369,7 @@ pub(crate) fn resolve_packaged_links(document: &mut Document, package: Option<&s
 /// The first candidate for `link` ([`packaged_link_candidates`]) that is a regular file no larger
 /// than [`MAX_LINKED_FILE_BYTES`] holding an image or a graphic, with its path.
 #[cfg(not(target_arch = "wasm32"))]
-fn read_packaged_link(link: &str, package: Option<&std::path::Path>) -> Option<(String, Vec<u8>)> {
+pub(crate) fn read_packaged_link(link: &str, package: Option<&std::path::Path>) -> Option<(String, Vec<u8>)> {
     for path in packaged_link_candidates(link, package) {
         if !crate::io::is_file(&path) || !crate::io::metadata(&path).is_ok_and(|m| m.len() <= MAX_LINKED_FILE_BYTES) {
             continue;
diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs
--- a/crates/engine/src/recovery.rs
+++ b/crates/engine/src/recovery.rs
@@ -528,7 +528,7 @@ pub fn save(s: &Session, dir: &Path) -> Result<usize> {
             discard(dir, d.uid);
             continue;
         }
-        write_entry(&access, dir, d)?;
+        write_snapshot(&Snapshot::of(&access, dir, d))?;
         n += 1;
     }
     Ok(n)
@@ -537,14 +537,7 @@ pub fn save(s: &Session, dir: &Path) -> Result<usize> {
 /// Command-path checkpointing also records explicitly selected directories, without configuring
 /// automatic recovery. The immutable public helper remains available for fixtures and callers.
 pub(crate) fn save_tracked(s: &mut Session, dir: &Path) -> Result<usize> {
-    // Reject the complete batch before writing rather than silently losing old ownership records.
-    for document in &s.docs {
-        if document.is_dirty() && !has_checkpoint(document, dir) && document.recovery_entries.len() >= MAX_RECOVERY_ENTRIES {
-            return Err(EngineError::Other(
-                "too many recovery checkpoints; save or close the document before choosing another recovery folder".into(),
-            ));
-        }
-    }
+    reject_overfull_checkpoints(s, dir)?;
     let access = prepare_save(s, dir)?;
     let mut n = 0;
     let preview = s.new_document_preview();
@@ -557,34 +550,246 @@ pub(crate) fn save_tracked(s: &mut Session, dir: &Path) -> Result<usize> {
             discard_document(s, index, Some(dir));
             continue;
         }
-        let Some(document) = s.docs.get_mut(index) else { continue };
-        let (doc, meta) = files(dir, document.uid);
-        let bytes = designcraft_format::save(document.preview_stash.as_deref().unwrap_or(document.doc.as_ref()))
-            .map_err(|e| EngineError::Other(e.to_string()))?;
-        // An entry this run did not write is never replaced, even one made since it checked.
-        let replace = access.owns(document.uid);
-        publish_recovery_file(&doc, &bytes, replace)?;
-        access.record(document.uid);
-        // The primary is useful even if optional metadata fails next. Track it immediately.
-        if !has_checkpoint(document, dir) {
+        let Some(document) = s.docs.get(index) else { continue };
+        let entry = Snapshot::of(&access, dir, document);
+        write_snapshot(&entry)?;
+        // The primary is useful even if optional metadata failed next. Track it immediately.
+        if let Some(document) = s.docs.get_mut(index)
+            && !has_checkpoint(document, dir)
+        {
             document.recovery_entries.push((dir.to_path_buf(), document.uid));
         }
-        let metadata = json!({"path": document.path, "title": document.title(), "saved": designcraft_doc::vars::now()});
-        publish_recovery_file(&meta, metadata.to_string().as_bytes(), replace)?;
         n += 1;
     }
     Ok(n)
 }
 
+/// Reject a complete batch before writing rather than silently losing old ownership records.
+fn reject_overfull_checkpoints(s: &Session, dir: &Path) -> Result<()> {
+    for document in &s.docs {
+        if document.is_dirty() && !has_checkpoint(document, dir) && document.recovery_entries.len() >= MAX_RECOVERY_ENTRIES {
+            return Err(EngineError::Other(
+                "too many recovery checkpoints; save or close the document before choosing another recovery folder".into(),
+            ));
+        }
+    }
+    Ok(())
+}
+
+/// A document's recovery write, copied so a background thread can write it while the interface
+/// stays responsive: no deflate, no copy of the placed files, happens on the interface thread.
+struct Snapshot {
+    dir: PathBuf,
+    uid: u64,
+    /// The document as it was when the write started (data-merge previews save the unfilled one).
+    doc: std::sync::Arc<designcraft_doc::Document>,
+    path: Option<String>,
+    title: String,
+    /// Whether this run may replace an entry already in the folder.
+    own: bool,
+    /// The folder's canonical key, for recording that a shared entry is this run's now.
+    key: PathBuf,
+}
+
+impl Snapshot {
+    fn of(access: &Access, dir: &Path, d: &DocState) -> Self {
+        Snapshot {
+            dir: dir.to_path_buf(),
+            uid: d.uid,
+            doc: d.preview_stash.clone().unwrap_or_else(|| d.doc.clone()),
+            path: d.path.clone(),
+            title: d.title(),
+            // An entry this run did not write is never replaced, even one made since it checked.
+            own: access.owns(d.uid),
+            key: access.key.clone(),
+        }
+    }
+}
+
 /// Publish a document's recovery entry before its optional metadata.
-fn write_entry(access: &Access, dir: &Path, d: &DocState) -> Result<()> {
-    let (doc, meta) = files(dir, d.uid);
-    let bytes = designcraft_format::save(d.preview_stash.as_deref().unwrap_or(d.doc.as_ref())).map_err(|e| EngineError::Other(e.to_string()))?;
-    let replace = access.owns(d.uid);
-    publish_recovery_file(&doc, &bytes, replace)?;
-    access.record(d.uid);
-    let metadata = json!({"path": d.path, "title": d.title(), "saved": designcraft_doc::vars::now()});
-    publish_recovery_file(&meta, metadata.to_string().as_bytes(), replace)
+fn write_snapshot(entry: &Snapshot) -> Result<()> {
+    let (doc, meta) = files(&entry.dir, entry.uid);
+    // Links stay links: recovery data is not a copy of the user's disk, and a catalogue with
+    // gigabytes of placed files otherwise writes them all again every few minutes (and leaves
+    // them behind after a crash). Recovery re-reads them, as opening the document does.
+    publish_recovery_written(&doc, entry.own, |out| {
+        designcraft_format::write_with(out, &entry.doc, None, designcraft_format::Placed::LinksOnly).map_err(|e| EngineError::Other(e.to_string()))
+    })?;
+    // Written: in a folder that is not this run's own, the entry is now this run's to replace and
+    // remove (Save and Close must clean up the snapshot they wrote).
+    if !entry.own {
+        WRITTEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert((entry.key.clone(), entry.uid));
+    }
+    let metadata = json!({"path": entry.path, "title": entry.title, "saved": designcraft_doc::vars::now()});
+    publish_recovery_file(&meta, metadata.to_string().as_bytes(), entry.own)
+}
+
+/// Write recovery entries for dirty documents on a background thread and drop clean checkpoints
+/// now. The interface thread only snapshots what to write: `designcraft_format::write` deflates
+/// and copies placed files, which takes minutes on a catalogue with gigabytes of links. Returns
+/// how many documents were queued.
+pub(crate) fn start_tracked(s: &mut Session, dir: &Path) -> Result<usize> {
+    reject_overfull_checkpoints(s, dir)?;
+    let access = prepare_save(s, dir)?;
+    // Nothing runs on the web's single thread: it would freeze the page just the same.
+    #[cfg(target_arch = "wasm32")]
+    {
+        let _ = access;
+        return save_tracked(s, dir);
+    }
+    #[cfg(not(target_arch = "wasm32"))]
+    {
+        let mut queued = 0;
+        let preview = s.new_document_preview();
+        for index in 0..s.docs.len() {
+            if s.docs.get(index).is_some_and(|document| preview == Some(document.uid)) {
+                continue;
+            }
+            if s.docs.get(index).is_some_and(|document| !document.is_dirty()) {
+                discard_document(s, index, Some(dir));
+                continue;
+            }
+            let Some(document) = s.docs.get(index) else { continue };
+            let entry = Snapshot::of(&access, dir, document);
+            // A write still running for this document already holds a snapshot of it; the next
+            // timer tick catches up with the edits made since.
+            match worker::submit(entry) {
+                Some(true) => queued += 1,
+                Some(false) => {}
+                None => {
+                    // No thread: write this one here rather than lose the recovery data.
+                    let entry = Snapshot::of(&access, dir, document);
+                    write_snapshot(&entry)?;
+                    queued += 1;
+                }
+            }
+        }
+        Ok(queued)
+    }
+}
+
+/// Apply finished background writes: record the checkpoint of each document still open, and drop
+/// the entry of one closed while it was being written. Returns what was written and what failed.
+pub(crate) struct Finished {
+    pub written: usize,
+    pub errors: Vec<String>,
+    pub busy: bool,
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+pub(crate) fn finished(s: &mut Session) -> Finished {
+    let mut out = Finished { written: 0, errors: Vec::new(), busy: false };
+    let Some(w) = worker::started() else { return out };
+    out.busy = w.busy();
+    let Ok(done) = w.done.lock() else { return out };
+    while let Ok(d) = done.try_recv() {
+        if let Some(e) = d.error {
+            out.errors.push(e);
+            continue;
+        }
+        match s.docs.iter_mut().find(|doc| doc.uid == d.uid) {
+            Some(doc) => {
+                if !has_checkpoint(doc, &d.dir) {
+                    doc.recovery_entries.push((d.dir, d.uid));
+                }
+                out.written += 1;
+            }
+            // Closed while being written: its snapshot must not come back after a crash.
+            None => discard(&d.dir, d.uid),
+        }
+    }
+    out.busy |= w.busy();
+    out
+}
+
+#[cfg(target_arch = "wasm32")]
+pub(crate) fn finished(_s: &mut Session) -> Finished {
+    // Nothing is written in the background on the web.
+    Finished { written: 0, errors: Vec::new(), busy: false }
+}
+
+/// Automatic recovery's writer: one thread with its own queue, so an unsaved document with
+/// gigabytes of placed files is written without stopping the interface.
+#[cfg(not(target_arch = "wasm32"))]
+mod worker {
+    use std::collections::BTreeSet;
+    use std::sync::mpsc::{Receiver, Sender};
+    use std::sync::{Mutex, OnceLock};
+
+    use super::*;
+
+    /// One document written, for the interface thread to record.
+    pub(super) struct Done {
+        pub(super) dir: PathBuf,
+        pub(super) uid: u64,
+        pub(super) error: Option<String>,
+    }
+
+    pub(super) struct Writer {
+        jobs: Sender<Snapshot>,
+        pub(super) done: Mutex<Receiver<Done>>,
+        /// Documents queued or being written: a second write of one still holds nothing newer.
+        running: Mutex<BTreeSet<(PathBuf, u64)>>,
+    }
+
+    static WRITER: OnceLock<Option<Writer>> = OnceLock::new();
+
+    impl Writer {
+        pub(super) fn busy(&self) -> bool {
+            self.running.lock().is_ok_and(|running| !running.is_empty())
+        }
+
+        fn finish(&self, dir: &Path, uid: u64) {
+            if let Ok(mut running) = self.running.lock() {
+                running.remove(&(dir.to_path_buf(), uid));
+            }
+        }
+    }
+
+    /// The writer, started on first use (a thread that can't start leaves recovery synchronous).
+    fn writer() -> Option<&'static Writer> {
+        WRITER
+            .get_or_init(|| {
+                let (jobs, job_rx) = std::sync::mpsc::channel::<Snapshot>();
+                let (done_tx, done_rx) = std::sync::mpsc::channel::<Done>();
+                let spawned = std::thread::Builder::new().name("designcraft-recovery".into()).spawn(move || {
+                    while let Ok(job) = job_rx.recv() {
+                        let error = write_snapshot(&job).err().map(|e| e.to_string());
+                        let (dir, uid) = (job.dir, job.uid);
+                        // Before the result is visible: a writer already done is not busy.
+                        if let Some(w) = WRITER.get().and_then(Option::as_ref) {
+                            w.finish(&dir, uid);
+                        }
+                        if done_tx.send(Done { dir, uid, error }).is_err() {
+                            break;
+                        }
+                    }
+                });
+                spawned.ok().and_then(|_| Some(Writer { jobs, done: Mutex::new(done_rx), running: Mutex::new(BTreeSet::new()) }))
+            })
+            .as_ref()
+    }
+
+    /// The writer if it has been started.
+    pub(super) fn started() -> Option<&'static Writer> {
+        WRITER.get().and_then(Option::as_ref)
+    }
+
+    /// Queue a document's write. `None`: no writer thread (the caller writes it itself);
+    /// `Some(false)`: one for this document is still queued or running.
+    pub(super) fn submit(entry: Snapshot) -> Option<bool> {
+        let w = writer()?;
+        let mut running = w.running.lock().ok()?;
+        let key = (entry.dir.clone(), entry.uid);
+        if !running.insert(key.clone()) {
+            return Some(false);
+        }
+        if w.jobs.send(entry).is_err() {
+            running.remove(&key);
+            return None;
+        }
+        Some(true)
+    }
 }
 
 #[cfg(not(target_arch = "wasm32"))]
@@ -632,6 +837,13 @@ fn secure_recovery_dir(_dir: &Path) -> Result<bool> {
 
 #[cfg(not(target_arch = "wasm32"))]
 fn publish_recovery_file(path: &Path, bytes: &[u8], replace: bool) -> Result<()> {
+    publish_recovery_written(path, replace, |out| out.write_all(bytes).map_err(|e| EngineError::Other(format!("{}: {e}", path.display()))))
+}
+
+/// [`publish_recovery_file`], with `write` filling the temporary file: a large document is written
+/// straight to disk instead of being built in memory first.
+#[cfg(not(target_arch = "wasm32"))]
+fn publish_recovery_written(path: &Path, replace: bool, write: impl FnOnce(&mut std::io::BufWriter<&mut std::fs::File>) -> Result<()>) -> Result<()> {
     let Some(dir) = path.parent() else {
         return Err(EngineError::Other(format!("{} has no parent folder", path.display())));
     };
@@ -646,7 +858,11 @@ fn publish_recovery_file(path: &Path, bytes: &[u8], replace: bool) -> Result<()>
         use std::os::unix::fs::PermissionsExt;
         tmp.as_file().set_permissions(std::fs::Permissions::from_mode(0o600)).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
     }
-    tmp.as_file_mut().write_all(bytes).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
+    {
+        let mut out = std::io::BufWriter::new(tmp.as_file_mut());
+        write(&mut out)?;
+        out.flush().map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
+    }
     tmp.as_file_mut().sync_all().map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
     // Validate the non-followed leaf again immediately before renaming the randomized temporary
     // file to its fixed sibling destination.
@@ -712,6 +928,15 @@ fn publish_recovery_file(_path: &Path, _bytes: &[u8], _replace: bool) -> Result<
     Err(EngineError::Other("recovery files are unavailable on the web".into()))
 }
 
+#[cfg(target_arch = "wasm32")]
+fn publish_recovery_written(
+    _path: &Path,
+    _replace: bool,
+    _write: impl FnOnce(&mut std::io::BufWriter<&mut std::fs::File>) -> Result<()>,
+) -> Result<()> {
+    Err(EngineError::Other("recovery files are unavailable on the web".into()))
+}
+
 /// Canonical keys are only for alias comparison; operational paths stay unchanged so run
 /// ownership checks still find the directory registered by the native profile.
 fn directory_key(dir: &Path) -> Result<PathBuf> {
@@ -772,6 +997,21 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> {
     out
 }
 
+/// Read back the placed files a recovery snapshot left out because they are linked, as opening
+/// the document does. A link whose file is gone stays a missing link, and the document opens the
+/// same way the original would: recovery data is not a second copy of the user's disk.
+#[cfg(not(target_arch = "wasm32"))]
+fn reload_links(d: &mut designcraft_doc::Document, document: Option<&Path>) {
+    for a in d.assets.values_mut() {
+        if !a.data.is_empty() {
+            continue;
+        }
+        let Some(link) = a.link.clone() else { continue };
+        let Some((_, bytes)) = crate::cmd::interchange::read_packaged_link(&link, document) else { continue };
+        std::sync::Arc::make_mut(a).data = std::sync::Arc::new(bytes);
+    }
+}
+
 /// Reopen recovery entries as unsaved documents. Publish each recovered document under its new
 /// uid before removing the original, so another crash before the next timer preserves the edits.
 /// Entries belonging to documents already open in this session's recovery folder are left alone.
@@ -816,6 +1056,9 @@ fn open_with_publisher(s: &mut Session, dir: &Path, mut publish: impl FnMut(&Pat
             Err(_) => continue,
         };
         let path = meta.get("path").and_then(Value::as_str).map(str::to_string);
+        // The snapshot left the linked files out; they are where the document says they are.
+        #[cfg(not(target_arch = "wasm32"))]
+        reload_links(&mut d, path.as_deref().map(Path::new));
         // Skipped font files are logged; the document opens without them.
         let fonts = path.as_deref().and_then(|p| crate::cmd::load_document_fonts(&mut d, p).0);
         let mut st = DocState::new(d, path);
@@ -834,7 +1077,8 @@ fn open_with_publisher(s: &mut Session, dir: &Path, mut publish: impl FnMut(&Pat
         // clean up every snapshot this document actually owns rather than an unrelated same uid.
         if let Some(state) = s.docs.get_mut(index) {
             let (document, metadata) = files(&destination, state.uid);
-            let published = designcraft_format::save(state.doc.as_ref())
+            // Still recovery data: the republished copy keeps links as links too.
+            let published = designcraft_format::save_links_only(state.doc.as_ref())
                 .map_err(|e| EngineError::Other(e.to_string()))
                 .and_then(|bytes| publish(&document, &bytes));
             match published {
diff --git a/crates/engine/src/recovery/tests.rs b/crates/engine/src/recovery/tests.rs
--- a/crates/engine/src/recovery/tests.rs
+++ b/crates/engine/src/recovery/tests.rs
@@ -126,6 +126,96 @@ fn recovery_data_is_private_to_the_user() {
     }
 }
 
+/// Poll `file.recovery.progress` until the background writer has nothing left, as the app does at
+/// the end of a frame.
+fn finished_writes(s: &mut Session) -> usize {
+    let mut written = 0;
+    for _ in 0..1200 {
+        let v = s.execute("file.recovery.progress", &json!({})).unwrap();
+        assert_eq!(v["errors"], json!([]), "{v}");
+        written += v["written"].as_u64().unwrap_or(0) as usize;
+        if !v["busy"].as_bool().unwrap_or(false) {
+            return written;
+        }
+        std::thread::sleep(std::time::Duration::from_millis(5));
+    }
+    panic!("the background writer did not finish");
+}
+
+#[test]
+fn a_background_save_writes_recovery_data_the_app_can_recover() {
+    let dir = tempfile::tempdir().unwrap();
+    let mut s = dirty_session();
+    s.recovery_dir = Some(dir.path().to_path_buf());
+    // The timer's call: the interface thread does not wait for the deflate or the file writes.
+    let queued = s.execute("file.recovery.save", &json!({"background": true})).unwrap();
+    assert_eq!(queued["queued"], 1, "{queued}");
+    assert_eq!(finished_writes(&mut s), 1);
+    assert_eq!(list(dir.path()).len(), 1);
+    assert!(s.docs[0].recovery_entries.iter().any(|(d, _)| same_directory(d, dir.path())), "the checkpoint is recorded");
+    // Recovering it gives the unsaved edits back.
+    let mut reopened = Session::new();
+    assert_eq!(open(&mut reopened, dir.path()).unwrap().len(), 1);
+    assert_eq!(reopened.doc().unwrap().doc.spreads[0].items.len(), 1);
+    assert!(reopened.doc().unwrap().is_dirty());
+    discard(dir.path(), reopened.doc().unwrap().uid);
+}
+
+/// A placed file linked to disk, as a catalogues with a Links folder has them.
+fn session_with_linked_image(dir: &Path) -> (Session, std::path::PathBuf) {
+    let image = dir.join("photo.png");
+    let png = designcraft_render::Rendered { width: 8, height: 8, pixels: vec![90u8; 8 * 8 * 4] }.to_png();
+    std::fs::write(&image, &png).unwrap();
+    let mut s = Session::new();
+    s.execute("file.new", &json!({})).unwrap();
+    s.execute("file.place", &json!({"path": image.to_string_lossy()})).unwrap();
+    assert!(s.doc().unwrap().doc.assets.values().any(|a| a.link.is_some()), "the file is placed as a link");
+    (s, image)
+}
+
+#[test]
+fn recovery_data_keeps_a_linked_file_as_a_link() {
+    // Embedding the linked file made a recovery snapshot as large as the user's Links folder,
+    // and left those gigabytes behind after every crash.
+    let dir = tempfile::tempdir().unwrap();
+    let (s, image) = session_with_linked_image(dir.path());
+    let entry = dir.path().join(format!("{}.designcraft", s.docs[0].uid));
+    assert_eq!(save(&s, dir.path()).unwrap(), 1);
+    let bytes = std::fs::read(&entry).unwrap();
+    let packaged = designcraft_format::load(&bytes).unwrap();
+    assert!(packaged.assets.values().all(|a| a.data.is_empty()), "the snapshot carries no copy of the link");
+    assert!(packaged.assets.values().all(|a| a.link.is_some()), "it keeps where the file is");
+    // Recovering reads it back: the document opens as it did.
+    let mut reopened = Session::new();
+    assert_eq!(open(&mut reopened, dir.path()).unwrap().len(), 1);
+    let a = reopened.doc().unwrap().doc.assets.values().find(|a| a.link.is_some()).expect("the link");
+    assert_eq!(a.link.as_deref(), Some(image.to_string_lossy().as_ref()));
+    assert_eq!(&*a.data, &std::fs::read(&image).unwrap());
+    discard(dir.path(), reopened.doc().unwrap().uid);
+    // A link that is gone after the crash is a missing link, not a failed recovery.
+    assert_eq!(save(&s, dir.path()).unwrap(), 1);
+    std::fs::remove_file(&image).unwrap();
+    let mut recovered = Session::new();
+    assert_eq!(open(&mut recovered, dir.path()).unwrap().len(), 1);
+    let a = recovered.doc().unwrap().doc.assets.values().find(|a| a.link.is_some()).expect("the link");
+    assert!(a.data.is_empty());
+    discard(dir.path(), recovered.doc().unwrap().uid);
+}
+
+#[test]
+fn a_saved_document_is_not_written_in_the_background() {
+    // The autosave writes unsaved documents only.
+    let dir = tempfile::tempdir().unwrap();
+    let mut s = dirty_session();
+    s.recovery_dir = Some(dir.path().to_path_buf());
+    let saved = dir.path().join("saved.designcraft");
+    s.execute("file.saveAs", &json!({"path": saved.to_string_lossy()})).unwrap();
+    let queued = s.execute("file.recovery.save", &json!({"background": true})).unwrap();
+    assert_eq!(queued["queued"], 0, "{queued}");
+    assert_eq!(finished_writes(&mut s), 0);
+    assert!(list(dir.path()).is_empty());
+}
+
 #[test]
 fn existing_recovery_directory_is_used() {
     let root = tempfile::tempdir().unwrap();
diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs
--- a/crates/format/src/lib.rs
+++ b/crates/format/src/lib.rs
@@ -3,7 +3,9 @@
 //! - `mimetype` (stored first, uncompressed): `application/vnd.designcraft+zip`
 //! - `document.json`: the [`Document`] (pretty JSON, documented by the serde model in `designcraft-doc`)
 //! - `assets/<id>.<ext>`: embedded placed files, byte-for-byte: one per distinct file, and only
-//!   the ones the document references (see [`Document::compact_assets`])
+//!   the ones the document references (see [`Document::compact_assets`]). A save can instead
+//!   leave out the files that are linked to disk ([`Placed::LinksOnly`]): recovery data does,
+//!   so that it is not a copy of the user's Links folder
 //! - `meta.json`: format version and generator
 //! - `preview.png` (optional): a small picture of the first page, for the start screen's recent files
 //!
@@ -66,6 +68,37 @@ pub fn save_with_preview(doc: &Document, preview: Option<&[u8]>) -> Result<Vec<u
     save_with_large_entries_from(doc, preview, LARGE_ENTRY)
 }
 
+/// [`save`], leaving out the placed files that are linked to a file on disk (see
+/// [`Placed::LinksOnly`]): the package holds their paths, and opening it reads them again.
+pub fn save_links_only(doc: &Document) -> Result<Vec<u8>, FormatError> {
+    let mut buf = Cursor::new(Vec::new());
+    write_with(&mut buf, doc, None, Placed::LinksOnly)?;
+    Ok(buf.into_inner())
+}
+
+/// Write the package to `out` instead of returning it in memory: a document with gigabytes of
+/// placed files needs no second copy in RAM (recovery snapshots, large saves).
+pub fn write<W: Write + std::io::Seek>(out: &mut W, doc: &Document, preview: Option<&[u8]>) -> Result<(), FormatError> {
+    write_with(out, doc, preview, Placed::Embed)
+}
+
+/// What a save does with placed files that are linked to a file on disk.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum Placed {
+    /// Every placed file goes into the package, byte for byte: a self-contained document.
+    #[default]
+    Embed,
+    /// Linked files are left out and only their paths stay: for data that is not a copy of the
+    /// user's disk (crash recovery), where re-reading a link is what opening the document does.
+    /// Files pasted into the document are still embedded — nothing else holds them.
+    LinksOnly,
+}
+
+/// [`write`], choosing what happens to linked placed files.
+pub fn write_with<W: Write + std::io::Seek>(out: &mut W, doc: &Document, preview: Option<&[u8]>, placed: Placed) -> Result<(), FormatError> {
+    write_with_large_entries_from(out, doc, preview, placed, LARGE_ENTRY)
+}
+
 /// Asset entries this size or larger are written with zip64 sizes. Zip rejects an entry over
 /// 4 GiB written without them; the margin covers deflate output that is larger than its input.
 const LARGE_ENTRY: usize = (u32::MAX as usize).saturating_sub(64 << 20);
@@ -86,6 +119,18 @@ fn required_version(doc: &Document) -> u32 {
 }
 
 fn save_with_large_entries_from(doc: &Document, preview: Option<&[u8]>, large_entry: usize) -> Result<Vec<u8>, FormatError> {
+    let mut buf = Cursor::new(Vec::new());
+    write_with_large_entries_from(&mut buf, doc, preview, Placed::Embed, large_entry)?;
+    Ok(buf.into_inner())
+}
+
+fn write_with_large_entries_from<W: Write + std::io::Seek>(
+    out: &mut W,
+    doc: &Document,
+    preview: Option<&[u8]>,
+    placed: Placed,
+    large_entry: usize,
+) -> Result<(), FormatError> {
     // JSON writes infinity and NaN as null, which no load accepts: the file would not open.
     if let Some(id) = doc.non_finite_item() {
         return Err(FormatError::Unsaveable(format!("object {} has a position or size that is not a finite number", id.0)));
@@ -94,9 +139,8 @@ fn save_with_large_entries_from(doc: &Document, preview: Option<&[u8]>, large_en
     compact.compact_assets();
     let doc = &compact;
     let io = |e: zip::result::ZipError| FormatError::Io(e.to_string());
-    let mut buf = Cursor::new(Vec::new());
     {
-        let mut z = zip::ZipWriter::new(&mut buf);
+        let mut z = zip::ZipWriter::new(out);
         let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
         let deflate = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
         z.start_file("mimetype", stored).map_err(io)?;
@@ -113,7 +157,7 @@ fn save_with_large_entries_from(doc: &Document, preview: Option<&[u8]>, large_en
         let json = serde_json::to_vec_pretty(doc).map_err(|e| FormatError::Io(e.to_string()))?;
         z.write_all(&json).map_err(|e| FormatError::Io(e.to_string()))?;
         for (id, a) in &doc.assets {
-            if a.data.is_empty() {
+            if a.data.is_empty() || (placed == Placed::LinksOnly && a.link.is_some()) {
                 continue;
             }
             // Already-compressed images are stored as-is.
@@ -124,7 +168,7 @@ fn save_with_large_entries_from(doc: &Document, preview: Option<&[u8]>, large_en
         }
         z.finish().map_err(io)?;
     }
-    Ok(buf.into_inner())
+    Ok(())
 }
 
 /// The file name extension of a DesignCraft document.
diff --git a/crates/format/src/tests.rs b/crates/format/src/tests.rs
--- a/crates/format/src/tests.rs
+++ b/crates/format/src/tests.rs
@@ -498,6 +498,24 @@ fn saved_tab_lists_load_sanitized() {
     assert_eq!(tabs.last().map(|t| t.position), Some(3960.0), "the first stops by position");
 }
 
+#[test]
+fn writing_to_a_stream_makes_the_same_file_as_saving_in_memory() {
+    // A save that streams needs no copy of the archive in RAM; it must write the same package.
+    let mut d = Document::new(&NewDocument::default());
+    let a = png_asset(&mut d, b"streamed bytes");
+    place(&mut d, a, 0.0);
+    let png = b"\x89PNG streamed".to_vec();
+    let saved = save_with_preview(&d, Some(&png)).unwrap();
+    let mut out = Cursor::new(Vec::new());
+    write(&mut out, &d, Some(&png)).unwrap();
+    let streamed = out.into_inner();
+    assert_eq!(saved.len(), streamed.len());
+    assert_eq!(asset_entries(&streamed), 1);
+    let back = load(&streamed).unwrap();
+    assert_eq!(back.assets.len(), 1);
+    assert_eq!(read_preview(Cursor::new(&streamed)), Some(png));
+}
+
 #[test]
 fn the_preview_is_read_back_without_the_document() {
     let d = Document::new(&NewDocument::default());
diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs
--- a/crates/ui-egui/src/app.rs
+++ b/crates/ui-egui/src/app.rs
@@ -92,6 +92,7 @@ impl DesignApp {
             shortcut_answers: Default::default(),
             last_time: now,
             last_recovery: now,
+            recovery_writing: false,
             egui_ctx: None,
             in_control: false,
             object_clip_text: None,
diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs
--- a/crates/ui-egui/src/app/frame.rs
+++ b/crates/ui-egui/src/app/frame.rs
@@ -77,14 +77,28 @@ impl DesignApp {
     /// uses the monotonic clock, so a wall-clock/NTP jump cannot postpone or advance it.
     pub(super) fn ui_frame_end(&mut self, ctx: &egui::Context) {
         let now = now_ms() / 1000.0;
-        // Crash recovery: unsaved documents are written to the recovery folder every 30 s.
+        // Crash recovery: unsaved documents are written to the recovery folder every 30 s, on a
+        // background thread — writing a catalogue with gigabytes of placed files took minutes, and
+        // on the interface thread the window stopped answering for all of it.
         #[cfg(not(target_arch = "wasm32"))]
-        if self.session.recovery_dir.is_some() && now - self.last_recovery > (self.session.prefs.recovery_minutes * 60.0).max(5.0) {
-            self.last_recovery = now;
-            if self.session.documents().iter().any(|d| d.is_dirty())
-                && let Err(e) = self.session.execute("file.recovery.save", &json!({}))
-            {
-                self.status(crate::i18n::tr(&self.ui.language, "Couldn't write recovery data: {error}").replace("{error}", &e.to_string()));
+        {
+            if self.session.recovery_dir.is_some() && now - self.last_recovery > (self.session.prefs.recovery_minutes * 60.0).max(5.0) {
+                self.last_recovery = now;
+                if self.session.documents().iter().any(|d| d.is_dirty()) {
+                    match self.session.execute("file.recovery.save", &json!({"background": true})) {
+                        Ok(v) if v["queued"].as_u64().unwrap_or(0) > 0 => self.recovery_writing = true,
+                        Ok(_) => {}
+                        Err(e) => self
+                            .status(crate::i18n::tr(&self.ui.language, "Couldn't write recovery data: {error}").replace("{error}", &e.to_string())),
+                    }
+                }
+            }
+            if self.recovery_writing {
+                self.poll_recovery();
+                // Keep the frames coming while the writer works; it has no interface to ask.
+                if self.recovery_writing {
+                    ctx.request_repaint_after(std::time::Duration::from_millis(150));
+                }
             }
         }
         let dt = now - self.last_time;
@@ -95,6 +109,18 @@ impl DesignApp {
         self.issue_screenshots(ctx);
     }
 
+    /// Take what the background recovery writer finished: its checkpoints, and what failed.
+    #[cfg(not(target_arch = "wasm32"))]
+    fn poll_recovery(&mut self) {
+        let Ok(v) = self.session.execute("file.recovery.progress", &json!({})) else { return };
+        self.recovery_writing = v["busy"].as_bool().unwrap_or(false);
+        let errors: Vec<String> = v["errors"].as_array().into_iter().flatten().filter_map(|e| e.as_str().map(str::to_string)).collect();
+        for e in errors {
+            let message = crate::i18n::tr(&self.ui.language, "Couldn't write recovery data: {error}").replace("{error}", &e);
+            self.status(message);
+        }
+    }
+
     /// The document area: the start screen, or the document tabs over one or two canvas panes.
     pub(crate) fn canvas_area(&mut self, ui: &mut egui::Ui, presenting: bool) {
         let t = theme::Tokens::get(ui.ctx());
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
@@ -218,6 +218,9 @@ pub struct DesignApp {
     last_time: f64,
     /// When recovery data was last written (seconds on the monotonic clock; see [`now_ms`]).
     pub last_recovery: f64,
+    /// Recovery data is being written in the background: poll for it, and keep repainting.
+    #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
+    pub(crate) recovery_writing: bool,
     /// The egui context (set by the first UI pass): copied text goes to the system clipboard through it.
     egui_ctx: Option<egui::Context>,
     /// A control-channel request is being handled: commands it runs never read or write the
diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs
--- a/crates/ui-egui/src/panels/pages.rs
+++ b/crates/ui-egui/src/panels/pages.rs
@@ -1,9 +1,13 @@
 //! Pages panel: parents at the top, document spreads with live thumbnails below.
 
 use std::collections::HashMap;
+#[cfg(not(target_arch = "wasm32"))]
+use std::collections::HashSet;
 use std::sync::Mutex;
+use std::sync::atomic::{AtomicU64, Ordering};
 
-use designcraft_doc::{SpreadId, SpreadRef};
+use designcraft_doc::{Document, SpreadId, SpreadRef};
+use designcraft_render::{RenderOptions, Rendered};
 use egui::{Color32, Sense, Stroke, StrokeKind, vec2};
 use serde_json::json;
 
@@ -20,50 +24,237 @@ enum ThumbOf {
 /// (document uid, revision, page or parent, thumbnail height).
 type ThumbKey = (u64, u64, ThumbOf, u32);
 
-static THUMBS: Mutex<Option<HashMap<ThumbKey, egui::TextureHandle>>> = Mutex::new(None);
+/// A thumbnail texture. `None` records a spread that can't be drawn, so it isn't retried every
+/// frame.
+struct Entry {
+    stamp: u64,
+    tex: Option<egui::TextureHandle>,
+}
+
+static THUMBS: Mutex<Option<HashMap<ThumbKey, Entry>>> = Mutex::new(None);
+/// Thumbnails the background worker is drawing ([`begin`] reserves a slot).
+#[cfg(not(target_arch = "wasm32"))]
+static PENDING: Mutex<Option<HashSet<ThumbKey>>> = Mutex::new(None);
+static CLOCK: AtomicU64 = AtomicU64::new(0);
+
+/// Thumbnails kept: older revisions of a page are replaced, so this bounds documents, not edits.
+const MAX_THUMBS: usize = 512;
+/// Thumbnails drawn at once; the rest wait for later frames, so scrolling never queues a whole
+/// document.
+#[cfg(not(target_arch = "wasm32"))]
+const MAX_IN_FLIGHT: usize = 4;
 
 /// Widest parent thumbnail, in pixels (a spread of many pages stays a small texture).
 const MAX_THUMB_W: f64 = 1024.0;
 
-fn thumb(app: &mut DesignApp, ctx: &egui::Context, of: ThumbOf, h: f32) -> Option<egui::TextureHandle> {
-    let st = app.session.active()?;
-    let key = (st.uid, st.revision, of, h.round() as u32);
-    let mut g = THUMBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
-    let map = g.get_or_insert_with(HashMap::new);
-    if let Some(t) = map.get(&key) {
-        return Some(t.clone());
-    }
-    let px = h as f64 * ctx.pixels_per_point() as f64;
-    let mut r = designcraft_render::Renderer::new();
-    r.threads = 0;
-    let opts = designcraft_render::RenderOptions { printing_only: true, ..Default::default() };
-    let (img, name) = match of {
+/// Thumbnails are drawn on a background thread. The tests and the web build draw them inline, so
+/// a frame stays deterministic (and wasm has no threads); `DESIGNCRAFT_SYNC_RENDER` does the same
+/// in a debugging session, like the canvas.
+#[cfg(not(target_arch = "wasm32"))]
+fn inline() -> bool {
+    cfg!(any(test, target_arch = "wasm32")) || std::env::var_os("DESIGNCRAFT_SYNC_RENDER").is_some()
+}
+
+/// Draw thumbnail `of` at `px` device pixels high, with its texture name.
+fn draw(
+    r: &mut designcraft_render::Renderer,
+    doc: &Document,
+    cache: &designcraft_compose::Cache,
+    of: ThumbOf,
+    px: f64,
+) -> Option<(Rendered, String)> {
+    let opts = RenderOptions { printing_only: true, ..Default::default() };
+    match of {
         ThumbOf::Page(abs) => {
-            let page = st.doc.page(abs)?;
-            let img = r.render_page(&st.doc, &app.session.cache, abs, px / page.height.max(1e-6), false, &opts)?;
-            (img, format!("thumb{abs}"))
+            let page = doc.page(abs)?;
+            let img = r.render_page(doc, cache, abs, px / page.height.max(1e-6), false, &opts)?;
+            Some((img, format!("thumb{abs}")))
         }
         ThumbOf::Parent(id) => {
-            let i = st.doc.parent_index(id)?;
-            let b = st.doc.parents.get(i)?.bounds();
+            let i = doc.parent_index(id)?;
+            let b = doc.parents.get(i)?.bounds();
             if !(b.width() > 0.0 && b.height() > 0.0) {
                 return None;
             }
             let scale = (px / b.height()).min(MAX_THUMB_W / b.width());
             let (w, h) = ((b.width() * scale).round().max(1.0) as u32, (b.height() * scale).round().max(1.0) as u32);
             let view = designcraft_geom::Affine::scale(scale) * designcraft_geom::Affine::translate(-b.origin().to_vec2());
-            let o = designcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..opts };
+            let o = RenderOptions { background: Some([255, 255, 255, 255]), ..opts };
             let placed = [designcraft_render::Placed { spread: SpreadRef::Parent(i), xf: designcraft_geom::Affine::IDENTITY }];
-            (r.render(&st.doc, &app.session.cache, &placed, w, h, view, &o), format!("thumbp{}", id.0))
+            Some((r.render(doc, cache, &placed, w, h, view, &o), format!("thumbp{}", id.0)))
         }
-    };
-    let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
-    let tex = crate::widgets::load_texture(ctx, format!("{name}_{}", h.round()), ci, egui::TextureOptions::LINEAR);
-    // Drop thumbnails of older revisions.
+    }
+}
+
+thread_local! {
+    /// Drawing inline reuses one renderer (its glyph and image caches make the next thumbnail
+    /// cheap) rather than building one per thumbnail.
+    static INLINE_RENDERER: std::cell::RefCell<Option<designcraft_render::Renderer>> = const { std::cell::RefCell::new(None) };
+}
+
+fn draw_inline(doc: &Document, cache: &designcraft_compose::Cache, of: ThumbOf, px: f64) -> Option<(Rendered, String)> {
+    INLINE_RENDERER.with(|cell| {
+        let mut g = cell.borrow_mut();
+        let r = g.get_or_insert_with(|| {
+            let mut r = designcraft_render::Renderer::new();
+            r.threads = 0;
+            r
+        });
+        draw(r, doc, cache, of, px)
+    })
+}
+
+/// Upload a finished thumbnail under `key` (or remember that it can't be drawn).
+fn store(ctx: &egui::Context, key: ThumbKey, drawn: Option<(Rendered, String)>) {
+    let tex = drawn.map(|(img, name)| {
+        let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
+        crate::widgets::load_texture(ctx, format!("{name}_{}", key.3), ci, egui::TextureOptions::LINEAR)
+    });
+    let stamp = CLOCK.fetch_add(1, Ordering::Relaxed);
+    let mut g = THUMBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
+    let map = g.get_or_insert_with(HashMap::new);
+    // One thumbnail per page and size: the older revision has done its job.
+    map.retain(|k, _| !(k.0 == key.0 && k.2 == key.2 && k.3 == key.3 && k.1 != key.1));
+    map.insert(key, Entry { stamp, tex });
+    if map.len() > MAX_THUMBS {
+        let mut oldest: Vec<(u64, ThumbKey)> = map.iter().map(|(k, e)| (e.stamp, *k)).collect();
+        oldest.sort_unstable_by_key(|(stamp, _)| *stamp);
+        let over = map.len() - MAX_THUMBS;
+        for (_, k) in oldest.into_iter().take(over) {
+            map.remove(&k);
+        }
+    }
+}
+
+/// The thumbnail for exactly this revision: `Some(None)` is a spread that can't be drawn.
+fn cached(key: ThumbKey) -> Option<Option<egui::TextureHandle>> {
+    let mut g = THUMBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
+    let e = g.as_mut()?.get_mut(&key)?;
+    e.stamp = CLOCK.fetch_add(1, Ordering::Relaxed);
+    Some(e.tex.clone())
+}
+
+/// The last thumbnail drawn for this page and size, from an older revision: shown until the
+/// background worker catches up, as InDesign lags its page thumbnails.
+fn older(uid: u64, of: ThumbOf, h: u32) -> Option<egui::TextureHandle> {
+    let g = THUMBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
+    let map = g.as_ref()?;
+    map.iter().filter(|(k, e)| k.0 == uid && k.2 == of && k.3 == h && e.tex.is_some()).max_by_key(|(k, _)| k.1).and_then(|(_, e)| e.tex.clone())
+}
+
+/// Reserve a background slot for `key`: false when it is already being drawn or the slots are
+/// full (a later frame tries again).
+#[cfg(not(target_arch = "wasm32"))]
+fn begin(key: ThumbKey) -> bool {
+    let mut g = PENDING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
+    let set = g.get_or_insert_with(HashSet::new);
+    set.len() < MAX_IN_FLIGHT && set.insert(key)
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+fn ended(key: ThumbKey) {
+    let mut g = PENDING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
+    if let Some(set) = g.as_mut() {
+        set.remove(&key);
+    }
+}
+
+/// Thumbnail of `of` `h` points high, or `None` until one is ready. `visible` says whether the
+/// row is on screen: a document with hundreds of pages must not draw them all.
+fn thumb(app: &DesignApp, ctx: &egui::Context, of: ThumbOf, h: f32, visible: bool) -> Option<egui::TextureHandle> {
+    if !visible {
+        return None;
+    }
+    let st = app.session.active()?;
     let (uid, rev) = (st.uid, st.revision);
-    map.retain(|k, _| !(k.0 == uid && k.1 != rev));
-    map.insert(key, tex.clone());
-    Some(tex)
+    let key = (uid, rev, of, h.round() as u32);
+    if let Some(tex) = cached(key) {
+        return tex;
+    }
+    let px = (h as f64 * ctx.pixels_per_point() as f64).max(1.0);
+    // Background: the panel keeps showing the previous revision instead of waiting for pixels.
+    #[cfg(not(target_arch = "wasm32"))]
+    if !inline() {
+        if begin(key) {
+            worker::submit(ctx, worker::Job { key, doc: std::sync::Arc::clone(&st.doc), cache: std::sync::Arc::clone(&app.session.cache), of, px });
+        }
+        return older(uid, of, key.3);
+    }
+    store(ctx, key, draw_inline(&st.doc, &app.session.cache, of, px));
+    if let Some(tex) = cached(key) { tex } else { older(uid, of, key.3) }
+}
+
+/// Background thumbnail rendering: one thread with its own renderer, so the Pages panel never
+/// blocks the interface.
+#[cfg(not(target_arch = "wasm32"))]
+mod worker {
+    use std::sync::mpsc::{Receiver, Sender};
+    use std::sync::{Arc, Mutex, OnceLock};
+
+    use super::*;
+
+    pub(super) struct Job {
+        pub key: ThumbKey,
+        pub doc: Arc<Document>,
+        pub cache: Arc<designcraft_compose::Cache>,
+        pub of: ThumbOf,
+        pub px: f64,
+    }
+
+    struct Worker {
+        jobs: Sender<Job>,
+        done: Mutex<Receiver<(ThumbKey, Option<(Rendered, String)>)>>,
+    }
+
+    static WORKER: OnceLock<Option<Worker>> = OnceLock::new();
+
+    fn worker(ctx: &egui::Context) -> Option<&'static Worker> {
+        WORKER
+            .get_or_init(|| {
+                let (jobs, job_rx) = std::sync::mpsc::channel::<Job>();
+                let (result_tx, result_rx) = std::sync::mpsc::channel::<(ThumbKey, Option<(Rendered, String)>)>();
+                let ctx = ctx.clone();
+                let spawned = std::thread::Builder::new().name("designcraft-thumbs".into()).spawn(move || {
+                    let mut r = designcraft_render::Renderer::new();
+                    r.threads = 0;
+                    while let Ok(job) = job_rx.recv() {
+                        if result_tx.send((job.key, draw(&mut r, &job.doc, &job.cache, job.of, job.px))).is_err() {
+                            break;
+                        }
+                        ctx.request_repaint();
+                    }
+                });
+                if spawned.is_err() {
+                    return None;
+                }
+                Some(Worker { jobs, done: Mutex::new(result_rx) })
+            })
+            .as_ref()
+    }
+
+    /// Queue a thumbnail (the caller reserved its slot with [`begin`]).
+    pub(super) fn submit(ctx: &egui::Context, job: Job) {
+        let Some(w) = worker(ctx) else {
+            ended(job.key);
+            return;
+        };
+        if let Err(e) = w.jobs.send(job) {
+            ended(e.0.key);
+        }
+    }
+
+    /// Upload finished thumbnails, on the interface thread.
+    pub(super) fn poll(ctx: &egui::Context) {
+        if WORKER.get().is_none() {
+            return;
+        }
+        let Some(w) = worker(ctx) else { return };
+        let Ok(done) = w.done.lock() else { return };
+        while let Ok((key, drawn)) = done.try_recv() {
+            ended(key);
+            store(ctx, key, drawn);
+        }
+    }
 }
 
 /// Thumbnail sizes of the Pages panel (Panel Options › Size), InDesign's six.
@@ -244,7 +435,7 @@ impl ParentRow {
 /// `thumbnails`), or blank pages for [None] and when the spread can't be rendered.
 fn parent_icon(app: &mut DesignApp, ui: &egui::Ui, right: egui::Pos2, row: &ParentRow, h: f32, thumbnails: bool, t: &Tokens) {
     let area = egui::Rect::from_min_size(egui::pos2(right.x - row.unit_w * h, right.y - h / 2.0), vec2(row.unit_w * h, h));
-    let tex = if thumbnails { row.parent.and_then(|(_, id)| thumb(app, ui.ctx(), ThumbOf::Parent(id), h)) } else { None };
+    let tex = if thumbnails { row.parent.and_then(|(_, id)| thumb(app, ui.ctx(), ThumbOf::Parent(id), h, ui.is_rect_visible(area))) } else { None };
     let p = ui.painter();
     match tex {
         Some(tex) => {
@@ -263,6 +454,11 @@ fn parent_icon(app: &mut DesignApp, ui: &egui::Ui, right: egui::Pos2, row: &Pare
 pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
     let t = Tokens::get(ui.ctx());
     let Some(st) = app.session.active() else { return };
+    // Thumbnails the background thread finished since the last frame.
+    #[cfg(not(target_arch = "wasm32"))]
+    if !inline() {
+        worker::poll(ui.ctx());
+    }
     let doc = st.doc.clone();
     let editing_parents = st.editing_parents;
     let aspect = (doc.settings.page_width / doc.settings.page_height.max(1.0)) as f32;
@@ -455,7 +651,8 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
                             egui::WidgetInfo::selected(egui::Role::Button, true, selected, format!("Page {}", app.session.page_label(abs)))
                         });
                         let tint = if selected { SELECTED_TINT } else { Color32::WHITE };
-                        match thumbnails.then(|| thumb(app, ui.ctx(), ThumbOf::Page(abs), th)).flatten() {
+                        let tex = if thumbnails { thumb(app, ui.ctx(), ThumbOf::Page(abs), th, ui.is_rect_visible(pr)) } else { None };
+                        match tex {
                             Some(tex) => {
                                 ui.painter().image(tex.id(), pr, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), tint);
                             }
diff --git a/crates/ui-egui/src/panels/pages/tests.rs b/crates/ui-egui/src/panels/pages/tests.rs
--- a/crates/ui-egui/src/panels/pages/tests.rs
+++ b/crates/ui-egui/src/panels/pages/tests.rs
@@ -326,6 +326,42 @@ fn parent_thumbnails_follow_panel_options() {
     assert_eq!(parent_thumb_heights(false), Vec::<u32>::new());
 }
 
+/// A long document draws thumbnails for the rows on screen, not for every page: the first frame
+/// of a 190-page catalogue used to render all 190 on the interface thread (a 40-second freeze,
+/// repeated on every edit).
+#[test]
+fn only_visible_pages_get_thumbnails() {
+    let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+    app.run("file.new", json!({"pages": 120, "facingPages": false})).unwrap();
+    app.ui.dock_tab = "pages".into();
+    let h = crate::test_window::open(app, vec2(1400.0, 900.0));
+    let st = h.state().app.session.active().unwrap();
+    let (uid, rev) = (st.uid, st.revision);
+    let thumbs = super::THUMBS.lock().unwrap();
+    let drawn = thumbs.as_ref().map_or(0, |m| m.keys().filter(|k| k.0 == uid && k.1 == rev && matches!(k.2, super::ThumbOf::Page(_))).count());
+    assert!(drawn > 0, "the rows on screen are drawn");
+    assert!(drawn < 40, "120 pages, but only the visible rows are drawn: {drawn}");
+    assert!(
+        !thumbs.as_ref().is_some_and(|m| m.keys().any(|k| k.0 == uid && k.1 == rev && k.2 == super::ThumbOf::Page(119))),
+        "the last page is far below the panel"
+    );
+}
+
+/// Background slots are capped, so a fast scroll across a long document queues a few thumbnails
+/// rather than every page.
+#[test]
+fn background_thumbnails_are_capped() {
+    let keys: Vec<ThumbKey> = (0..10).map(|i| (u64::MAX - 1, 0, ThumbOf::Page(i), 24)).collect();
+    let taken = keys.iter().filter(|k| begin(**k)).count();
+    assert_eq!(taken, super::MAX_IN_FLIGHT);
+    assert!(!begin(keys[0]), "a slot is reserved once");
+    for k in &keys {
+        ended(*k);
+    }
+    assert!(begin(keys[0]), "released slots are free again");
+    ended(keys[0]);
+}
+
 #[test]
 fn the_panel_menu_follows_indesigns_order() {
     let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs
--- a/crates/ui-egui/src/tests.rs
+++ b/crates/ui-egui/src/tests.rs
@@ -141,6 +141,35 @@ fn app_with_unsaved_document() -> DesignApp {
     app
 }
 
+/// The recovery timer wrote the whole document on the interface thread: on a catalogue with
+/// gigabytes of linked files the window stopped answering for minutes after every edit.
+#[test]
+fn the_recovery_timer_writes_off_the_interface_thread() {
+    let ctx = egui::Context::default();
+    let mut app = app_with_unsaved_document();
+    let dir = tempfile::tempdir().unwrap();
+    app.session.recovery_dir = Some(dir.path().to_path_buf());
+    app.last_recovery = -1.0e9;
+    let uid = app.session.documents()[0].uid;
+    let entry = dir.path().join(format!("{uid}.designcraft"));
+    let mut written = false;
+    for _ in 0..600 {
+        shot_frame(&mut app, &ctx, egui::vec2(1400.0, 900.0));
+        // The timer fired and the write finished: the file is there and nothing is still queued.
+        if entry.exists() && !app.recovery_writing {
+            written = true;
+            break;
+        }
+        std::thread::sleep(std::time::Duration::from_millis(5));
+    }
+    assert!(written, "the timer's background write published the document");
+    assert!(app.last_recovery > 0.0, "the timer restarted its interval");
+    // The checkpoint is the document's: saving it removes the recovery entry.
+    let saved = dir.path().join("saved.designcraft");
+    app.run("file.saveAs", json!({"path": saved.to_string_lossy()})).unwrap();
+    assert!(!dir.path().join(format!("{uid}.designcraft")).exists());
+}
+
 /// File ▸ Close (and its shortcut) closed a document with unsaved changes without asking, and
 /// closing discards the document's recovery data too.
 #[test]
diff --git a/docs/architecture.md b/docs/architecture.md
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1,6 +1,6 @@
 # DesignCraft architecture
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (shared PDF writing) · **Target:** Adobe InDesign 2026
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (recovery writes on a background thread, streamed and links-only; Pages-panel thumbnails off the interface thread) · **Target:** Adobe InDesign 2026
 
 How DesignCraft is built today. The design notes behind it are in `plan/architecture.md`
 (local, gitignored); this file describes what the code on `main` does. Agents: AGENTS.md has the
@@ -58,7 +58,9 @@ panels, zoom, dialogs) are `UI_COMMANDS` in `crates/ui-egui/src/menus/ui_command
 Tool gestures emit Begin/Preview/Commit commands so a drag is one undo step. Every journaled
 command is replayable as a script. Escaped panics during a command or an import/export become an
 error (`engine/src/guard.rs`), never a lost document; native recovery is written periodically
-into each running app's locked folder. Orderly native shutdown finalizes complete, current quit
+into each running app's locked folder, on a background thread and straight to disk, and keeps
+placed files that are linked as links instead of copying gigabytes of them every few minutes.
+Orderly native shutdown finalizes complete, current quit
 approvals and writes the remaining documents' latest recovery state, including clean-document
 cleanup after Undo. Unavailable or failed recovery is reported. The desktop opts into a pinned
 winit 0.30 patch that cancels AppKit termination and queues ordinary window close requests,
@@ -110,6 +112,7 @@ builds (on manifest changes), packaging lint and the release workflow; `xtask ci
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-27 | minor | Recovery writes off the interface thread, streamed to disk, keeping linked placed files as links; Pages-panel thumbnails on a background thread for visible rows |
 | 2026-10-12 | minor | Documented cancelable Dock Quit through the native event-loop boundary |
 | 2026-10-12 | minor | Documented orderly native exit recovery and the remaining cancelable Dock Quit gap |
 | 2026-10-10 | major | Created from the code on main (92649ff) and `plan/architecture.md` |
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -1,6 +1,6 @@
 # File format parity
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (PDF/X-4 visible unembedded font detection, #909; IDML liquid guide types, #872; .indd and .indt open through indd-utils; XLSX cached values preserve numeric precision and text; shared print spooler; Windows CI passes; content-indexed asset deduplication, #727) · **Target:** Adobe InDesign 2026 (21.6)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (a save can leave linked placed files out of the package and keep their paths, which recovery data does: `Placed::LinksOnly` · PDF/X-4 visible unembedded font detection, #909; IDML liquid guide types, #872; .indd and .indt open through indd-utils; XLSX cached values preserve numeric precision and text; shared print spooler; Windows CI passes; content-indexed asset deduplication, #727) · **Target:** Adobe InDesign 2026 (21.6)
 
 Every format InDesign 2026 reads or writes, with DesignCraft's support, fidelity and tests. The
 InDesign list comes from its bundle's `Info.plist` (`CFBundleDocumentTypes`,
@@ -122,6 +122,7 @@ up to 30 points.
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-27 | minor | A `.designcraft` save can leave linked placed files out (`designcraft_format::Placed::LinksOnly`) and keep their paths; automatic recovery data does, and re-reads the links when recovering, so it is not a copy of the user's Links folder |
 | 2026-10-19 | minor | PDF/X-4 export and `validate-pdf` report visible unembedded fonts by name and page, including placed forms; invisible text is exempt, operation- or depth-limited checks report incompleteness, and automatic font repair remains missing (#909) |
 | 2026-10-15 | minor | IDML retains liquid versus ruler guide types on document and parent pages, with literal XML and mixed-guide round-trip coverage; object liquid constraints remain unsupported (#872) |
 | 2026-10-13 | minor | Asset loading and compaction use content fingerprints with exact equality checks; tests cover equal-sized distinct buffers, shared copies, independent links and missing data (#727) |
diff --git a/docs/gaps.md b/docs/gaps.md
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -1,6 +1,6 @@
 # Where DesignCraft falls short of InDesign
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (placed graphic layer-option preservation and explicit rendering warnings; gap 16 shrunk: File › File Info; gap 16: Convert Variable to Text; gap 28 added: placed-PDF font repair remains after visible unembedded font detection, #909; gap 26 shrunk: autoflow fills the following pages before it adds pages at the end; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (gap 20: Pages-panel thumbnails and automatic recovery moved off the interface thread; recovery data keeps links as links; what remains is reading links at open and the native save of a huge catalogue · placed graphic layer-option preservation and explicit rendering warnings; gap 16 shrunk: File › File Info; gap 16: Convert Variable to Text; gap 28 added: placed-PDF font repair remains after visible unembedded font detection, #909; gap 26 shrunk: autoflow fills the following pages before it adds pages at the end; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6)
 
 Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an
 InDesign professional from doing real work. This is the work list: agents pick from the top.
@@ -229,9 +229,16 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
 
 ### 20. Performance on large documents (P)
 - **Missing:** incremental composition (recompose from the edited paragraph; 8 ms keystroke
-  budget), benchmarks against InDesign on long books and image-heavy catalogues.
+  budget), benchmarks against InDesign on long books and image-heavy catalogues; opening a
+  catalogue with gigabytes of linked files still reads every link before the document shows
+  (44 s / ~6 GB RSS on a 190-page, 71-link, 4.2 GB catalogue); saving the same document
+  natively takes 112 s, and the archive it writes (3.6 GB) exceeds the loader's per-asset cap.
 - **Have:** compose 300k characters ~38 ms warm, damage-region repaint (< 1 ms while typing),
-  parallel story composition.
+  parallel story composition. Pages-panel thumbnails are rendered on a background thread for
+  the rows on screen only (that catalogue's Pages tab: 43 s → 0.2 s, no freeze after an edit).
+  Automatic recovery writes on a background thread, streams to disk and keeps linked placed
+  files as links, so an autosave of that catalogue is ~1 MB instead of 3.6 GB written on the
+  interface thread.
 - **Estimate:** 40–80 h.
 
 ### 26. Autoflow flows into the primary text frame of the pages that follow (F)
