diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,9 @@
+## [2.2.0](https://github.com/haskell-github-trust/effectful-data-cache/compare/2.1.0...2.2.0) (2026-10-27)
+
+### Features
+
+* add stm-based interpreter ([5b76933](https://github.com/haskell-github-trust/effectful-data-cache/commit/5b7693322b1fef87f9753aaf24e527764bf688a2))
+
 ## [2.1.0](https://github.com/haskell-github-trust/effectful-data-cache/compare/2.0.2...2.1.0) (2026-10-26)
 
 ### Features
diff --git a/effectful-data-cache.cabal b/effectful-data-cache.cabal
--- a/effectful-data-cache.cabal
+++ b/effectful-data-cache.cabal
@@ -5,7 +5,7 @@
 -- see: https://github.com/sol/hpack
 
 name:           effectful-data-cache
-version:        0.2.1.0
+version:        0.2.2.0
 synopsis:       Data cache effect for the `effectful` system
 description:    Please see the README on GitHub at <https://github.com/haskell-github-trust/effectful-data-cache#readme>
 category:       cache
@@ -29,6 +29,7 @@
 library
   exposed-modules:
       Effectful.Cache
+      Effectful.Cache.STM
   other-modules:
       Paths_effectful_data_cache
   autogen-modules:
@@ -42,12 +43,15 @@
     , clock ==0.8.*
     , effectful-core >=2.3 && <3
     , hashable >=1.5 && <2
+    , stm >=2.5 && <3
+    , unordered-containers ==0.2.*
   default-language: Haskell2010
 
 test-suite effectful-data-cache-test
   type: exitcode-stdio-1.0
   main-is: Spec.hs
   other-modules:
+      Effectful.Cache.STMSpec
       Effectful.CacheSpec
       Paths_effectful_data_cache
   autogen-modules:
@@ -65,4 +69,6 @@
     , effectful-data-cache
     , hashable >=1.5 && <2
     , hspec
+    , stm >=2.5 && <3
+    , unordered-containers ==0.2.*
   default-language: Haskell2010
diff --git a/src/Effectful/Cache/STM.hs b/src/Effectful/Cache/STM.hs
new file mode 100644
--- /dev/null
+++ b/src/Effectful/Cache/STM.hs
@@ -0,0 +1,91 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeOperators #-}
+
+-- | 'Cache' interpreter over a 'HashMap' in a 'TVar', without "Data.Cache".
+module Effectful.Cache.STM (
+  Store,
+  newStore,
+  runCacheSTM,
+  runCacheSTMWith,
+) where
+
+import Control.Concurrent.STM (
+  STM,
+  TVar,
+  atomically,
+  modifyTVar',
+  newTVarIO,
+  readTVar,
+  writeTVar,
+ )
+import qualified Data.HashMap.Strict as HM
+import Effectful (Eff, IOE, MonadIO (liftIO), type (:>))
+import Effectful.Cache (Cache (..))
+import Effectful.Dispatch.Dynamic (interpret)
+import System.Clock (Clock (Monotonic), TimeSpec, getTime)
+
+-- | Entries with their absolute expiration, and the default expiration.
+--
+-- @since 0.2.2.0
+data Store k v = Store (TVar (HM.HashMap k (v, Maybe TimeSpec))) (TVar (Maybe TimeSpec))
+
+-- | Fresh store with the given default expiration.
+--
+-- @since 0.2.2.0
+newStore :: Maybe TimeSpec -> IO (Store k v)
+newStore ts = Store <$> newTVarIO HM.empty <*> newTVarIO ts
+
+-- | Run against a fresh store with the given default expiration.
+--
+-- @since 0.2.2.0
+runCacheSTM
+  :: forall k v es a
+   . IOE :> es
+  => Maybe TimeSpec
+  -> Eff (Cache k v : es) a
+  -> Eff es a
+runCacheSTM ts eff = do
+  s <- liftIO (newStore ts)
+  runCacheSTMWith s eff
+
+-- | Run against an existing 'Store', e.g. one shared between threads.
+--
+-- @since 0.2.2.0
+runCacheSTMWith
+  :: forall k v es a
+   . IOE :> es
+  => Store k v
+  -> Eff (Cache k v : es) a
+  -> Eff es a
+runCacheSTMWith s = interpret $ \_ op -> liftIO $ do
+  now <- getTime Monotonic
+  atomically (withStore s now op)
+
+withStore :: Store k v -> TimeSpec -> Cache k v m a -> STM a
+withStore (Store ref def) now op = case op of
+  Insert k v -> readTVar def >>= \ts -> modifyTVar' ref (HM.insert k (v, (now +) <$> ts))
+  Insert' ts k v -> modifyTVar' ref (HM.insert k (v, (now +) <$> ts))
+  Lookup k -> do
+    m <- HM.lookup k <$> readTVar ref
+    case m of
+      Just (v, e) | live e -> pure (Just v)
+      Just _ -> Nothing <$ modifyTVar' ref (HM.delete k)
+      Nothing -> pure Nothing
+  Lookup' k -> do
+    m <- HM.lookup k <$> readTVar ref
+    pure $ case m of
+      Just (v, e) | live e -> Just v
+      _ -> Nothing
+  Keys -> HM.keys <$> readTVar ref
+  Delete k -> modifyTVar' ref (HM.delete k)
+  FilterWithKey p -> modifyTVar' ref (HM.filterWithKey (\k (v, _) -> p k v))
+  Purge -> writeTVar ref HM.empty
+  PurgeExpired -> modifyTVar' ref (HM.filter (live . snd))
+  Size -> HM.size <$> readTVar ref
+  DefaultExpiration -> readTVar def
+  SetDefaultExpiration ts -> writeTVar def ts
+ where
+  live = maybe True (> now)
diff --git a/test/Effectful/Cache/STMSpec.hs b/test/Effectful/Cache/STMSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Effectful/Cache/STMSpec.hs
@@ -0,0 +1,9 @@
+module Effectful.Cache.STMSpec (spec) where
+
+import Effectful (runEff)
+import Effectful.Cache.STM (runCacheSTM)
+import Effectful.CacheSpec (specWith)
+import Test.Hspec (Spec)
+
+spec :: Spec
+spec = specWith (\ts -> runEff . runCacheSTM ts)
diff --git a/test/Effectful/CacheSpec.hs b/test/Effectful/CacheSpec.hs
--- a/test/Effectful/CacheSpec.hs
+++ b/test/Effectful/CacheSpec.hs
@@ -1,7 +1,8 @@
 {-# LANGUAGE DataKinds #-}
+{-# LANGUAGE RankNTypes #-}
 {-# LANGUAGE TypeApplications #-}
 
-module Effectful.CacheSpec (spec) where
+module Effectful.CacheSpec (spec, specWith) where
 
 import Data.List (sort)
 import Effectful
@@ -10,14 +11,16 @@
 import Test.Hspec
 import Prelude hiding (lookup)
 
-run :: Maybe TimeSpec -> Eff '[Cache String Int, IOE] a -> IO a
-run ts = runEff . runCache ts
+type Runner = forall a. Maybe TimeSpec -> Eff '[Cache String Int, IOE] a -> IO a
 
 expired :: Maybe TimeSpec
 expired = Just (TimeSpec (-1) 0)
 
 spec :: Spec
-spec = do
+spec = specWith (\ts -> runEff . runCache ts)
+
+specWith :: Runner -> Spec
+specWith run = do
   it "looks up what it inserted" $ do
     r <- run Nothing $ do
       insert "a" (1 :: Int)
