diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -66,7 +66,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
 - Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`.
 - Missing glyphs, as in InDesign: a character the applied font lacks is drawn as that font's missing-glyph box (its .notdef; a drawn box when the font's is empty), on screen and in PDF (as a path, so PDF/A and PDF/UA exports take it), and Preflight lists the characters by font. Preferences › Composition › Draw Missing Glyphs from Fallback Fonts (`document.preferences {glyphFallback}`, stored with the document) draws them from other fonts instead; it is off in new documents and IDML imports, on in documents saved before it existed. A missing font's substitute always uses fallback fonts.
 - Damage-region repaint: after an edit the canvas re-renders only the frames whose composed text (or the items that) changed and patches its texture — pixel-identical to a full render; typing in a frame repaints in under a millisecond instead of a full-viewport render (22 ms measured on the sample).
-- Crash recovery: unsaved documents are written to the recovery folder every 30 s and reopened (unsaved, remembering their files) after a crash; File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`.
+- Crash recovery: unsaved documents changed since their last checkpoint are written to the recovery folder every 30 s and reopened (unsaved, remembering their files) after a crash; File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`.
 - Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present.
 - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).
 - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.
@@ -378,6 +378,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
 
 Newest first.
 
+- **2026-10-25** · Crash recovery writes only the documents that changed since their last checkpoint: an unsaved document left alone was zipped and written again every 30 s, about 7 GB an hour for one with a 58 MB image (each time also freezing the window for 70–160 ms on an M2 Max). An entry removed from the recovery folder is written again at the next tick, and a recovered document is not written again until it changes. The timer now asks for its own frame when changes are due, so the last edits are written even while nothing else draws (the window in the background); it no longer waits for a caret blink or a pointer move. `file.recovery.save` writes every unsaved document unless given `changedOnly`. No readiness percentage change.
 - **2026-10-24** · Story Options rejects malformed or nonexistent explicit story/frame IDs without changing the selected story, including an invalid frame alongside a valid story. Null targets remain absent; omitted targets still use the selection. No readiness percentage change.
 - **2026-10-24** · Text Frame Options explains that a custom baseline-grid increment must be a measure greater than zero when zero, a negative value or a non-measure is entered. Validation is unchanged. No readiness percentage change.
 - **2026-10-24** · Renaming a paragraph or character style, or moving it into a style group, keeps its Export Tagging under the new name, and undo puts it back under the old one. Deleting a tagged style drops its tagging and leaves the replacement style's own tagging as it was. No readiness percentage change.
diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs
--- a/crates/engine/src/cmd/file.rs
+++ b/crates/engine/src/cmd/file.rs
@@ -69,9 +69,10 @@ 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?, changedOnly?} — write every unsaved document to the recovery folder (the app does this on a timer, with changedOnly: documents still as their checkpoint there left them are not written again)", always, |s, p| {
             let dir = recovery_dir(s, p, FolderAccess::Write)?;
-            Ok(json!({"saved": crate::recovery::save_tracked(s, &dir)?}))
+            let changed_only = p.get("changedOnly").and_then(Value::as_bool).unwrap_or(false);
+            Ok(json!({"saved": crate::recovery::save_tracked(s, &dir, changed_only)?}))
         }),
         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.
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -104,6 +104,10 @@ pub struct DocState {
     /// Recovery entries this state actually opened or published, for repeated-open and cleanup.
     /// Bounded, deduplicated checkpoint addresses; never inferred from uid alone.
     pub(crate) recovery_entries: Vec<(std::path::PathBuf, u64)>,
+    /// The recovery checkpoint written last: its folder, the document as written and the path
+    /// its metadata names. The app's timer leaves the entry alone while the document is still
+    /// that very `Arc` (an edit replaces it) and its path is unchanged.
+    pub(crate) last_checkpoint: Option<(std::path::PathBuf, Arc<Document>, Option<String>)>,
     /// Unfilled document while data-merge preview is showing a record. Not saved.
     pub preview_stash: Option<Arc<Document>>,
     /// 1-based record currently previewed.
@@ -158,6 +162,7 @@ impl DocState {
             editing_parents: false,
             uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
             recovery_entries: Vec::new(),
+            last_checkpoint: None,
             preview_stash: None,
             preview_record: None,
             fonts: None,
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
@@ -23,7 +23,9 @@
 #[cfg(not(target_arch = "wasm32"))]
 use std::io::Write;
 use std::path::{Path, PathBuf};
+use std::sync::Arc;
 
+use designcraft_doc::Document;
 use serde_json::{Value, json};
 
 use crate::{DocState, EngineError, Result, Session};
@@ -466,6 +468,7 @@ pub(crate) fn discard_document(s: &mut Session, index: usize, default_dir: Optio
     // A completed Save/Close/Revert consumes the provenance, even when removal fails. A stale
     // source uid must never become a future claim on another document's checkpoint.
     let mut entries = std::mem::take(&mut doc.recovery_entries);
+    doc.last_checkpoint = None;
     if let Some(dir) = default_dir
         && !entries.iter().any(|(entry_dir, entry_uid)| *entry_uid == uid && same_directory(entry_dir, dir))
     {
@@ -536,7 +539,9 @@ 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> {
+/// With `changed_only` (the app's timer), a document that is still the snapshot its entry in
+/// `dir` holds is left as it is.
+pub(crate) fn save_tracked(s: &mut Session, dir: &Path, changed_only: bool) -> 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 {
@@ -558,9 +563,13 @@ pub(crate) fn save_tracked(s: &mut Session, dir: &Path) -> Result<usize> {
             continue;
         }
         let Some(document) = s.docs.get_mut(index) else { continue };
+        // Data merge's preview is session state: the unfilled template is what is kept.
+        let snapshot = document.preview_stash.clone().unwrap_or_else(|| document.doc.clone());
+        if changed_only && is_current(document, &snapshot, dir) {
+            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()))?;
+        let bytes = designcraft_format::save(&snapshot).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)?;
@@ -571,11 +580,37 @@ pub(crate) fn save_tracked(s: &mut Session, dir: &Path) -> Result<usize> {
         }
         let metadata = json!({"path": document.path, "title": document.title(), "saved": designcraft_doc::vars::now()});
         publish_recovery_file(&meta, metadata.to_string().as_bytes(), replace)?;
+        document.last_checkpoint = Some((dir.to_path_buf(), snapshot, document.path.clone()));
         n += 1;
     }
     Ok(n)
 }
 
+/// Whether the checkpoint of `document` written last is `doc` (that very allocation, held, so
+/// never changed or reused meanwhile) under the same path, in a folder `same` accepts.
+fn checkpointed(document: &DocState, doc: &Arc<Document>, same: impl Fn(&Path) -> bool) -> bool {
+    document
+        .last_checkpoint
+        .as_ref()
+        .is_some_and(|(written_dir, written, path)| Arc::ptr_eq(written, doc) && *path == document.path && same(written_dir))
+}
+
+/// Whether `document`'s entry in `dir` already holds `doc`, and is still there.
+fn is_current(document: &DocState, doc: &Arc<Document>, dir: &Path) -> bool {
+    checkpointed(document, doc, |written| same_directory(written, dir))
+        && std::fs::symlink_metadata(files(dir, document.uid).0).is_ok_and(|m| m.file_type().is_file())
+}
+
+/// Whether an unsaved document has changes that its checkpoint in `dir` lacks. In memory only,
+/// cheap enough for every frame: the app's timer asks for a frame when they are due, so they are
+/// written even while nothing else draws (the window in the background).
+pub fn has_changes_to_checkpoint(s: &Session, dir: &Path) -> bool {
+    let preview = s.new_document_preview();
+    s.docs
+        .iter()
+        .any(|d| d.is_dirty() && preview != Some(d.uid) && !checkpointed(d, d.preview_stash.as_ref().unwrap_or(&d.doc), |written| written == dir))
+}
+
 /// 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);
@@ -846,6 +881,8 @@ fn open_with_publisher(s: &mut Session, dir: &Path, mut publish: impl FnMut(&Pat
                         Ok(()) => {
                             discard(dir, uid);
                             state.recovery_entries = vec![(destination.clone(), state.uid)];
+                            // Just written: the timer leaves it until it changes.
+                            state.last_checkpoint = Some((destination.clone(), state.doc.clone(), state.path.clone()));
                         }
                         Err(e) => log::warn!("recovery entry {uid} kept: {e}"),
                     }
@@ -1692,6 +1729,73 @@ mod tests {
         assert!(save(&dirty_session(), root.path()).is_err());
         assert!(list(root.path()).is_empty());
     }
+
+    /// The timer's checkpoint: changed documents only.
+    fn timer_checkpoint(s: &mut Session, dir: &Path) -> usize {
+        let saved = s.execute("file.recovery.save", &json!({"dir": dir.to_string_lossy(), "changedOnly": true})).unwrap()["saved"].clone();
+        usize::try_from(saved.as_u64().unwrap()).unwrap()
+    }
+
+    fn type_text(s: &mut Session, text: &str) {
+        s.execute("frame.create", &json!({"rect": [10, 120, 100, 200], "content": "text", "text": text})).unwrap();
+    }
+
+    /// A 62 MB document was zipped and written again every 30 s although nothing had changed.
+    #[test]
+    fn the_timer_leaves_an_unchanged_documents_checkpoint_alone() {
+        let dir = tempfile::tempdir().unwrap();
+        let mut s = unsaved("first");
+        s.recovery_dir = Some(dir.path().to_path_buf());
+        assert!(has_changes_to_checkpoint(&s, dir.path()));
+        assert_eq!(timer_checkpoint(&mut s, dir.path()), 1);
+        let (doc, meta) = files(dir.path(), s.docs[0].uid);
+        let written = (std::fs::read(&doc).unwrap(), std::fs::read(&meta).unwrap());
+        // Nothing changed: nothing is written, not even the metadata's time.
+        assert!(!has_changes_to_checkpoint(&s, dir.path()));
+        assert_eq!(timer_checkpoint(&mut s, dir.path()), 0);
+        assert_eq!((std::fs::read(&doc).unwrap(), std::fs::read(&meta).unwrap()), written);
+        // An edit is written at the next tick.
+        type_text(&mut s, "second");
+        assert!(has_changes_to_checkpoint(&s, dir.path()));
+        assert_eq!(timer_checkpoint(&mut s, dir.path()), 1);
+        assert_eq!(persisted_texts(dir.path()), ["first", "second"]);
+        // Undo back to the state written last: still current.
+        type_text(&mut s, "undone");
+        s.execute("edit.undo", &json!({})).unwrap();
+        assert_eq!(timer_checkpoint(&mut s, dir.path()), 0);
+        // An entry removed behind the app's back is written again.
+        std::fs::remove_file(&doc).unwrap();
+        assert_eq!(timer_checkpoint(&mut s, dir.path()), 1);
+        assert_eq!(persisted_texts(dir.path()), ["first", "second"]);
+        // Another folder has no checkpoint of it yet.
+        let other = tempfile::tempdir().unwrap();
+        assert_eq!(timer_checkpoint(&mut s, other.path()), 1);
+        // Without changedOnly the command keeps its contract: every unsaved document.
+        assert_eq!(s.execute("file.recovery.save", &json!({})).unwrap()["saved"], 1);
+        // Saved, the entry goes; edited again, it comes back.
+        s.execute("file.saveAs", &json!({"path": dir.path().join("saved.designcraft").to_string_lossy()})).unwrap();
+        assert!(list(dir.path()).is_empty());
+        assert!(!has_changes_to_checkpoint(&s, dir.path()));
+        type_text(&mut s, "third");
+        assert_eq!(timer_checkpoint(&mut s, dir.path()), 1);
+        assert_eq!(persisted_texts(dir.path()), ["first", "second", "third"]);
+    }
+
+    /// A recovered document was written again at the first tick although its new entry already
+    /// held it.
+    #[test]
+    fn a_recovered_document_is_not_written_again_until_it_changes() {
+        let dir = tempfile::tempdir().unwrap();
+        assert_eq!(save(&unsaved("recovered"), dir.path()).unwrap(), 1);
+        let mut s = Session::new();
+        s.recovery_dir = Some(dir.path().to_path_buf());
+        assert_eq!(open(&mut s, dir.path()).unwrap().len(), 1);
+        assert!(!has_changes_to_checkpoint(&s, dir.path()));
+        assert_eq!(timer_checkpoint(&mut s, dir.path()), 0);
+        type_text(&mut s, "edited after recovery");
+        assert_eq!(timer_checkpoint(&mut s, dir.path()), 1);
+        assert_eq!(persisted_texts(dir.path()), ["edited after recovery", "recovered"]);
+    }
 }
 
 #[cfg(test)]
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
@@ -783,6 +783,31 @@ impl DesignApp {
         self.ui.status = s.into();
     }
 
+    /// Crash recovery: every 30 s the unsaved documents changed since their last checkpoint are
+    /// written to the recovery folder; one still as its checkpoint left it is not written again.
+    /// Changes not in a checkpoint yet ask for the frame that writes them when they are due, so
+    /// they are written even while nothing else draws (the timer used to wait for some other frame:
+    /// the caret's blink, a pointer move), and nothing asks for frames once every checkpoint is
+    /// current.
+    #[cfg(not(target_arch = "wasm32"))]
+    fn recovery_timer(&mut self, ctx: &egui::Context, now: f64) {
+        let Some(dir) = self.session.recovery_dir.clone() else { return };
+        let interval = (self.session.prefs.recovery_minutes * 60.0).max(5.0);
+        let since = now - self.last_recovery;
+        if since <= interval {
+            if designcraft_engine::recovery::has_changes_to_checkpoint(&self.session, &dir) {
+                ctx.request_repaint_after_secs((interval - since) as f32 + 0.1);
+            }
+            return;
+        }
+        self.last_recovery = now;
+        if self.session.documents().iter().any(|d| d.is_dirty())
+            && let Err(e) = self.session.execute("file.recovery.save", &json!({"changedOnly": true}))
+        {
+            self.status(crate::i18n::tr(&self.ui.language, "Couldn't write recovery data: {error}").replace("{error}", &e.to_string()));
+        }
+    }
+
     /// The host's save dialog for `name`, started beside the active document once it has been
     /// saved, else in the folder of the last file saved or exported (#1653). The answer's folder
     /// is remembered for the next one.
@@ -1210,16 +1235,8 @@ impl DesignApp {
             self.restyle = false;
         }
         let now = ctx.input(|i| i.time);
-        // Crash recovery: unsaved documents are written to the recovery folder every 30 s.
         #[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()));
-            }
-        }
+        self.recovery_timer(ctx, now);
         let dt = now - self.last_time;
         if dt > 0.0 {
             self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1;
@@ -1838,6 +1855,45 @@ pub fn drop_key_name_text(raw: &mut egui::RawInput, text_input_focused: bool) {
 mod tests {
     use super::*;
 
+    /// The recovery timer wrote every unsaved document every 30 s, changed or not, and ran only
+    /// when some other frame did. Changes ask for the frame that writes them; a document still as
+    /// its checkpoint left it is not written again, and asks for no frame.
+    #[cfg(not(target_arch = "wasm32"))]
+    #[test]
+    fn the_recovery_timer_writes_only_changes_and_wakes_only_for_them() {
+        let dir = tempfile::tempdir().unwrap();
+        let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default());
+        app.run("file.new", json!({})).unwrap();
+        app.run("frame.create", json!({"rect": [20, 20, 120, 120], "content": "text", "text": "edited"})).unwrap();
+        app.session.recovery_dir = Some(dir.path().to_path_buf());
+        let entry = dir.path().join(format!("{}.designcraft", app.session.active().unwrap().uid));
+        let ctx = egui::Context::default();
+        let tick = |app: &mut DesignApp, now: f64| {
+            let raw = egui::RawInput { time: Some(now), ..Default::default() };
+            let mut out = ctx.run_ui(raw, |ui| app.recovery_timer(ui.ctx(), now));
+            out.textures_delta.clear();
+            out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.repaint_delay)
+        };
+        let secs = |d: Option<std::time::Duration>| d.map(|d| d.as_secs_f64());
+        // A new context asks for frames of its own at first.
+        tick(&mut app, 9.0);
+        tick(&mut app, 9.5);
+        // 10 s after the last tick, 30 s apart: the frame is due in 20 s.
+        assert!(secs(tick(&mut app, 10.0)).is_some_and(|s| (20.0..21.0).contains(&s)));
+        // Due: written. Nothing changed since: no frame is asked for.
+        tick(&mut app, 30.5);
+        let written = std::fs::read(&entry).unwrap();
+        assert_eq!(tick(&mut app, 31.0), Some(std::time::Duration::MAX));
+        // Due again with nothing changed: not written again.
+        tick(&mut app, 61.0);
+        assert_eq!(std::fs::read(&entry).unwrap(), written);
+        // Edited: a frame at the next tick, 30 s after the last, which writes it.
+        app.run("frame.create", json!({"rect": [20, 140, 120, 240]})).unwrap();
+        assert!(secs(tick(&mut app, 70.0)).is_some_and(|s| (21.0..21.2).contains(&s)));
+        tick(&mut app, 91.5);
+        assert_ne!(std::fs::read(&entry).unwrap(), written);
+    }
+
     /// The Control panel's two-row groups were centred as if they were one row tall, so the second
     /// row hung below the panel and was cut off.
     #[test]
