diff --git a/apps/wordcraft-web/src/host.rs b/apps/wordcraft-web/src/host.rs
--- a/apps/wordcraft-web/src/host.rs
+++ b/apps/wordcraft-web/src/host.rs
@@ -18,12 +18,23 @@
 //!   - Save As or Export to a format that doesn't (PDF, Markdown, plain text), Export as PDF and
 //!     Export Page as PNG: `false`. They are copies; the document's file stays the same.
 //! - `wordcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
+//! - `wordcraft:changed` (to the parent): `{ revision }` after every native document revision,
+//!   including further edits while `dirty` stays true. Revision is an opaque string that also
+//!   distinguishes replacing the document.
+//! - `wordcraft:snapshot` (from the parent): `{ requestId }`, a nonempty string of at most 128
+//!   UTF-8 bytes. Requests the complete native JSON. Pending comment/reply text is committed as
+//!   for File Save; dirty state is never cleared and storage is not acknowledged.
+//! - `wordcraft:snapshot-result` (to the parent): `{ requestId, revision, bytes }` (a transferred
+//!   `ArrayBuffer` of native JSON), or `{ requestId, error }`. At most one request is pending;
+//!   another receives an error. The native Document parser checks the serialized snapshot.
+//!   Neither snapshot nor changed grants durable Saved. Reopen native bytes with `wordcraft:open`
+//!   and a `.json` name. The host guards unsaved work before replacing a document.
 //! - `wordcraft:failed` (to the parent): `{ error }` when the app couldn't start.
 //!
 //! Only the parent window on the page's own origin is heard or answered. In host mode the parent
 //! guards leaving the page, and `?author=` names the user for comments and tracked changes.
 
-use std::cell::Cell;
+use std::cell::{Cell, RefCell};
 use std::rc::Rc;
 
 use wasm_bindgen::JsCast as _;
@@ -39,6 +50,8 @@ pub struct Bridge {
     window: web_sys::Window,
     parent: web_sys::Window,
     origin: String,
+    snapshot: Rc<RefCell<Option<String>>>,
+    observed: Rc<RefCell<crate::snapshot::RevisionObserver>>,
 }
 
 impl Bridge {
@@ -60,7 +73,7 @@ impl Bridge {
         if parent.location().origin().ok()? != origin {
             return None;
         }
-        Some(Self { window, parent, origin })
+        Some(Self { window, parent, origin, snapshot: Rc::default(), observed: Rc::default() })
     }
 
     /// Hand saved and exported files to the parent instead of downloading them, report unsaved
@@ -119,11 +132,27 @@ impl Bridge {
     fn listen(&self, inbox: Inbox, ctx: egui::Context) {
         let parent = self.parent.clone();
         let origin = self.origin.clone();
+        let snapshot = self.snapshot.clone();
+        let bridge = self.clone();
         let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
             if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
                 return;
             }
             let data = event.data();
+            if string(&data, "type").as_deref() == Some("wordcraft:snapshot") {
+                let Some(id) = string(&data, "requestId").filter(|id| !id.is_empty() && id.len() <= 128) else { return };
+                if snapshot.borrow().is_some() {
+                    bridge.send(&[
+                        ("type", "wordcraft:snapshot-result".into()),
+                        ("requestId", id.into()),
+                        ("error", "snapshot already pending".into()),
+                    ]);
+                    return;
+                }
+                *snapshot.borrow_mut() = Some(id);
+                ctx.request_repaint();
+                return;
+            }
             if string(&data, "type").as_deref() != Some("wordcraft:open") {
                 return;
             }
@@ -138,6 +167,38 @@ impl Bridge {
         }
     }
 
+    /// Report native revisions independently of dirty state, then answer a pending native
+    /// snapshot request after the app has processed its inbox and commands. Pending comment/reply
+    /// drafts are committed through the same commands as Save. Neither is a save
+    /// acknowledgement; the host must confirm its own durable storage separately.
+    pub fn observe(&self, app: &mut WordApp) {
+        let pending = self.snapshot.borrow_mut().take();
+        let prepared = if pending.is_some() { app.prepare_native_snapshot() } else { Ok(()) };
+        let session = &app.session;
+        let draft_revision = app.native_draft_revision();
+        let revision = crate::snapshot::revision(session, draft_revision);
+        if self.observed.borrow_mut().changed(session, draft_revision) {
+            self.send(&[("type", "wordcraft:changed".into()), ("revision", revision.as_str().into())]);
+        }
+        let Some(id) = pending else { return };
+        let result = prepared.and_then(|()| crate::snapshot::native_snapshot(session)).and_then(|bytes| {
+            let buffer = js_sys::Uint8Array::from(bytes.as_slice()).buffer();
+            let value = message(&[
+                ("type", "wordcraft:snapshot-result".into()),
+                ("requestId", id.as_str().into()),
+                ("revision", revision.as_str().into()),
+                ("bytes", buffer.clone().into()),
+            ])
+            .ok_or_else(|| "couldn't build the snapshot message".to_string())?;
+            self.parent
+                .post_message_with_transfer(&value, &self.origin, &js_sys::Array::of1(&buffer))
+                .map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
+        });
+        if let Err(error) = result {
+            self.send(&[("type", "wordcraft:snapshot-result".into()), ("requestId", id.as_str().into()), ("error", error.as_str().into())]);
+        }
+    }
+
     /// Post a message to the parent; false if it couldn't be sent.
     fn send(&self, fields: &[(&str, JsValue)]) -> bool {
         message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
diff --git a/apps/wordcraft-web/src/main.rs b/apps/wordcraft-web/src/main.rs
--- a/apps/wordcraft-web/src/main.rs
+++ b/apps/wordcraft-web/src/main.rs
@@ -18,6 +18,9 @@ mod prefs;
 #[cfg(any(target_arch = "wasm32", test))]
 mod save_fallback;
 
+#[cfg(any(target_arch = "wasm32", test))]
+mod snapshot;
+
 #[cfg(target_arch = "wasm32")]
 mod host;
 #[cfg(target_arch = "wasm32")]
diff --git a/apps/wordcraft-web/src/snapshot.rs b/apps/wordcraft-web/src/snapshot.rs
new file mode 100644
--- /dev/null
+++ b/apps/wordcraft-web/src/snapshot.rs
@@ -0,0 +1,106 @@
+//! Native JSON snapshots for a host that stores complete editor versions.
+
+use wordcraft_engine::Session;
+
+/// Serialize only the native model, without changing dirty state, undo or file identity.
+/// Parsing our own output also rejects values (such as a non-finite required float) that
+/// serialize but cannot be opened as a native Document.
+pub fn native_snapshot(session: &Session) -> Result<Vec<u8>, String> {
+    let bytes = wordcraft_engine::io::save_bytes("snapshot.json", &session.doc)?;
+    let _: wordcraft_doc::Document = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
+    Ok(bytes)
+}
+
+/// An opaque token: include document identity, and keep u64 values out of JS numbers.
+pub fn revision(session: &Session, draft_revision: u64) -> String {
+    format!("{}:{}:{}", session.document_id(), session.rev(), draft_revision)
+}
+
+#[derive(Default)]
+pub struct RevisionObserver(Option<(u64, u64, u64)>);
+
+impl RevisionObserver {
+    pub fn changed(&mut self, session: &Session, draft_revision: u64) -> bool {
+        let current = (session.document_id(), session.rev(), draft_revision);
+        if self.0 == Some(current) {
+            return false;
+        }
+        self.0 = Some(current);
+        true
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use wordcraft_doc::{InlineObject, Part, PartKind};
+    use wordcraft_engine::Session;
+
+    #[test]
+    fn native_snapshot_preserves_complete_model_and_does_not_mark_saved() {
+        let mut session = Session::new(wordcraft_engine::sample::sample_document());
+        session.doc.parts.insert(
+            600,
+            Part {
+                kind: PartKind::Comment,
+                blocks: vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Snapshot comment", Default::default()))],
+                ..Default::default()
+            },
+        );
+        session.doc.comments.insert(1, wordcraft_doc::Comment { author: "Tester".into(), part: 600, ..Default::default() });
+        let image = session.doc.add_media(vec![71; 3_200_000], "png");
+        let mut para = wordcraft_doc::Paragraph::with_text("\u{fffc}", Default::default());
+        para.objects.push(InlineObject::Image {
+            media: image,
+            w: 10.,
+            h: 10.,
+            alt: "Large image".into(),
+            float: Default::default(),
+            crop: [0.; 4],
+            ole: None,
+        });
+        session.doc.body.push(wordcraft_doc::para_block(para));
+        session.run("text.insert", &serde_json::json!({"text":"Edited "})).unwrap();
+        let document_id = session.document_id();
+        let revision = session.rev();
+        let undo_depth = session.undo_depth();
+        let before = serde_json::to_value(&session.doc).unwrap();
+        let bytes = native_snapshot(&session).unwrap();
+        assert!(bytes.len() > 4 * 1024 * 1024);
+        let reopened = wordcraft_engine::io::open_bytes("version.json", &bytes).unwrap();
+        assert_eq!(serde_json::to_value(&reopened).unwrap(), before);
+        assert_eq!(serde_json::to_value(&session.doc).unwrap(), before);
+        assert!(session.dirty);
+        assert_eq!(session.document_id(), document_id);
+        assert_eq!(session.rev(), revision);
+        assert_eq!(session.undo_depth(), undo_depth);
+    }
+    #[test]
+    fn changes_report_every_revision_even_while_the_document_stays_dirty() {
+        let mut session = Session::new(wordcraft_doc::Document::new());
+        let mut observer = RevisionObserver::default();
+        assert!(observer.changed(&session, 0));
+        assert!(!observer.changed(&session, 0));
+        session.run("text.insert", &serde_json::json!({"text":"first"})).unwrap();
+        assert!(session.dirty);
+        assert!(observer.changed(&session, 0));
+        let first = revision(&session, 0);
+        session.run("text.insert", &serde_json::json!({"text":"second"})).unwrap();
+        assert!(session.dirty);
+        assert!(observer.changed(&session, 0));
+        assert_ne!(revision(&session, 0), first);
+        assert!(!observer.changed(&session, 0));
+        session.set_document(wordcraft_doc::Document::new());
+        assert!(observer.changed(&session, 0));
+    }
+    #[test]
+    fn pending_draft_changes_are_reported_without_serializing_the_document() {
+        let session = Session::new(wordcraft_doc::Document::new());
+        let mut observer = RevisionObserver::default();
+        assert!(observer.changed(&session, 0));
+        assert!(observer.changed(&session, 1));
+        assert!(observer.changed(&session, 2));
+        assert!(!observer.changed(&session, 2));
+        assert_ne!(revision(&session, 1), revision(&session, 2));
+    }
+}
diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs
--- a/apps/wordcraft-web/src/web.rs
+++ b/apps/wordcraft-web/src/web.rs
@@ -99,7 +99,7 @@ pub fn start() {
                         crate::host::Bridge::set_author(&mut app, &author);
                         prefs.keep_own_author(&app.session.author, &own);
                     }
-                    Ok(Box::new(WebShell { app, inbox, messages, prefs }))
+                    Ok(Box::new(WebShell { app, inbox, messages, prefs, bridge: app_bridge }))
                 }),
             )
             .await;
@@ -134,6 +134,7 @@ struct WebShell {
     inbox: Inbox,
     messages: Rc<RefCell<Vec<String>>>,
     prefs: crate::prefs::PrefsSync,
+    bridge: Option<crate::host::Bridge>,
 }
 
 fn local_storage() -> Option<web_sys::Storage> {
@@ -155,6 +156,9 @@ impl eframe::App for WebShell {
             self.app.status(msg);
         }
         self.app.logic(ctx);
+        if let Some(bridge) = &self.bridge {
+            bridge.observe(&mut self.app);
+        }
         // Store changed preferences straight away: a reload or closed tab runs no exit code (#2132).
         if let Some(json) = self.prefs.changed(&self.app) {
             match local_storage() {
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -10865,10 +10865,11 @@ fn ime_body_epoch_marks_body_replacements_not_edits() {
     run(&mut s, "text.insert", json!({"text": " two"}));
     assert_eq!(s.ime_body_epoch(), first, "typing is not a replacement");
 
+    let document_id = s.document_id();
     run(&mut s, "document.setText", json!({"text": "same text"}));
     let replaced = s.ime_body_epoch();
     assert!(replaced > first, "setText replaces the body under the same document id");
-    assert_eq!(s.document_id(), 1, "the document id itself is unchanged");
+    assert_eq!(s.document_id(), document_id, "the document id itself is unchanged");
 
     // The same text again still replaces the body: a composition must not survive on content
     // equality.
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
@@ -20,6 +20,17 @@ impl WordApp {
         }
     }
 
+    /// Commit pending comment/reply text as a normal save does, without acknowledging any
+    /// storage or clearing dirty state. A refused draft stays local and returns an error.
+    pub fn prepare_native_snapshot(&mut self) -> Result<(), String> {
+        panes::flush_comment_drafts(self)
+    }
+
+    /// Changes to pending comment/reply text, independent of the document's dirty flag.
+    pub fn native_draft_revision(&self) -> u64 {
+        self.comment_drafts.revision_for(self.session.document_id())
+    }
+
     /// Tell the host whether the document has unsaved changes ([`Services::on_dirty`]).
     pub(super) fn report_dirty(&self) {
         if let Some(f) = &self.services.on_dirty {
diff --git a/crates/ui-egui/src/panes/comments.rs b/crates/ui-egui/src/panes/comments.rs
--- a/crates/ui-egui/src/panes/comments.rs
+++ b/crates/ui-egui/src/panes/comments.rs
@@ -72,6 +72,7 @@ pub(crate) struct CommentDrafts {
     document: u64,
     edits: BTreeMap<u32, String>,
     replies: BTreeMap<u32, String>,
+    revision: u64,
 }
 
 impl CommentDrafts {
@@ -85,6 +86,37 @@ impl CommentDrafts {
         self
     }
 
+    /// A cheap change token for unsaved text that is not in the native model yet.
+    pub(crate) fn revision_for(&self, document: u64) -> u64 {
+        if self.document == document { self.revision } else { 0 }
+    }
+
+    fn record_edit(&mut self, id: u32, text: String) {
+        if self.edits.get(&id) != Some(&text) {
+            self.edits.insert(id, text);
+            self.revision = self.revision.wrapping_add(1);
+        }
+    }
+
+    fn record_reply(&mut self, id: u32, text: String) {
+        if self.replies.get(&id) != Some(&text) {
+            self.replies.insert(id, text);
+            self.revision = self.revision.wrapping_add(1);
+        }
+    }
+
+    fn forget_edit(&mut self, id: u32) {
+        if self.edits.remove(&id).is_some() {
+            self.revision = self.revision.wrapping_add(1);
+        }
+    }
+
+    fn forget_reply(&mut self, id: u32) {
+        if self.replies.remove(&id).is_some() {
+            self.revision = self.revision.wrapping_add(1);
+        }
+    }
+
     /// Nothing typed and not yet written.
     pub(crate) fn is_empty(&self) -> bool {
         self.edits.is_empty() && self.replies.is_empty()
@@ -133,12 +165,12 @@ pub(crate) fn comment_editor(app: &mut WordApp, ui: &mut Ui, id: u32, font: Opti
         r.request_focus();
     }
     if r.changed() {
-        drafts(app).edits.insert(id, text);
+        drafts(app).record_edit(id, text);
     }
     if r.lost_focus() {
         if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
             // Escape throws the draft away: the field shows the comment's text again.
-            drafts(app).edits.remove(&id);
+            drafts(app).forget_edit(id);
         } else {
             // A refusal is on the status line, and the draft stays in the field.
             let _ = commit_comment(app, id);
@@ -161,7 +193,7 @@ pub(crate) fn commit_comment(app: &mut WordApp, id: u32) -> Result<(), String> {
     {
         app.run("review.editComment", json!({"id": id, "text": text}))?;
     }
-    drafts(app).edits.remove(&id);
+    drafts(app).forget_edit(id);
     Ok(())
 }
 
@@ -193,11 +225,11 @@ pub(crate) fn reply_editor(app: &mut WordApp, ui: &mut Ui, id: u32, font: Option
         r.request_focus();
     }
     if r.changed() {
-        drafts(app).replies.insert(id, text);
+        drafts(app).record_reply(id, text);
     }
     if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
         // Escape throws the reply away and closes the field.
-        drafts(app).replies.remove(&id);
+        drafts(app).forget_reply(id);
         return true;
     }
     r.lost_focus() && commit_reply(app, id).is_ok()
@@ -211,7 +243,7 @@ pub(crate) fn commit_reply(app: &mut WordApp, id: u32) -> Result<(), String> {
         let root = thread_root(app, id);
         app.run("review.reply", json!({"id": root, "text": text.trim_end()}))?;
     }
-    drafts(app).replies.remove(&id);
+    drafts(app).forget_reply(id);
     Ok(())
 }
 
@@ -238,3 +270,55 @@ pub(crate) fn flush_comment_drafts(app: &mut WordApp) -> Result<(), String> {
     }
     first
 }
+
+#[cfg(test)]
+mod native_snapshot_tests {
+    use super::*;
+    use crate::Services;
+    use wordcraft_engine::Session;
+
+    #[test]
+    fn native_snapshot_commits_pending_comment_and_keeps_it_unsaved() {
+        let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
+        app.run("review.newComment", json!({"text":"original"})).unwrap();
+        let id = *app.session.doc.comments.keys().next().unwrap();
+        let part = app.session.doc.comments[&id].part;
+        let before = app.session.rev();
+        drafts(&mut app).record_edit(id, "pending native comment".into());
+        assert_eq!(app.session.doc.plain_text(StoryRef::Part(part)), "original");
+        app.prepare_native_snapshot().unwrap();
+        assert_eq!(app.session.doc.plain_text(StoryRef::Part(part)), "pending native comment");
+        assert!(app.session.dirty, "export is not a storage acknowledgement");
+        assert!(app.session.rev() > before);
+        assert!(!has_comment_drafts(&app));
+    }
+
+    #[test]
+    fn every_pending_comment_and_reply_edit_changes_the_native_draft_token() {
+        let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
+        app.session.dirty = true;
+        let first = app.native_draft_revision();
+        drafts(&mut app).record_edit(0, "first".into());
+        let second = app.native_draft_revision();
+        drafts(&mut app).record_edit(0, "second".into());
+        let third = app.native_draft_revision();
+        drafts(&mut app).record_reply(0, "reply draft".into());
+        let fourth = app.native_draft_revision();
+        assert_ne!(first, second);
+        assert_ne!(second, third);
+        assert_ne!(third, fourth);
+        assert!(app.session.dirty);
+    }
+    #[test]
+    fn refused_pending_comment_stays_local_and_snapshot_preparation_fails() {
+        let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
+        app.run("review.newComment", json!({"text":"original"})).unwrap();
+        let id = *app.session.doc.comments.keys().next().unwrap();
+        drafts(&mut app).record_edit(id, "pending retained".into());
+        app.session.run("review.restrict", &json!({"mode":"readOnly"})).unwrap();
+        let before = serde_json::to_value(&app.session.doc).unwrap();
+        assert!(app.prepare_native_snapshot().is_err());
+        assert_eq!(serde_json::to_value(&app.session.doc).unwrap(), before);
+        assert_eq!(comment_draft(&app, id).as_deref(), Some("pending retained"));
+    }
+}
