diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -222,7 +222,7 @@ dependencies = [
  "objc2-foundation 0.3.2",
  "parking_lot",
  "percent-encoding",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
  "wl-clipboard-rs",
  "x11rb",
 ]
@@ -1008,6 +1008,7 @@ dependencies = [
  "image",
  "js-sys",
  "log",
+ "memory-stats",
  "serde",
  "serde_json",
  "thiserror 2.0.21",
@@ -1218,6 +1219,7 @@ dependencies = [
  "egui",
  "egui_kittest",
  "log",
+ "memory-stats",
  "serde",
  "serde_json",
  "skrifa 0.47.0",
@@ -1593,7 +1595,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
 dependencies = [
  "libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -2700,6 +2702,16 @@ dependencies = [
  "autocfg",
 ]
 
+[[package]]
+name = "memory-stats"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c73f5c649995a115e1a0220b35e4df0a1294500477f97a91d0660fb5abeb574a"
+dependencies = [
+ "libc",
+ "windows-sys 0.52.0",
+]
+
 [[package]]
 name = "minicov"
 version = "0.3.8"
@@ -2847,7 +2859,7 @@ version = "0.50.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
 dependencies = [
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -3320,7 +3332,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
 dependencies = [
  "libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -3863,7 +3875,7 @@ dependencies = [
  "errno",
  "libc",
  "linux-raw-sys 0.12.1",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -3920,7 +3932,7 @@ dependencies = [
  "security-framework",
  "security-framework-sys",
  "webpki-root-certs",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -4541,10 +4553,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
 dependencies = [
  "fastrand",
- "getrandom 0.3.4",
+ "getrandom 0.4.3",
  "once_cell",
  "rustix 1.1.5",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -5461,7 +5473,7 @@ version = "0.1.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
 dependencies = [
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
--- a/crates/engine/Cargo.toml
+++ b/crates/engine/Cargo.toml
@@ -29,6 +29,7 @@ chrono = { workspace = true }
 
 [dev-dependencies]
 deckcraft-anim = { workspace = true }
+memory-stats = "1.2.0"
 zip = { workspace = true }
 
 # The browser clock for comment timestamps (wasm has no std::time::SystemTime).
diff --git a/crates/engine/src/soak_tests.rs b/crates/engine/src/soak_tests.rs
--- a/crates/engine/src/soak_tests.rs
+++ b/crates/engine/src/soak_tests.rs
@@ -64,6 +64,16 @@ fn params(r: &mut Rng, s: &Session) -> Value {
     }
 }
 
+/// Physical memory in use, in bytes: the resident set size on Linux and macOS, the working set on
+/// Windows. `None` where the platform cannot say.
+fn rss_bytes() -> Option<u64> {
+    memory_stats::memory_stats().map(|m| m.physical_mem as u64)
+}
+
+/// RSS growth allowed between the warmed-up start and the end of a soak. Generous: `cargo test`
+/// threads share the process, so other tests' allocations count too.
+const MAX_RSS_GROWTH: u64 = 256 << 20;
+
 fn doc(s: &Session) -> Option<deckcraft_model::Presentation> {
     s.active().map(|d| (*d.doc).clone())
 }
@@ -91,7 +101,12 @@ fn soak(seed: u64, steps: usize) {
     let mut r = Rng(seed | 1);
     let mut log: VecDeque<String> = VecDeque::new();
     let mut edits = 0usize;
+    let mut rss_start = None;
     for step in 0..steps {
+        // Sample after a quarter of the run so one-off caches and allocator arenas are warm.
+        if step == steps / 4 {
+            rss_start = rss_bytes();
+        }
         let id = *r.pick(&ids).unwrap();
         let p = params(&mut r, &s);
         log.push_back(format!("{id} {p}"));
@@ -140,7 +155,13 @@ fn soak(seed: u64, steps: usize) {
     }
     // The soak must really edit, not just bounce off disabled commands.
     assert!(edits * 10 >= steps, "only {edits} of {steps} steps changed the document; seed {seed}");
-    eprintln!("soak seed {seed}: {edits} of {steps} steps edited");
+    if let (Some(start), Some(end)) = (rss_start, rss_bytes()) {
+        let grew = end.saturating_sub(start);
+        eprintln!("soak seed {seed}: {edits} of {steps} steps edited, memory {} -> {} MiB", start >> 20, end >> 20);
+        assert!(grew <= MAX_RSS_GROWTH, "memory grew {} MiB over {} MiB; seed {seed}", grew >> 20, MAX_RSS_GROWTH >> 20);
+    } else {
+        eprintln!("soak seed {seed}: {edits} of {steps} steps edited");
+    }
 }
 
 fn steps(default: usize) -> usize {
diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml
--- a/crates/ui-egui/Cargo.toml
+++ b/crates/ui-egui/Cargo.toml
@@ -32,6 +32,7 @@ skrifa = { workspace = true }
 # kittest's own dev-dependencies do not propagate; tests need both font features explicitly.
 egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
 egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
+memory-stats = "1.2.0"
 
 [build-dependencies]
 serde_json = { workspace = true }
diff --git a/crates/ui-egui/src/ui_soak_tests.rs b/crates/ui-egui/src/ui_soak_tests.rs
--- a/crates/ui-egui/src/ui_soak_tests.rs
+++ b/crates/ui-egui/src/ui_soak_tests.rs
@@ -23,6 +23,16 @@ const MAX_JOURNAL: usize = 10_000;
 /// Mean wall time per frame, generous for slow CI machines (the test profile is optimized).
 const MAX_MEAN_FRAME: Duration = Duration::from_millis(250);
 
+/// RSS growth allowed between the warmed-up start and the end of a soak; generous because
+/// `cargo test` threads share the process.
+const MAX_RSS_GROWTH: u64 = 256 << 20;
+
+/// Physical memory in use, in bytes: the resident set size on Linux and macOS, the working set on
+/// Windows. `None` where the platform cannot say.
+fn rss_bytes() -> Option<u64> {
+    memory_stats::memory_stats().map(|m| m.physical_mem as u64)
+}
+
 struct Rng(u64);
 
 impl Rng {
@@ -166,7 +176,12 @@ fn soak(seed: u64, steps: usize) {
     let mut log = VecDeque::new();
     let t0 = Instant::now();
     let mut frames = 0usize;
+    let mut rss_start = None;
     for step in 0..steps {
+        // Sample after a quarter of the run so texture caches and allocator arenas are warm.
+        if step == steps / 4 {
+            rss_start = rss_bytes();
+        }
         log.push_back(act(&mut h, &mut r));
         if log.len() > 20 {
             log.pop_front();
@@ -189,6 +204,11 @@ fn soak(seed: u64, steps: usize) {
     let mean = t0.elapsed() / frames.max(1) as u32;
     eprintln!("ui soak seed {seed}: {steps} actions, {edits} commands, ~{frames} frames, mean frame {mean:?}");
     assert!(mean <= MAX_MEAN_FRAME, "mean frame {mean:?} over {MAX_MEAN_FRAME:?}; seed {seed}");
+    if let (Some(start), Some(end)) = (rss_start, rss_bytes()) {
+        let grew = end.saturating_sub(start);
+        eprintln!("ui soak seed {seed}: memory {} -> {} MiB", start >> 20, end >> 20);
+        assert!(grew <= MAX_RSS_GROWTH, "memory grew {} MiB over {} MiB; seed {seed}", grew >> 20, MAX_RSS_GROWTH >> 20);
+    }
 }
 
 fn env(name: &str, default: u64) -> u64 {
diff --git a/docs/gaps.md b/docs/gaps.md
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -49,7 +49,7 @@ revision history.
 - **Estimate:** 4–8 h (backend probing outside the process or a safe-mode fallback, a software
   renderer path). Needs a tester with the hardware. **Beta blocker.**
 
-### Soak testing has no memory or timing budgets
+### Soak memory budgets run in CI on Linux only
 - **Evidence:** `crates/engine/src/soak_tests.rs` runs seeded random command sequences over a
   ~100-slide deck (validity, selection, undo/redo exactness, bounded undo, save/reopen in both
   formats every 100 steps); `DECKCRAFT_SOAK_STEPS` runs it long. It found a chart `clamp` panic
@@ -57,9 +57,13 @@ revision history.
   seeded random clicks, drags, keys (plain, Ctrl, Shift), typing and scrolling over the sample
   deck, checking document validity, bounded undo/journal/thumbnail/texture caches, zero caught
   command and render panics (`Session::panics_caught`, `deckcraft_render::panics_caught`) and a
-  mean frame under 250 ms; 4 × 3000-action runs are clean.
-- **Impact:** long-session crash rate is sampled in CI; RSS is not measured directly.
-- **Estimate:** 1–2 h (RSS sampling per platform).
+  mean frame under 250 ms; 4 × 3000-action runs are clean. Both soaks also sample resident memory
+  (the `memory-stats` dev-dependency: `/proc` on Linux, `GetProcessMemoryInfo` on Windows, Mach
+  task info on macOS) after a quarter of the run and fail if it then grows by more than 256 MiB.
+- **Impact:** long-session crash rate is sampled in CI; the RSS budget is only exercised on Linux
+  (`ci.yml`), and the Windows and macOS code paths have never run in CI. The engine soak has no
+  wall-time budget.
+- **Estimate:** 1 h (a Windows and macOS test job; a timing budget).
 
 ### A control-server timing test may fail under load
 - **Evidence:** `silent_refused_peers_do_not_delay_the_next_refusal` in
@@ -372,6 +376,7 @@ Detail in [localization-parity.md](localization-parity.md).
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-26 | trivial | The engine and UI soaks fail if resident memory grows by more than 256 MiB after warm-up (through `memory-stats`: Linux, Windows and macOS; only Linux runs in CI) |
 | 2026-10-25 | trivial | New entries: copying text and cell ranges (no media, no table), a control-server timing test that may fail under load; Proofing notes that automatic lists follow Replace text as you type and that Backspace puts the typed lead back |
 | 2026-10-24 | trivial | AutoFormat starts bulleted and numbered lists as you type (`* `, `-- `, `> `, `1. `, `a) ` and the rest), with an AutoCorrect option to turn it off |
 | 2026-10-22 | minor | Animation Pane ▸ Play From starts the preview at the selected effect; gap 10 keeps the effect gallery (7–13 h) |
