diff --git a/quasar-wayland.cabal b/quasar-wayland.cabal
index 3e31b23a45e5e7029c27ecfacb00f8a1fe66b868..f1b49e3149e1427f9ac5975ee1fa13332891a166 100644
--- a/quasar-wayland.cabal
+++ b/quasar-wayland.cabal
@@ -75,6 +75,7 @@ library
   import: shared-properties
   exposed-modules:
     Quasar.Wayland.Client
+    Quasar.Wayland.Client.Buffer
     Quasar.Wayland.Client.Registry
     Quasar.Wayland.Client.Socket
     Quasar.Wayland.Client.Sync
diff --git a/src/Quasar/Wayland/Client/Buffer.hs b/src/Quasar/Wayland/Client/Buffer.hs
new file mode 100644
index 0000000000000000000000000000000000000000..6ad8e969cff4fa072771c0e069243852c36000dc
--- /dev/null
+++ b/src/Quasar/Wayland/Client/Buffer.hs
@@ -0,0 +1,106 @@
+module Quasar.Wayland.Client.Buffer (
+  -- * wl_shm
+  ShmBufferManager(formats),
+  newShmBufferManager,
+  newShmPool,
+  newShmBuffer,
+) where
+
+import Control.Concurrent.STM
+import Control.Monad.Catch
+import Data.Set qualified as Set
+import Foreign
+import Quasar
+import Quasar.Awaitable
+import Quasar.Prelude
+import Quasar.Wayland.Client
+import Quasar.Wayland.Protocol
+import Quasar.Wayland.Protocol.Generated
+import Quasar.Wayland.Utils.SharedMemory
+import System.Posix.IO (closeFd)
+import System.Posix.Types (Fd)
+
+
+data Buffer = Buffer {
+  wlBuffer :: Object 'Client Interface_wl_buffer,
+  currentlyUsed :: TVar Bool
+}
+
+
+data ShmBufferManager = ShmBufferManager {
+  wlShm :: Object 'Client Interface_wl_shm,
+  formats :: Awaitable (Set.Set Word32)
+}
+
+newShmBufferManager :: WaylandClient -> STM ShmBufferManager
+newShmBufferManager client = do
+  formatsVar <- newTVar mempty
+
+  wlShm <- bindSingleton @Interface_wl_shm client.registry
+  setEventHandler wlShm $ EventHandler_wl_shm {
+    format = modifyTVar formatsVar . Set.insert
+  }
+
+  -- Formats are emittet all at once; sync ensures the list is complete
+  formatListComplete <- client.sync
+  let formats = formatListComplete >> unsafeAwaitSTM (readTVar formatsVar)
+
+  pure ShmBufferManager {
+    wlShm,
+    formats
+  }
+
+data ShmPool = ShmPool {
+  wlShmPool :: Object 'Client Interface_wl_shm_pool,
+  shmPtr :: TVar (Maybe (ForeignPtr Word8))
+}
+
+newShmPool :: ShmBufferManager -> Int32 -> IO ShmPool
+newShmPool shm size = do
+  (wlShmPool, ptr) <- trySendShm size (\fd -> shm.wlShm.create_pool fd size)
+
+  shmPtr <- newTVarIO (Just ptr)
+
+  pure ShmPool {
+    wlShmPool,
+    shmPtr
+  }
+
+
+newShmBuffer
+  :: ShmBufferManager
+  -> Int32
+  -> Int32
+  -> IO (Object 'Client Interface_wl_buffer, ForeignPtr Word32)
+newShmBuffer shm width height = do
+  (wlShmPool, ptr) <- trySendShm size (\fd -> shm.wlShm.create_pool fd size)
+
+  wlBuffer <- liftIO $ atomically $
+    wlShmPool.create_buffer offset width height stride pixelFormat
+
+  atomically wlShmPool.destroy
+
+  pure (wlBuffer, castForeignPtr ptr)
+
+  where
+    bytePerPixel = 4
+    offset = 0
+    stride = width * bytePerPixel
+    size = width * height * bytePerPixel
+    pixelFormat = 0 -- argb8888
+
+
+trySendShm :: Int32 -> (Fd -> STM a) -> IO (a, ForeignPtr Word8)
+trySendShm size sendAction = do
+  fd <- memfdCreate $ fromIntegral size
+
+  -- Has to be created before sending since the fd will be closed when sent
+  ptr <- mmap fd $ fromIntegral size
+
+  -- Fd ownership is transferred to the outbox (it will be closed after it has
+  -- been sent)
+  result <- atomically (sendAction fd)
+    `onException`
+      (closeFd fd >> finalizeForeignPtr ptr)
+
+  pure (result, ptr)