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
@@ -80,6 +80,11 @@ impl Clut {
         self.data.len() / self.outputs
     }
 
+    /// Returns the approximate memory footprint in bytes of the lookup table data and grid metadata.
+    pub fn table_bytes(&self) -> usize {
+        self.data.len() * std::mem::size_of::<f32>() + (self.grid.len() + self.strides.len()) * std::mem::size_of::<usize>()
+    }
+
     /// Builds a table by sampling `f` at every node (inputs normalised to `[0, 1]`); the
     /// dimensions are checked as in [`Clut::new`].
     pub fn sample(grid: Vec<usize>, outputs: usize, mut f: impl FnMut(&[f32], &mut [f32])) -> Result<Self, CmsError> {
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
@@ -50,6 +50,14 @@ impl Curve {
         }
     }
 
+    /// Approximate memory footprint in bytes of any sampled lookup table.
+    pub fn table_bytes(&self) -> usize {
+        match self {
+            Curve::Table(t) => t.len() * std::mem::size_of::<f32>(),
+            _ => 0,
+        }
+    }
+
     /// Evaluates the curve at `x` (f64 reference path).
     pub fn eval64(&self, x: f64) -> f64 {
         match self {
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
@@ -13,7 +13,7 @@
 //! * [`Lut3d`] samples a display transform into a 3D texture for the GPU canvas.
 //!
 //! Implemented from the public ICC.1:2010 (v4.3) and ICC.1:2001-04 (v2) specifications,
-//! CIE 15, and Adobe's published black point compensation paper.
+//! CIE 15, and the ISO 18619 black point compensation standard.
 #![forbid(unsafe_code)]
 
 pub mod builtin;
@@ -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::{
+    CacheLimits, CacheStats, SampleKind, Transform, TransformCache, TransformOptions, black_point, cache_limits, cache_stats, cached, clear_cache,
+    set_cache_limits,
+};
 
 /// 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
@@ -77,6 +77,16 @@ impl Stage {
         Stage::Matrix { rows: 3, cols: 3, m: m.iter().flatten().copied().collect(), offset: vec![0.0; 3] }
     }
 
+    /// Returns the approximate memory footprint in bytes of lookup tables and buffers owned by this stage.
+    pub fn table_bytes(&self) -> usize {
+        match self {
+            Stage::Curves(cs) | Stage::InvCurves(cs) => cs.iter().map(Curve::table_bytes).sum(),
+            Stage::Matrix { m, offset, .. } => (m.len() + offset.len()) * std::mem::size_of::<f64>(),
+            Stage::Clut(c) => c.table_bytes(),
+            _ => 0,
+        }
+    }
+
     #[inline]
     fn run(&self, v: &mut [f32; MAX], n: usize) -> usize {
         match self {
@@ -198,6 +208,11 @@ impl Pipeline {
         &self.stages
     }
 
+    /// Returns the approximate memory footprint in bytes of lookup tables and buffers in the pipeline stages.
+    pub fn table_bytes(&self) -> usize {
+        self.stages.iter().map(Stage::table_bytes).sum()
+    }
+
     /// Appends stages, checked as in [`Pipeline::new`]; on error the pipeline is unchanged.
     pub fn extend(&mut self, stages: impl IntoIterator<Item = Stage>) -> Result<(), CmsError> {
         let mut n = self.outputs;
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
@@ -1,7 +1,7 @@
 //! Colour transforms between profiles through the PCS, with rendering intents, black point
 //! compensation, soft-proofing chains, and fast lookup-table evaluation of pixel buffers.
 
-use std::collections::HashMap;
+use std::collections::{HashMap, VecDeque};
 use std::sync::{Arc, Mutex, OnceLock};
 
 use crate::clut::{Clut, MAX_CHANNELS};
@@ -270,6 +270,38 @@ impl Transform {
     pub fn outputs(&self) -> usize {
         self.outputs
     }
+    /// Returns the approximate memory footprint in bytes of the precomputed lookup tables, grids, and exact pipeline.
+    pub fn table_bytes(&self) -> usize {
+        let mut bytes = self.pipeline.table_bytes();
+        match &self.core {
+            Core::Shaper { curves8, curves, matrix } => {
+                bytes += curves8.len() * 256 * std::mem::size_of::<f32>();
+                for c in curves {
+                    bytes += c.len() * std::mem::size_of::<f32>();
+                }
+                if let Some((_, _, m, b)) = matrix {
+                    bytes += (m.len() + b.len()) * std::mem::size_of::<f32>();
+                }
+            }
+            Core::Grid(clut) => {
+                bytes += clut.table_bytes();
+            }
+            Core::Exact => {}
+        }
+        if let Some(post) = &self.post {
+            for t in &post.tables {
+                bytes += t.len() * std::mem::size_of::<f32>();
+            }
+        }
+        bytes += self.grid8.len() * 256 * std::mem::size_of::<(u32, f32)>();
+        if let Some(white) = &self.white {
+            bytes += (white.src.len() + white.dst.len()) * std::mem::size_of::<f32>();
+            if let Some(src8) = &white.src8 {
+                bytes += src8.len();
+            }
+        }
+        bytes
+    }
     pub fn options(&self) -> TransformOptions {
         self.opts
     }
@@ -734,7 +766,7 @@ fn connection(src: &Profile, dst: &Profile, intent: Intent, bpc: bool) -> Result
     if (bs - bd).abs() < 1e-6 {
         return Ok(None);
     }
-    // Neutral black points (luminance only, as in Adobe's BPC), mapped linearly in XYZ so that
+    // Neutral black points (luminance only, as in Adobe BPC), mapped linearly in XYZ so that
     // the source black lands on the destination black and the white stays fixed.
     let w = math::D50;
     let mut scale = [1.0; 3];
@@ -750,7 +782,7 @@ fn connection(src: &Profile, dst: &Profile, intent: Intent, bpc: bool) -> Result
 
 /// Luminance (Y, D50-relative) of the profile's black point for `intent`.
 ///
-/// Follows the structure of Adobe's published BPC algorithm: matrix/TRC profiles use device
+/// Follows the structure of the Adobe BPC algorithm: matrix/TRC profiles use device
 /// zero; v4 LUT profiles in perceptual/saturation use the perceptual reference black; other LUT
 /// profiles use the darkest colour the device reaches (Lab 0 round-tripped through BToA/AToB,
 /// or device black for additive spaces). The destination-side curve fitting of the paper is
@@ -802,23 +834,212 @@ pub fn black_point(p: &Profile, intent: Intent, _input: bool) -> Result<f64, Cms
 
 // ------------------------------------------------------------ cache
 
+/// Limits for a transform cache.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct CacheLimits {
+    /// Maximum number of transform entries in the cache.
+    pub max_entries: usize,
+    /// Maximum total lookup table bytes allowed across all cached transforms.
+    pub max_bytes: usize,
+}
+
+impl Default for CacheLimits {
+    fn default() -> Self {
+        Self { max_entries: 64, max_bytes: 64 * 1024 * 1024 }
+    }
+}
+
+/// Statistics for a transform cache.
+///
+/// Note: `total_bytes` reflects the memory of lookup tables currently retained by entries
+/// resident inside the cache. It does not measure memory retained by callers holding
+/// `Arc<Transform>` handles after eviction.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
+pub struct CacheStats {
+    /// Current number of transform entries in the cache.
+    pub entries: usize,
+    /// Current total lookup table memory in bytes retained across all cached entries.
+    pub total_bytes: usize,
+}
+
 type Key = (u64, u64, TransformOptions);
 
+struct CacheEntry {
+    transform: Arc<Transform>,
+    bytes: usize,
+}
+
+struct CacheState {
+    entries: HashMap<Key, CacheEntry>,
+    order: VecDeque<Key>,
+    total_bytes: usize,
+    limits: CacheLimits,
+}
+
+impl CacheState {
+    fn new(limits: CacheLimits) -> Self {
+        Self { entries: HashMap::new(), order: VecDeque::new(), total_bytes: 0, limits }
+    }
+
+    fn touch(&mut self, key: &Key) {
+        if let Some(pos) = self.order.iter().position(|k| k == key) {
+            self.order.remove(pos);
+            self.order.push_back(*key);
+        }
+    }
+
+    fn insert(&mut self, key: Key, transform: Arc<Transform>) {
+        let bytes = transform.table_bytes();
+        if bytes > self.limits.max_bytes {
+            return;
+        }
+        if let Some(old) = self.entries.insert(key, CacheEntry { transform, bytes }) {
+            self.total_bytes = self.total_bytes.saturating_sub(old.bytes);
+            if let Some(pos) = self.order.iter().position(|k| k == &key) {
+                self.order.remove(pos);
+            }
+        }
+        self.total_bytes = self.total_bytes.saturating_add(bytes);
+        self.order.push_back(key);
+        self.evict();
+    }
+
+    fn evict(&mut self) {
+        while self.entries.len() > self.limits.max_entries || (self.total_bytes > self.limits.max_bytes && !self.entries.is_empty()) {
+            if let Some(oldest) = self.order.pop_front() {
+                if let Some(removed) = self.entries.remove(&oldest) {
+                    self.total_bytes = self.total_bytes.saturating_sub(removed.bytes);
+                }
+            } else {
+                break;
+            }
+        }
+    }
+
+    fn set_limits(&mut self, limits: CacheLimits) {
+        self.limits = limits;
+        self.evict();
+    }
+
+    fn clear(&mut self) {
+        self.entries.clear();
+        self.order.clear();
+        self.total_bytes = 0;
+    }
+
+    fn stats(&self) -> CacheStats {
+        CacheStats { entries: self.entries.len(), total_bytes: self.total_bytes }
+    }
+}
+
+/// A bounded LRU cache of colour transforms.
+///
+/// May be instantiated locally by applications to isolate specific workloads (e.g. background
+/// exports or tile pipelines), or accessed through the shared process-wide instance via
+/// [`TransformCache::global`] or [`cached`].
+pub struct TransformCache {
+    state: Mutex<CacheState>,
+}
+
+impl Default for TransformCache {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl TransformCache {
+    /// Creates a new transform cache with default limits (64 entries, 64 MiB).
+    pub fn new() -> Self {
+        Self::with_limits(CacheLimits::default())
+    }
+
+    /// Creates a new transform cache with custom entry and byte limits.
+    pub fn with_limits(limits: CacheLimits) -> Self {
+        Self { state: Mutex::new(CacheState::new(limits)) }
+    }
+
+    /// Accesses the process-wide global transform cache.
+    pub fn global() -> &'static Self {
+        static GLOBAL: OnceLock<TransformCache> = OnceLock::new();
+        GLOBAL.get_or_init(Self::new)
+    }
+
+    /// Retrieves an existing transform or computes and caches a new one.
+    ///
+    /// Transform compilation happens outside the cache lock to avoid blocking concurrent lookups.
+    /// If multiple threads concurrently request the same uncached transform, each may compile it;
+    /// the first completion is cached, subsequent completions return the cached transform, and all callers
+    /// receive valid transforms.
+    pub fn get(&self, src: &Profile, dst: &Profile, opts: TransformOptions) -> Result<Arc<Transform>, CmsError> {
+        let key = (src.content_hash(), dst.content_hash(), opts);
+        {
+            let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
+            if let Some(t) = state.entries.get(&key).map(|e| e.transform.clone()) {
+                state.touch(&key);
+                return Ok(t);
+            }
+        }
+        let t = Arc::new(Transform::with_options(src, dst, opts)?);
+        let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
+        if let Some(t) = state.entries.get(&key).map(|e| e.transform.clone()) {
+            state.touch(&key);
+            return Ok(t);
+        }
+        state.insert(key, t.clone());
+        Ok(t)
+    }
+
+    /// Sets entry and byte limits, evicting least recently used entries if needed.
+    pub fn set_limits(&self, limits: CacheLimits) {
+        let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
+        state.set_limits(limits);
+    }
+
+    /// Returns the current cache limits.
+    pub fn limits(&self) -> CacheLimits {
+        let state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
+        state.limits
+    }
+
+    /// Returns the current entry count and total lookup table memory in bytes.
+    pub fn stats(&self) -> CacheStats {
+        let state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
+        state.stats()
+    }
+
+    /// Clears all entries from the cache.
+    pub fn clear(&self) {
+        let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
+        state.clear();
+    }
+}
+
+/// Sets the limits for the process-wide transform cache and evicts least recently used entries if needed.
+pub fn set_cache_limits(limits: CacheLimits) {
+    TransformCache::global().set_limits(limits);
+}
+
+/// Returns the current limits of the process-wide transform cache.
+pub fn cache_limits() -> CacheLimits {
+    TransformCache::global().limits()
+}
+
+/// Returns current statistics of the process-wide transform cache.
+pub fn cache_stats() -> CacheStats {
+    TransformCache::global().stats()
+}
+
+/// Clears all entries from the process-wide transform cache.
+pub fn clear_cache() {
+    TransformCache::global().clear();
+}
+
 /// Process-wide cache of transforms keyed by profile content and options.
+///
+/// Retrieves an existing transform or precomputes and caches a new one, evicting
+/// least recently used entries when limits are exceeded.
 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 t = Arc::new(Transform::with_options(src, dst, opts)?);
-    let mut c = cache.lock().unwrap_or_else(|e| e.into_inner());
-    if c.len() > 64 {
-        c.clear();
-    }
-    c.insert(key, t.clone());
-    Ok(t)
+    TransformCache::global().get(src, dst, opts)
 }
 
 impl Clut {
diff --git a/crates/cms/tests/cache.rs b/crates/cms/tests/cache.rs
new file mode 100644
--- /dev/null
+++ b/crates/cms/tests/cache.rs
@@ -0,0 +1,238 @@
+use craft_cms::{
+    Builtin, CacheLimits, Transform, TransformCache, TransformOptions, cache_limits, cache_stats, cached, clear_cache, set_cache_limits,
+};
+
+#[test]
+fn test_table_bytes() {
+    let srgb = Builtin::Srgb.profile();
+    let p3 = Builtin::DisplayP3.profile();
+    let opts = TransformOptions::default();
+
+    let t = Transform::with_options(srgb, p3, opts).expect("transform");
+    assert!(t.table_bytes() > 0);
+}
+
+#[test]
+fn test_global_lru_cache() {
+    clear_cache();
+    let initial_limits = cache_limits();
+    assert_eq!(cache_stats().entries, 0);
+    assert_eq!(cache_stats().total_bytes, 0);
+
+    let srgb = Builtin::Srgb.profile();
+    let p3 = Builtin::DisplayP3.profile();
+    let adobe = Builtin::AdobeRgbCompat.profile();
+    let rec2020 = Builtin::Rec2020.profile();
+
+    let opts = TransformOptions::default();
+
+    // Set cache limit to 2 entries
+    set_cache_limits(CacheLimits { max_entries: 2, max_bytes: 64 * 1024 * 1024 });
+
+    // Insert entry 1: sRGB -> P3
+    let _ = cached(srgb, p3, opts).expect("cached 1");
+    assert_eq!(cache_stats().entries, 1);
+    assert!(cache_stats().total_bytes > 0);
+
+    // Insert entry 2: sRGB -> Adobe
+    let _ = cached(srgb, adobe, opts).expect("cached 2");
+    assert_eq!(cache_stats().entries, 2);
+
+    // Touch entry 1 by querying it again (makes entry 2 the LRU)
+    let _ = cached(srgb, p3, opts).expect("cached 1 hit");
+    assert_eq!(cache_stats().entries, 2);
+
+    // Insert entry 3: sRGB -> Rec2020 (should evict entry 2)
+    let _ = cached(srgb, rec2020, opts).expect("cached 3");
+    assert_eq!(cache_stats().entries, 2);
+
+    // Reinsert entry 2 so cache has 3 resident transforms before applying byte limit
+    set_cache_limits(CacheLimits { max_entries: 10, max_bytes: 64 * 1024 * 1024 });
+    let _ = cached(srgb, adobe, opts).expect("cached 2 again");
+    assert_eq!(cache_stats().entries, 3);
+
+    // Byte budget eviction test: set limit so only 1 transform fits (evicts 2 entries)
+    let single_bytes = cache_stats().total_bytes / 3;
+    set_cache_limits(CacheLimits { max_entries: 10, max_bytes: single_bytes + 10 });
+    assert_eq!(cache_stats().entries, 1);
+    assert!(cache_stats().total_bytes <= single_bytes + 10);
+
+    // Clear cache
+    clear_cache();
+    assert_eq!(cache_stats().entries, 0);
+    assert_eq!(cache_stats().total_bytes, 0);
+
+    // Restore initial limits
+    set_cache_limits(initial_limits);
+}
+
+#[test]
+fn test_isolated_transform_cache_instance() {
+    let srgb = Builtin::Srgb.profile();
+    let p3 = Builtin::DisplayP3.profile();
+    let adobe = Builtin::AdobeRgbCompat.profile();
+    let rec2020 = Builtin::Rec2020.profile();
+    let opts = TransformOptions::default();
+
+    // Isolated cache with 2-entry capacity
+    let local_cache = TransformCache::with_limits(CacheLimits { max_entries: 2, max_bytes: 32 * 1024 * 1024 });
+    assert_eq!(local_cache.stats().entries, 0);
+
+    let _ = local_cache.get(srgb, p3, opts).expect("local 1");
+    let _ = local_cache.get(srgb, adobe, opts).expect("local 2");
+    assert_eq!(local_cache.stats().entries, 2);
+
+    // Touch entry 1
+    let _ = local_cache.get(srgb, p3, opts).expect("local 1 hit");
+
+    // Insert entry 3 -> evicts entry 2
+    let _ = local_cache.get(srgb, rec2020, opts).expect("local 3");
+    assert_eq!(local_cache.stats().entries, 2);
+
+    local_cache.clear();
+    assert_eq!(local_cache.stats().entries, 0);
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+#[test]
+fn test_concurrent_misses_and_contention() {
+    use std::sync::Arc;
+    use std::thread;
+
+    let cache = Arc::new(TransformCache::with_limits(CacheLimits { max_entries: 4, max_bytes: 64 * 1024 * 1024 }));
+
+    let srgb = Builtin::Srgb.profile();
+    let p3 = Builtin::DisplayP3.profile();
+    let adobe = Builtin::AdobeRgbCompat.profile();
+    let rec2020 = Builtin::Rec2020.profile();
+    let opts = TransformOptions::default();
+
+    // 1. Concurrent misses on the same uncached transform
+    let mut handles = Vec::new();
+    for _ in 0..8 {
+        let c = Arc::clone(&cache);
+        handles.push(thread::spawn(move || {
+            let t = c.get(srgb, p3, opts).expect("get transform concurrently");
+            let mut out = [0.0f32; 3];
+            t.eval(&[0.5, 0.5, 0.5], &mut out).expect("eval");
+            assert!(out[0] > 0.0);
+        }));
+    }
+    for h in handles {
+        h.join().expect("thread join");
+    }
+
+    // Cache should contain exactly 1 entry despite concurrent misses
+    assert_eq!(cache.stats().entries, 1);
+    assert!(cache.stats().total_bytes > 0);
+
+    // 2. High contention concurrent queries across multiple transforms with strict entry and byte bounds
+    let tight_cache = Arc::new(TransformCache::with_limits(CacheLimits { max_entries: 2, max_bytes: 2 * 1024 * 1024 }));
+
+    let mut contention_handles = Vec::new();
+    let profiles = [srgb, p3, adobe, rec2020];
+
+    for id in 0..16 {
+        let c = Arc::clone(&tight_cache);
+        let src = profiles[id % profiles.len()];
+        let dst = profiles[(id + 1) % profiles.len()];
+        contention_handles.push(thread::spawn(move || {
+            for _ in 0..10 {
+                let t = c.get(src, dst, opts).expect("contention get");
+                let mut out = [0.0f32; 3];
+                t.eval(&[0.2, 0.4, 0.6], &mut out).expect("eval under contention");
+            }
+        }));
+    }
+
+    for h in contention_handles {
+        h.join().expect("contention thread join");
+    }
+
+    let stats = tight_cache.stats();
+    assert!(stats.entries <= 2, "entries ({}) exceeded max_entries (2)", stats.entries);
+    assert!(stats.total_bytes <= 2 * 1024 * 1024, "total_bytes ({}) exceeded max_bytes", stats.total_bytes);
+}
+
+fn make_lut16_profile_65() -> craft_cms::Profile {
+    let mut lut = b"mft2\0\0\0\0".to_vec();
+    lut.extend_from_slice(&[3, 3, 65, 0]);
+    for k in 0..9 {
+        let v: i32 = if k % 4 == 0 { 0x10000 } else { 0 };
+        lut.extend_from_slice(&v.to_be_bytes());
+    }
+    lut.extend_from_slice(&[0, 2, 0, 2]);
+    for _ in 0..3 {
+        lut.extend_from_slice(&[0, 0, 0xff, 0xff]);
+    }
+    let clut_vals = 65usize * 65 * 65 * 3;
+    lut.resize(lut.len() + clut_vals * 2, 0x80);
+    for _ in 0..3 {
+        lut.extend_from_slice(&[0, 0, 0xff, 0xff]);
+    }
+
+    let mut icc = vec![0u8; 128];
+    icc[8] = 4;
+    icc[12..16].copy_from_slice(b"mntr");
+    icc[16..20].copy_from_slice(b"RGB ");
+    icc[20..24].copy_from_slice(b"XYZ ");
+    icc[36..40].copy_from_slice(b"acsp");
+    icc.extend_from_slice(&1u32.to_be_bytes());
+    icc.extend_from_slice(b"A2B1");
+    icc.extend_from_slice(&144u32.to_be_bytes());
+    icc.extend_from_slice(&(lut.len() as u32).to_be_bytes());
+    icc.extend_from_slice(&lut);
+    let size = icc.len() as u32;
+    icc[..4].copy_from_slice(&size.to_be_bytes());
+
+    craft_cms::Profile::parse(&icc).expect("valid profile with 65x65x65 lut16")
+}
+
+#[test]
+fn test_retained_pipeline_clut_memory_accounting_and_eviction() {
+    let src = make_lut16_profile_65();
+    let dst = Builtin::Srgb.profile();
+    let opts = TransformOptions::default();
+
+    let transform = Transform::with_options(&src, dst, opts).expect("transform");
+    let table_bytes = transform.table_bytes();
+
+    // Verify accounting includes the retained 65^3 * 3 * 4 = 3,295,500 bytes CLUT from the pipeline
+    assert!(table_bytes >= 3_295_500, "table_bytes ({table_bytes}) must account for retained pipeline CLUT data (at least 3,295,500 bytes)");
+
+    // Cache with configured limit of 1 MiB (1,048,576 bytes) as reported by @euisuh
+    let limit_bytes = 1_048_576;
+    let cache = TransformCache::with_limits(CacheLimits { max_entries: 10, max_bytes: limit_bytes });
+
+    // Insert a smaller transform first to verify oversized entries don't evict it
+    let p3 = Builtin::DisplayP3.profile();
+    let small_t = cache.get(dst, p3, opts).expect("small transform");
+    let small_bytes = small_t.table_bytes();
+    assert_eq!(cache.stats().entries, 1);
+    assert_eq!(cache.stats().total_bytes, small_bytes);
+
+    // The transform exceeds the 1 MiB budget (it is ~3.7 MiB)
+    let t = cache.get(&src, dst, opts).expect("get transform");
+    assert!(t.table_bytes() >= 3_295_500);
+
+    // The oversized transform must not be cached, and the small resident entry must survive
+    let stats = cache.stats();
+    assert_eq!(stats.entries, 1, "oversized transform must not remain resident and must not evict smaller entries");
+    assert_eq!(stats.total_bytes, small_bytes);
+
+    // When cache byte budget is large enough to accommodate the transform (e.g. 8 MiB)
+    cache.set_limits(CacheLimits { max_entries: 10, max_bytes: 8 * 1024 * 1024 });
+    let t2 = cache.get(&src, dst, opts).expect("get transform with sufficient budget");
+    let stats2 = cache.stats();
+    assert_eq!(stats2.entries, 2);
+    assert_eq!(stats2.total_bytes, small_bytes + t2.table_bytes());
+
+    // Touch small_t so t2 is the LRU entry
+    let _ = cache.get(dst, p3, opts).expect("touch small transform");
+
+    // Reducing the limit back to 1 MiB should evict the oversized transform while keeping the small entry
+    cache.set_limits(CacheLimits { max_entries: 10, max_bytes: limit_bytes });
+    let stats3 = cache.stats();
+    assert_eq!(stats3.entries, 1);
+    assert_eq!(stats3.total_bytes, small_bytes);
+}
