diff --git a/crates/cms/src/clut.rs b/crates/cms/src/clut.rs
--- a/crates/cms/src/clut.rs
+++ b/crates/cms/src/clut.rs
@@ -23,6 +23,11 @@ pub struct Clut {
 }
 
 impl Clut {
+    /// Heap bytes this table holds: the nodes and the index arithmetic beside them.
+    pub fn table_bytes(&self) -> usize {
+        self.data.capacity() * std::mem::size_of::<f32>() + (self.grid.capacity() + self.strides.capacity()) * std::mem::size_of::<usize>()
+    }
+
     /// Builds a table from node data: `grid` holds the points per input dimension (1 to
     /// [`MAX_CHANNELS`] dimensions, each ≥ 2, or 1 for a degenerate dimension), `data` holds
     /// `outputs` values per node. Refuses inconsistent dimensions with
diff --git a/crates/cms/src/curve.rs b/crates/cms/src/curve.rs
--- a/crates/cms/src/curve.rs
+++ b/crates/cms/src/curve.rs
@@ -26,6 +26,14 @@ pub fn srgb_trc() -> Curve {
 }
 
 impl Curve {
+    /// Heap bytes this curve holds: the samples of a table, nothing for a function.
+    pub fn table_bytes(&self) -> usize {
+        match self {
+            Curve::Table(t) => t.capacity() * std::mem::size_of::<f32>(),
+            _ => 0,
+        }
+    }
+
     /// Number of parameters stored for a parametric function type.
     pub fn param_count(kind: u16) -> usize {
         match kind {
diff --git a/crates/cms/src/lib.rs b/crates/cms/src/lib.rs
--- a/crates/cms/src/lib.rs
+++ b/crates/cms/src/lib.rs
@@ -35,7 +35,10 @@ pub use curve::Curve;
 pub use gamut::GamutCheck;
 pub use lut3d::Lut3d;
 pub use profile::{ColorSpace, Pcs, Profile, ProfileClass};
-pub use transform::{SampleKind, Transform, TransformOptions, black_point, cached};
+pub use transform::{
+    DEFAULT_CACHE_BUDGET, MAX_CACHED, SampleKind, Transform, TransformOptions, black_point, cache_budget, cache_usage, cached, clear_cache,
+    set_cache_budget,
+};
 
 /// ICC rendering intent.
 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
diff --git a/crates/cms/src/pipeline.rs b/crates/cms/src/pipeline.rs
--- a/crates/cms/src/pipeline.rs
+++ b/crates/cms/src/pipeline.rs
@@ -176,7 +176,25 @@ pub struct Pipeline {
     stages: Vec<Stage>,
 }
 
+impl Stage {
+    /// Heap bytes this stage holds: curve samples, matrix coefficients and table nodes.
+    pub fn table_bytes(&self) -> usize {
+        let curves = |cs: &Vec<Curve>| cs.capacity() * std::mem::size_of::<Curve>() + cs.iter().map(Curve::table_bytes).sum::<usize>();
+        match self {
+            Stage::Curves(cs) | Stage::InvCurves(cs) => curves(cs),
+            Stage::Matrix { m, offset, .. } => (m.capacity() + offset.capacity()) * std::mem::size_of::<f64>(),
+            Stage::Clut(c) => c.table_bytes(),
+            _ => 0,
+        }
+    }
+}
+
 impl Pipeline {
+    /// Heap bytes the stages hold ([`Stage::table_bytes`]).
+    pub fn table_bytes(&self) -> usize {
+        self.stages.capacity() * std::mem::size_of::<Stage>() + self.stages.iter().map(Stage::table_bytes).sum::<usize>()
+    }
+
     /// A pipeline over `inputs` channels (1 to [`MAX`]). Refuses stages that cannot run on the
     /// channels reaching them with [`CmsError::InvalidArgument`].
     pub fn new(inputs: usize, stages: Vec<Stage>) -> Result<Self, CmsError> {
diff --git a/crates/cms/src/transform.rs b/crates/cms/src/transform.rs
--- a/crates/cms/src/transform.rs
+++ b/crates/cms/src/transform.rs
@@ -2,6 +2,7 @@
 //! compensation, soft-proofing chains, and fast lookup-table evaluation of pixel buffers.
 
 use std::collections::HashMap;
+use std::sync::atomic::{AtomicUsize, Ordering};
 use std::sync::{Arc, Mutex, OnceLock};
 
 use crate::clut::{Clut, MAX_CHANNELS};
@@ -106,6 +107,30 @@ struct White {
 }
 
 impl Transform {
+    /// Heap bytes this transform's tables hold: the exact pipeline, the acceleration tables built
+    /// from it (input curves or a device-link grid, the output inverse tables, the 8-bit grid
+    /// coordinates) and the white it keeps. What the cache accounts for, since a CMYK or
+    /// LUT-based link holds megabytes where a matrix/TRC one holds kilobytes.
+    pub fn table_bytes(&self) -> usize {
+        let f32s = |n: usize| n * std::mem::size_of::<f32>();
+        let core = match &self.core {
+            Core::Shaper { curves8, curves, matrix } => {
+                curves8.capacity() * std::mem::size_of::<[f32; 256]>()
+                    + curves.capacity() * std::mem::size_of::<Vec<f32>>()
+                    + curves.iter().map(|c| f32s(c.capacity())).sum::<usize>()
+                    + matrix.as_ref().map_or(0, |(_, _, m, o)| f32s(m.capacity() + o.capacity()))
+            }
+            Core::Grid(c) => c.table_bytes(),
+            Core::Exact => 0,
+        };
+        let post = self
+            .post
+            .as_ref()
+            .map_or(0, |p| p.tables.capacity() * std::mem::size_of::<Vec<f32>>() + p.tables.iter().map(|t| f32s(t.capacity())).sum::<usize>());
+        let white = self.white.as_ref().map_or(0, |w| f32s(w.src.capacity() + w.dst.capacity()) + w.src8.as_ref().map_or(0, Vec::capacity));
+        self.pipeline.table_bytes() + core + post + self.grid8.capacity() * std::mem::size_of::<[(u32, f32); 256]>() + white
+    }
+
     /// Transform from `src` to `dst` with `intent`, optionally with black point compensation.
     pub fn new(src: &Profile, dst: &Profile, intent: Intent, bpc: bool) -> Result<Transform, CmsError> {
         Self::with_options(src, dst, TransformOptions { intent, bpc, ..Default::default() })
@@ -804,20 +829,109 @@ pub fn black_point(p: &Profile, intent: Intent, _input: bool) -> Result<f64, Cms
 
 type Key = (u64, u64, TransformOptions);
 
+/// Transforms the cache keeps at most, whatever their size.
+pub const MAX_CACHED: usize = 64;
+
+/// Bytes of transform tables the cache keeps by default ([`set_cache_budget`]). A matrix/TRC link
+/// holds a few hundred kilobytes and a CMYK or LUT-based one a few megabytes, so a count alone
+/// bounds the cache by a factor of ten or more; this bounds the memory instead.
+pub const DEFAULT_CACHE_BUDGET: usize = 64 << 20;
+
+static BUDGET: AtomicUsize = AtomicUsize::new(DEFAULT_CACHE_BUDGET);
+
+/// What the cache may hold, in bytes of transform tables ([`Transform::table_bytes`]). An app with
+/// its own memory budget can lower this; 0 keeps only the transform last built.
+pub fn set_cache_budget(bytes: usize) {
+    BUDGET.store(bytes, Ordering::Relaxed);
+    let cache = cache();
+    let mut c = cache.lock().unwrap_or_else(|e| e.into_inner());
+    c.evict(bytes);
+}
+
+/// The byte budget in force.
+pub fn cache_budget() -> usize {
+    BUDGET.load(Ordering::Relaxed)
+}
+
+/// What the cache holds now: transforms, and bytes of their tables.
+pub fn cache_usage() -> (usize, usize) {
+    let cache = cache();
+    let c = cache.lock().unwrap_or_else(|e| e.into_inner());
+    (c.entries.len(), c.bytes)
+}
+
+/// Drop every cached transform (the memory goes as their last users do).
+pub fn clear_cache() {
+    let cache = cache();
+    let mut c = cache.lock().unwrap_or_else(|e| e.into_inner());
+    c.entries.clear();
+    c.bytes = 0;
+}
+
+struct Entry {
+    transform: Arc<Transform>,
+    bytes: usize,
+    /// Value of the cache's counter when this transform was last asked for.
+    used: u64,
+}
+
+#[derive(Default)]
+struct Cache {
+    entries: HashMap<Key, Entry>,
+    bytes: usize,
+    tick: u64,
+}
+
+impl Cache {
+    /// Drop least recently used transforms until the cache is inside `budget` and
+    /// [`MAX_CACHED`]. The transform just asked for is the most recently used, so it stays -- one
+    /// is kept whatever the budget, since the caller holds it anyway and rebuilding it for the
+    /// next frame would cost more than the bytes it holds.
+    fn evict(&mut self, budget: usize) {
+        while self.entries.len() > 1 && (self.bytes > budget || self.entries.len() > MAX_CACHED) {
+            let Some(oldest) = self.entries.iter().min_by_key(|(_, e)| e.used).map(|(k, _)| *k) else { break };
+            if let Some(e) = self.entries.remove(&oldest) {
+                self.bytes = self.bytes.saturating_sub(e.bytes);
+            }
+        }
+    }
+}
+
+fn cache() -> &'static Mutex<Cache> {
+    static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();
+    CACHE.get_or_init(Default::default)
+}
+
 /// Process-wide cache of transforms keyed by profile content and options.
+///
+/// Least recently used transforms are dropped when the cache passes [`MAX_CACHED`] or its byte
+/// budget ([`set_cache_budget`]). It used to clear itself whole, which dropped the transforms the
+/// next frames were about to use along with the rest, and counted entries rather than bytes, so a
+/// CMYK document's cache was an order of magnitude larger than an RGB one's (#63).
 pub fn cached(src: &Profile, dst: &Profile, opts: TransformOptions) -> Result<Arc<Transform>, CmsError> {
-    static CACHE: OnceLock<Mutex<HashMap<Key, Arc<Transform>>>> = OnceLock::new();
     let key = (src.content_hash(), dst.content_hash(), opts);
-    let cache = CACHE.get_or_init(Default::default);
-    if let Some(t) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
-        return Ok(t.clone());
+    let cache = cache();
+    {
+        let mut c = cache.lock().unwrap_or_else(|e| e.into_inner());
+        c.tick += 1;
+        let tick = c.tick;
+        if let Some(e) = c.entries.get_mut(&key) {
+            e.used = tick;
+            return Ok(e.transform.clone());
+        }
     }
+    // Built without the lock held: another thread may want a different transform meanwhile, and
+    // building one takes far longer than a lookup.
     let t = Arc::new(Transform::with_options(src, dst, opts)?);
+    let bytes = t.table_bytes();
     let mut c = cache.lock().unwrap_or_else(|e| e.into_inner());
-    if c.len() > 64 {
-        c.clear();
+    c.tick += 1;
+    let used = c.tick;
+    if let Some(old) = c.entries.insert(key, Entry { transform: t.clone(), bytes, used }) {
+        c.bytes = c.bytes.saturating_sub(old.bytes);
     }
-    c.insert(key, t.clone());
+    c.bytes += bytes;
+    c.evict(BUDGET.load(Ordering::Relaxed));
     Ok(t)
 }
 
diff --git a/crates/cms/tests/cms.rs b/crates/cms/tests/cms.rs
--- a/crates/cms/tests/cms.rs
+++ b/crates/cms/tests/cms.rs
@@ -991,3 +991,61 @@ fn white_is_exact_on_every_path() {
         assert!(o[2] < 0.99, "{cmyk:?}: absolute paper {o:?}");
     }
 }
+
+/// #63: the transform cache counted entries and cleared itself whole. A CMYK or LUT-based link
+/// holds megabytes where a matrix/TRC one holds kilobytes, so a count bounds the memory only to
+/// within an order of magnitude, and clearing everything dropped the transforms the next frames
+/// were about to use. It evicts the least recently used against a byte budget now.
+#[test]
+fn the_transform_cache_evicts_by_size_and_keeps_what_is_in_use() {
+    use craft_cms::{cache_budget, cache_usage, cached, clear_cache, set_cache_budget};
+
+    // What the issue is about: the two kinds of link are nothing like the same size.
+    let rgb = Transform::new(srgb(), lab(), Intent::RelativeColorimetric, true).unwrap().table_bytes();
+    let link = Transform::new(cmyk(), srgb(), Intent::Perceptual, true).unwrap().table_bytes();
+    assert!(rgb > 0 && link > rgb * 4, "a CMYK link is far larger than a matrix/TRC one: {link} vs {rgb}");
+
+    let budget = cache_budget();
+    clear_cache();
+    assert_eq!(cache_usage(), (0, 0));
+
+    // A budget that holds one of these transforms and not two.
+    set_cache_budget(link + rgb / 2);
+    let opts = |intent| TransformOptions { intent, ..Default::default() };
+    let first = cached(cmyk(), srgb(), opts(Intent::Perceptual)).unwrap();
+    let (entries, bytes) = cache_usage();
+    assert_eq!(entries, 1);
+    assert_eq!(bytes, first.table_bytes());
+
+    // The second does not fit beside the first, so the first goes -- and the caller's copy of it
+    // is still usable, which is why eviction is safe.
+    let second = cached(srgb(), lab(), opts(Intent::RelativeColorimetric)).unwrap();
+    let (entries, bytes) = cache_usage();
+    assert_eq!(entries, 1, "the older transform was evicted, not kept beside the new one");
+    assert_eq!(bytes, second.table_bytes());
+    let mut out = [0.0f32; 3];
+    first.eval(&[0.1, 0.2, 0.3, 0.4], &mut out).unwrap();
+
+    // Least recently used, not first inserted: asking for the older one again keeps it while the
+    // other goes.
+    set_cache_budget(rgb * 2 + link * 2);
+    clear_cache();
+    let a = cached(srgb(), lab(), opts(Intent::RelativeColorimetric)).unwrap();
+    let b = cached(srgb(), lab(), opts(Intent::Saturation)).unwrap();
+    let _ = (&a, &b);
+    assert_eq!(cache_usage().0, 2);
+    let _again = cached(srgb(), lab(), opts(Intent::RelativeColorimetric)).unwrap(); // a is now the newer
+    set_cache_budget(cache_usage().1 - 1); // room for one
+    assert_eq!(cache_usage().0, 1, "one was evicted");
+    let kept = cached(srgb(), lab(), opts(Intent::RelativeColorimetric)).unwrap();
+    assert_eq!(cache_usage().0, 1, "and it was the one asked for last, so this was a hit");
+    assert!(std::sync::Arc::ptr_eq(&kept, &_again), "the same transform, not a rebuilt one");
+
+    // A budget of nothing still keeps the transform last built: the caller holds it anyway.
+    set_cache_budget(0);
+    let only = cached(cmyk(), srgb(), opts(Intent::Perceptual)).unwrap();
+    assert_eq!(cache_usage(), (1, only.table_bytes()));
+
+    set_cache_budget(budget);
+    clear_cache();
+}
