diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,24 @@
+## 0.3.0.0
+
+* Use `MonadUnliftIO` in functions
+* Drop dedicated worker threads
+
+## 0.2.2.0
+
+* Rely on `unliftio` for exception handling
+
+## 0.2.1.0
+
+* Use `QSemN` to stop all workers at once
+
+## 0.2.0.0
+
+* Add `WaitingStopStrategy` and helper functions
+
+## 0.1.0.1
+
+* Fix missing `WorkQueue` export
+
+## 0.1.0.0
+
+* Initial version
diff --git a/poolboy.cabal b/poolboy.cabal
--- a/poolboy.cabal
+++ b/poolboy.cabal
@@ -1,8 +1,8 @@
 cabal-version:       3.0
 name:                poolboy
-version:             0.2.2.0
+version:             0.3.0.0
 author:              Gautier DI FOLCO
-maintainer:          gautier.difolco@gmail.com
+maintainer:          foss@difolco.dev
 category:            Data
 build-type:          Simple
 license:             ISC
@@ -10,14 +10,16 @@
 synopsis:            Simple work queue for bounded concurrency
 description:         In-memory work queue helping with load management.
 Homepage:            https://github.com/blackheaven/poolboy
-tested-with:         GHC==9.2.4
+tested-with:         GHC==9.8.4
 
+extra-source-files:
+    CHANGELOG.md
+
 library
   default-language:   Haskell2010
   build-depends:
-      base == 4.*
-    , async == 2.*
-    , stm == 2.*
+      base >= 4.2 && <5
+    , unordered-containers >= 0.2.10.0 && <3
     , unliftio == 0.2.*
   hs-source-dirs: src
   exposed-modules:
@@ -49,7 +51,7 @@
       TypeOperators
   ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wpartial-fields -Wredundant-constraints
 
-test-suite poolboy-test
+test-suite spec
   type: exitcode-stdio-1.0
   hs-source-dirs: test
   main-is: Spec.hs
@@ -84,4 +86,5 @@
     , poolboy
     , hspec
     , hspec-core
+    , timeit >= 1.0.0.0 && < 3
   default-language: Haskell2010
diff --git a/src/Data/Poolboy.hs b/src/Data/Poolboy.hs
--- a/src/Data/Poolboy.hs
+++ b/src/Data/Poolboy.hs
@@ -1,10 +1,12 @@
+{-# LANGUAGE TupleSections #-}
+
 module Data.Poolboy
   ( -- * Configuration
     PoolboySettings (..),
     WorkersCountSettings (..),
     defaultPoolboySettings,
     poolboySettingsWith,
-    simpleSerializedLogger,
+    poolboySettingsName,
 
     -- * Running
     WorkQueue,
@@ -24,24 +26,34 @@
 
     -- * Enqueueing
     enqueue,
+    enqueueTracking,
     enqueueAfter,
+    enqueueAfterTracking,
+    WorkQueueStoppedException,
   )
 where
 
-import Control.Concurrent
-import Control.Concurrent.Async
-import Control.Concurrent.STM.TQueue
+import Control.Exception (BlockedIndefinitelyOnMVar (BlockedIndefinitelyOnMVar))
 import Control.Monad
-import Control.Monad.STM
-import Data.Maybe (isNothing)
-import System.Timeout (timeout)
-import UnliftIO.Exception (bracket, tryAny)
+import Data.Functor (($>))
+import qualified Data.HashMap.Strict as HM
+import Data.Maybe (listToMaybe)
+import GHC.Conc (labelThread)
+import GHC.Stack (HasCallStack, callStack, getCallStack, prettySrcLoc, withFrozenCallStack)
+import UnliftIO (MonadIO (liftIO), MonadUnliftIO)
+import UnliftIO.Async
+import UnliftIO.Concurrent
+import UnliftIO.Exception
+import UnliftIO.IORef
+import UnliftIO.QSemN
+import UnliftIO.Timeout (timeout)
 
 -- | Initial settings
 data PoolboySettings = PoolboySettings
   { workersCount :: WorkersCountSettings,
-    log :: String -> IO ()
+    workQueueName :: String
   }
+  deriving stock (Eq, Show)
 
 -- | Initial number of threads
 data WorkersCountSettings
@@ -50,181 +62,192 @@
   | FixedWCS Int -- arbitrary number
   deriving stock (Eq, Show)
 
--- | Usual configuration 'CapabilitiesWCS' and no log
-defaultPoolboySettings :: PoolboySettings
+-- | Usual configuration 'CapabilitiesWCS' and no name
+defaultPoolboySettings :: (HasCallStack) => PoolboySettings
 defaultPoolboySettings =
-  PoolboySettings
-    { workersCount = CapabilitiesWCS,
-      log = \x -> seq x $ return ()
-    }
+  withFrozenCallStack $
+    PoolboySettings
+      { workersCount = CapabilitiesWCS,
+        workQueueName =
+          case listToMaybe $ getCallStack callStack of
+            Nothing -> "<no name>"
+            Just (f, loc) -> "<no name> created in " <> f <> ", called at " <> prettySrcLoc loc
+      }
 
--- | Arbitrary-numbered settings
+-- | Arbitrary-numbered of workers settings
 poolboySettingsWith :: Int -> PoolboySettings
 poolboySettingsWith c = defaultPoolboySettings {workersCount = FixedWCS c}
 
--- | Simple (but not particularly performant) serialized logger
-simpleSerializedLogger :: IO (String -> IO ())
-simpleSerializedLogger = do
-  logLock <- newMVar ()
-  return $ \x ->
-    withMVar logLock $ \() -> do
-      putStrLn x
-      return ()
+-- | Name of the work queue settings (used in error and debugging)
+poolboySettingsName :: String -> PoolboySettings -> PoolboySettings
+poolboySettingsName n s = s {workQueueName = n}
 
 -- | 'backet'-based usage (recommended)
-withPoolboy :: PoolboySettings -> WaitingStopStrategy -> (WorkQueue -> IO a) -> IO a
-withPoolboy settings waitStopWorkQueue = bracket (newPoolboy settings) (\wq -> stopWorkQueue wq >> waitStopWorkQueue wq)
+withPoolboy :: (MonadUnliftIO m) => PoolboySettings -> WaitingStopStrategy m -> (WorkQueue -> m a) -> m a
+withPoolboy settings waitStopWorkQueue =
+  bracket
+    (newPoolboy settings)
+    (\wq -> stopWorkQueue wq >> waitStopWorkQueue wq)
 
 -- | Standalone/manual usage
-newPoolboy :: PoolboySettings -> IO WorkQueue
+newPoolboy :: (MonadUnliftIO m) => PoolboySettings -> m WorkQueue
 newPoolboy settings = do
-  wq <-
-    WorkQueue
-      <$> newTQueueIO
-      <*> newTQueueIO
-      <*> newEmptyMVar
-      <*> newQSemN 0
-
   count <-
     case settings.workersCount of
       CapabilitiesWCS -> getNumCapabilities
       FixedWCS x -> return x
 
-  changeDesiredWorkersCount wq count
-  void $
-    forkIO $
-      controller $
-        WorkQueueControllerState
-          { commands = wq.commands,
-            queue = wq.queue,
-            stopped = wq.stopped,
-            log = settings.log,
-            workers = [],
-            waitingWorkers = wq.waitingWorkers,
-            capabilityCount = 0
-          }
-
-  return wq
+  WorkQueue settings.workQueueName
+    <$> newQSemN count
+    <*> newIORef count
+    <*> newEmptyMVar
+    <*> newIORef mempty
 
 -- | Request a worker number adjustment
-changeDesiredWorkersCount :: WorkQueue -> Int -> IO ()
-changeDesiredWorkersCount wq =
-  atomically . writeTQueue wq.commands . ChangeDesiredWorkersCount
+--
+-- Warning: non-concurrent operation
+changeDesiredWorkersCount :: (MonadUnliftIO m) => WorkQueue -> Int -> m ()
+changeDesiredWorkersCount wq n = do
+  ensureRunning wq
+  refWorkers <- readIORef wq.maxWorkers
+  when (n > 0) $ do
+    writeIORef wq.maxWorkers n
+    if refWorkers < n
+      then signalQSemN wq.availableWorkers $ n - refWorkers
+      else
+        void $
+          async' wq $
+            waitQSemN wq.availableWorkers $
+              refWorkers - n
 
--- | Request stopping wrokers
-stopWorkQueue :: WorkQueue -> IO ()
-stopWorkQueue wq =
-  atomically $ writeTQueue wq.commands Stop
+-- | Request stopping wokers
+stopWorkQueue :: (MonadUnliftIO m) => WorkQueue -> m ()
+stopWorkQueue wq = do
+  stopped <- isStopedWorkQueue wq
+  unless stopped $ do
+    void $ tryPutMVar wq.stopped ()
 
 -- | Non-blocking check of the work queue's running status
-isStopedWorkQueue :: WorkQueue -> IO Bool
-isStopedWorkQueue wq =
-  not <$> isEmptyMVar wq.stopped
+isStopedWorkQueue :: (MonadUnliftIO m) => WorkQueue -> m Bool
+isStopedWorkQueue wq = not <$> isEmptyMVar wq.stopped
 
-type WaitingStopStrategy = WorkQueue -> IO ()
+type WaitingStopStrategy m = WorkQueue -> m ()
 
 -- | Block until the queue is totally stopped (no more running worker)
-waitingStopFinishWorkers :: WaitingStopStrategy
+waitingStopFinishWorkers :: (MonadUnliftIO m) => WaitingStopStrategy m
 waitingStopFinishWorkers wq =
-  void $ tryAny $ readMVar wq.stopped
+  void $
+    retryBlocked 10 $ do
+      readMVar wq.stopped
+      let waitWorkerCompletion = do
+            workers <- readIORef wq.inflightWorkers
+            unless (HM.null workers) $ do
+              forM_ (HM.elems workers) waitCatch
+              atomicModifyIORef wq.inflightWorkers $ \ws ->
+                (foldr HM.delete ws $ HM.keys workers, ())
+              threadDelay 10000
+              waitWorkerCompletion
 
+      waitWorkerCompletion
+      workersCount <- atomicModifyIORef' wq.maxWorkers (0,)
+      waitQSemN wq.availableWorkers workersCount
+
 -- | Block until the queue is totally stopped or deadline (in micro seconds) is reached
-waitingStopTimeout :: Int -> WaitingStopStrategy
+waitingStopTimeout :: (MonadUnliftIO m) => Int -> WaitingStopStrategy m
 waitingStopTimeout delay wq = void $ timeout delay $ waitingStopFinishWorkers wq
 
 -- | Enqueue one action in the work queue (non-blocking)
-enqueue :: WorkQueue -> IO () -> IO ()
-enqueue wq =
-  atomically . writeTQueue wq.queue . Right
+--
+-- Throws 'WorkQueueStoppedException' if the work queue is stopped
+enqueue :: (MonadUnliftIO m) => WorkQueue -> m () -> m ()
+enqueue wq = void . enqueueTracking wq
 
+-- | Enqueue one action in the work queue (non-blocking)
+--
+-- Throws 'WorkQueueStoppedException' if the work queue is stopped
+enqueueTracking :: (MonadUnliftIO m) => WorkQueue -> m a -> m (Async a)
+enqueueTracking wq f = do
+  ensureRunning wq
+  enqueueTrackingAfterUnsafe wq (return ()) f
+
 -- | Block until one worker is available
-waitReadyQueue :: WorkQueue -> IO ()
+waitReadyQueue :: (MonadUnliftIO m) => WorkQueue -> m ()
 waitReadyQueue wq = do
-  waitQSemN wq.waitingWorkers 1
-  signalQSemN wq.waitingWorkers 1
+  ensureRunning wq
+  waitQSemN wq.availableWorkers 1
+  signalQSemN wq.availableWorkers 1
 
 -- | Enqueue action and some actions to be run after it
-enqueueAfter :: (Foldable f) => WorkQueue -> IO () -> f (IO ()) -> IO ()
-enqueueAfter wq x xs =
-  enqueue wq $ do
-    x
-    forM_ xs $ enqueue wq
+--
+-- Throws 'WorkQueueStoppedException' if the work queue is stopped
+enqueueAfter :: (Traversable f, MonadUnliftIO m) => WorkQueue -> m () -> f (m ()) -> m ()
+enqueueAfter wq x xs = void $ enqueueAfterTracking wq x xs
 
+-- | Enqueue action and some actions to be run after it
+--
+-- Throws 'WorkQueueStoppedException' if the work queue is stopped
+enqueueAfterTracking :: (Traversable f, MonadUnliftIO m) => WorkQueue -> m a -> f (m b) -> m (Async a, f (Async b))
+enqueueAfterTracking wq x xs = do
+  rm <- enqueueTracking wq x
+  st <- forM xs $ enqueueTrackingAfterUnsafe wq (wait rm)
+  return (rm, st)
+
 -- Support (internal)
 data WorkQueue = WorkQueue
-  { commands :: TQueue Commands,
-    queue :: TQueue (Either () (IO ())),
+  { name :: String,
+    availableWorkers :: QSemN,
+    maxWorkers :: IORef Int,
     stopped :: MVar (),
-    waitingWorkers :: QSemN
+    inflightWorkers :: IORef (HM.HashMap ThreadId (Async ()))
   }
 
-data Commands
-  = ChangeDesiredWorkersCount Int
-  | Stop
-  deriving stock (Show)
+-- | Ensure the queue is stopped
+--
+-- Throws 'WorkQueueStoppedException' if not
+ensureRunning :: (MonadUnliftIO m) => WorkQueue -> m ()
+ensureRunning wq = do
+  stopped <- isStopedWorkQueue wq
+  when stopped $
+    throwIO $
+      WorkQueueStoppedException wq.name
 
-data WorkQueueControllerState = WorkQueueControllerState
-  { commands :: TQueue Commands,
-    queue :: TQueue (Either () (IO ())),
-    stopped :: MVar (),
-    log :: String -> IO (),
-    workers :: [Async ()],
-    waitingWorkers :: QSemN,
-    capabilityCount :: Int
-  }
+newtype WorkQueueStoppedException = WorkQueueStoppedException {stoppedWorkQueue :: String}
+  deriving stock (Eq, Show)
 
-controller :: WorkQueueControllerState -> IO ()
-controller wq = do
-  command <- atomically $ readTQueue wq.commands
-  let stopOneWorker = atomically $ writeTQueue wq.queue $ Left ()
-      getLiveWorkers = filterM (fmap isNothing . poll) wq.workers
-      prefix = "Controller: "
-  wq.log $ prefix <> "Command: " <> show command
-  case command of
-    ChangeDesiredWorkersCount n -> do
-      liveWorkers <- getLiveWorkers
-      let diff = length liveWorkers - n
-      (newWorkers, newCapabilityCount) <-
-        if diff > 0
-          then do
-            replicateM_ diff stopOneWorker
-            return ([], wq.capabilityCount)
-          else do
-            let newWorkersCount = abs diff
-            newWorkers <- forM [1 .. newWorkersCount] $ \capability -> do
-              wq.log $ prefix <> "Pre-fork"
-              asyncOn (capability - 1) $ worker $ WorkQueueWorkerState {queue = wq.queue, waitingWorkers = wq.waitingWorkers, log = wq.log}
-            return (newWorkers, wq.capabilityCount + newWorkersCount)
-      controller $ wq {workers = newWorkers <> liveWorkers, capabilityCount = newCapabilityCount}
-    Stop -> do
-      liveWorkers <- getLiveWorkers
-      let currentCount = length liveWorkers
-      wq.log $ prefix <> "Stopping " <> show currentCount <> " workers"
-      waitQSemN wq.waitingWorkers currentCount
-      replicateM_ currentCount stopOneWorker
-      void $ tryPutMVar wq.stopped ()
+instance Exception WorkQueueStoppedException
 
-data WorkQueueWorkerState = WorkQueueWorkerState
-  { queue :: TQueue (Either () (IO ())),
-    waitingWorkers :: QSemN,
-    log :: String -> IO ()
-  }
+-- | Enqueue one action in the work queue (non-blocking)
+--
+-- Does not check if the queue is stopping
+enqueueTrackingAfterUnsafe :: forall a m i. (MonadUnliftIO m) => WorkQueue -> m i -> m a -> m (Async a)
+enqueueTrackingAfterUnsafe wq prerequisite f = do
+  task <-
+    async' wq $ do
+      threadId <- myThreadId
+      void prerequisite
+      bracket_
+        (waitQSemN wq.availableWorkers 1)
+        ( signalQSemN wq.availableWorkers 1
+            >> atomicModifyIORef wq.inflightWorkers (\ws -> (HM.delete threadId ws, ()))
+        )
+        f
+  atomicModifyIORef wq.inflightWorkers (\ws -> (HM.insert (asyncThreadId task) (task $> ()) ws, ()))
+  return task
 
-worker :: WorkQueueWorkerState -> IO ()
-worker wq = do
-  workerId <- show <$> myThreadId
-  let prefix = "Worker [" <> workerId <> "]: "
-  wq.log $ prefix <> "Starting"
-  let loop = do
-        signalQSemN wq.waitingWorkers 1
-        command <- atomically $ readTQueue wq.queue
-        case command of
-          Left () -> do
-            wq.log $ prefix <> "Stopping"
-          Right act -> do
-            waitQSemN wq.waitingWorkers 1
-            wq.log (prefix <> "pop")
-            void (tryAny act)
-            wq.log (prefix <> "poped")
-            loop
-  loop
+-- f
+
+-- | Start and label a thread
+async' :: (MonadUnliftIO m) => WorkQueue -> m a -> m (Async a)
+async' wq f = do
+  t <- async f
+  liftIO $ labelThread (asyncThreadId t) wq.name
+  return t
+
+retryBlocked :: (MonadUnliftIO m) => Int -> m a -> m (Either BlockedIndefinitelyOnMVar a)
+retryBlocked n f = do
+  r <- try f
+  case r of
+    Left BlockedIndefinitelyOnMVar
+      | n > 0 -> threadDelay 1000 >> retryBlocked (n - 1) f
+      | otherwise -> try f
+    Right x -> return $ Right x
diff --git a/test/Spec.hs b/test/Spec.hs
--- a/test/Spec.hs
+++ b/test/Spec.hs
@@ -6,6 +6,7 @@
 import Data.IORef
 import Data.Maybe
 import Data.Poolboy
+import System.TimeIt
 import System.Timeout
 import Test.Hspec
 
@@ -17,34 +18,40 @@
   describe "Poolboy" $ do
     replicateM_ 1 $
       it "threadDelay should be absorbed in mulitple threads" $ do
-        computations <-
-          timeout 2500 $
-            withPoolboy (poolboySettingsWith 100) waitingStopFinishWorkers $ \wq -> do
-              replicateM_ 100 $ enqueue wq $ threadDelay 1000
+        (duration, computations) <-
+          timeItT $
+            timeout 250000 $
+              withPoolboy (poolboySettingsWith 100) waitingStopFinishWorkers $ \wq -> do
+                replicateM_ 100 $ enqueue wq $ threadDelay 1000
         computations `shouldSatisfy` isJust
+        duration `shouldSatisfy` (< 20000)
     replicateM_ 1 $
       it "should be resilient to errors and Exceptions" $ do
         witness <- newIORef False
-        computations <-
-          timeout 10000 $
-            withPoolboy (poolboySettingsWith 5) waitingStopFinishWorkers $ \wq -> do
-              mapM_ (enqueue wq) [error "an error", throw RandomException, writeIORef witness True]
+        (duration, computations) <-
+          timeItT $
+            timeout 1000000 $
+              withPoolboy (poolboySettingsWith 5) waitingStopFinishWorkers $ \wq -> do
+                mapM_ (enqueue wq) [error "an error", throw RandomException, writeIORef witness True]
         computations `shouldSatisfy` isJust
+        duration `shouldSatisfy` (< 20000)
         readIORef witness `shouldReturn` True
     replicateM_ 1 $
       it "enqueueing when working on a stopping work queue should run all jobs" $ do
         counter <- newIORef @Int 0
-        let incrAnd wq next =
-              enqueueAfter
-                wq
-                (threadDelay 100 >> atomicModifyIORef' counter (\n -> (n + 1, ())))
-                [next]
-        computations <-
-          timeout 10000 $
-            withPoolboy (poolboySettingsWith 10) waitingStopFinishWorkers $ \wq -> do
-              replicateM_ 10 $ incrAnd wq $ incrAnd wq $ incrAnd wq $ incrAnd wq $ incrAnd wq $ return ()
+        let incr = do
+              threadDelay 100
+              atomicModifyIORef' counter (\n -> (n + 1, ()))
+        (duration, computations) <-
+          timeItT $
+            timeout 100000000 $
+              withPoolboy (poolboySettingsWith 10) waitingStopFinishWorkers $ \wq -> do
+                replicateM_ 10 $
+                  enqueueAfter wq incr $
+                    replicate 10 incr
         computations `shouldSatisfy` isJust
-        readIORef counter `shouldReturn` 50
+        duration `shouldSatisfy` (< 2000)
+        readIORef counter `shouldReturn` 110
 
 data RandomException = RandomException
   deriving (Show)
