diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
-## [1.0.0] - 2023-10-10
+## [1.0.0.1] - May 16, 2024
 
+- Fix `term-variable-capture` warnings
+- Require at least GHC 9.2
+
+## [1.0.0] - October 10, 2023
+
 - Add `count`, which returns the number of timers in a timer wheel
 - Add `Seconds` type alias for readability
 - Add `Timer` newtype for readability
@@ -12,11 +17,11 @@
 - Improve the resolution of timers from microseconds to nanoseconds
 - Simplify and optimize internals
 
-## [0.4.0.1] - 2022-11-05
+## [0.4.0.1] - November 5, 2022
 
 - Fix inaccurate haddock on `recurring`
 
-## [0.4.0] - 2022-11-05
+## [0.4.0] - November 5, 2022
 
 - Add `create`
 - Rename `Data.TimerWheel` to `TimerWheel`
@@ -24,7 +29,7 @@
 - Treat negative delays as 0
 - Drop support for GHC < 8.6
 
-## [0.3.0] - 2020-06-18
+## [0.3.0] - June 18, 2020
 
 - Add `with`
 - Add support for GHC 8.8, GHC 8.10
@@ -38,11 +43,11 @@
 - Remove `InvalidTimerWheelConfig` exception. `error` is used instead
 - Remove support for GHC < 8.6
 
-## [0.2.0.1] - 2019-05-19
+## [0.2.0.1] - May 19, 2019
 
 - Swap out `ghc-prim` and `primitive` for `vector`
 
-## [0.2.0] - 2019-02-03
+## [0.2.0] - February 3, 2019
 
 - Add `destroy` function, for reaping the background thread
 - Add `recurring_` function
@@ -57,6 +62,6 @@
 - Rename `new` to `create`
 - Make recurring timers more accurate
 
-## [0.1.0] - 2018-07-18
+## [0.1.0] - July 18, 2018
 
 - Initial release
diff --git a/LICENSE b/LICENSE
--- a/LICENSE
+++ b/LICENSE
@@ -1,4 +1,4 @@
-Copyright 2018-2023 Mitchell Rosen, Travis Staton
+Copyright 2018-2024 Mitchell Dalvi Rosen, Travis Staton
 
 Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
 
diff --git a/src/TimerWheel.hs b/src/TimerWheel.hs
--- a/src/TimerWheel.hs
+++ b/src/TimerWheel.hs
@@ -32,20 +32,20 @@
 where
 
 import Control.Exception (mask_)
-import qualified Data.Atomics as Atomics
-import Data.Functor (void)
-import Data.Primitive.Array (MutableArray)
-import qualified Data.Primitive.Array as Array
-import GHC.Base (RealWorld)
-import qualified Ki
+import Data.Primitive.Array qualified as Array
+import Ki qualified
+import TimerWheel.Internal.Alarm (Alarm (..))
+import TimerWheel.Internal.AlarmBuckets (AlarmBuckets, AlarmId)
+import TimerWheel.Internal.AlarmBuckets qualified as AlarmBuckets
 import TimerWheel.Internal.Bucket (Bucket)
-import qualified TimerWheel.Internal.Bucket as Bucket
+import TimerWheel.Internal.Bucket qualified as Bucket
 import TimerWheel.Internal.Counter (Counter, decrCounter_, incrCounter, incrCounter_, newCounter, readCounter)
 import TimerWheel.Internal.Nanoseconds (Nanoseconds (..))
-import qualified TimerWheel.Internal.Nanoseconds as Nanoseconds
+import TimerWheel.Internal.Nanoseconds qualified as Nanoseconds
 import TimerWheel.Internal.Prelude
+import TimerWheel.Internal.Timer (Timer (..), cancel)
 import TimerWheel.Internal.Timestamp (Timestamp)
-import qualified TimerWheel.Internal.Timestamp as Timestamp
+import TimerWheel.Internal.Timestamp qualified as Timestamp
 
 -- | A timer wheel is a vector-of-collections-of timers to fire. Timers may be one-shot or recurring, and may be
 -- scheduled arbitrarily far in the future.
@@ -90,11 +90,12 @@
 -- |          |         | 'recurring_'   |
 -- +----------+---------+----------------+
 data TimerWheel = TimerWheel
-  { buckets :: {-# UNPACK #-} !(MutableArray RealWorld (Bucket Timer0)),
+  { buckets :: {-# UNPACK #-} !AlarmBuckets,
     resolution :: {-# UNPACK #-} !Nanoseconds,
-    numTimers :: {-# UNPACK #-} !Counter,
+    -- The number of registered alarms.
+    count :: {-# UNPACK #-} !Counter,
     -- A counter to generate unique ints that identify registered actions, so they can be canceled.
-    timerIdSupply :: {-# UNPACK #-} !Counter
+    supply :: {-# UNPACK #-} !Counter
   }
 
 -- | A timer wheel config.
@@ -125,15 +126,15 @@
   Config ->
   -- | ​
   IO TimerWheel
-create scope (Config spokes0 resolution0) = do
+create scope config = do
   buckets <- Array.newArray spokes Bucket.empty
-  numTimers <- newCounter
-  timerIdSupply <- newCounter
-  Ki.fork_ scope (runTimerReaperThread buckets numTimers resolution)
-  pure TimerWheel {buckets, numTimers, resolution, timerIdSupply}
+  count_ <- newCounter
+  supply <- newCounter
+  Ki.fork_ scope (runTimerReaperThread buckets resolution)
+  pure TimerWheel {buckets, count = count_, resolution, supply}
   where
-    spokes = if spokes0 <= 0 then 1024 else spokes0
-    resolution = Nanoseconds.fromNonNegativeSeconds (if resolution0 <= 0 then 1 else resolution0)
+    spokes = if config.spokes <= 0 then 1024 else config.spokes
+    resolution = Nanoseconds.fromNonNegativeSeconds (if config.resolution <= 0 then 1 else config.resolution)
 
 -- | Perform an action with a timer wheel.
 with ::
@@ -152,8 +153,8 @@
 --
 -- /O(1)/.
 count :: TimerWheel -> IO Int
-count TimerWheel {numTimers} =
-  readCounter numTimers
+count wheel =
+  readCounter wheel.count
 
 -- | @register wheel delay action@ registers __@action@__ in __@wheel@__ to fire after __@delay@__ seconds.
 --
@@ -168,19 +169,16 @@
   IO () ->
   -- | The timer
   IO (Timer Bool)
-register TimerWheel {buckets, numTimers, resolution, timerIdSupply} delay action = do
+register wheel delay action = do
   now <- Timestamp.now
-  let timestamp = now `Timestamp.plus` Nanoseconds.fromSeconds delay
-  let index = timestampToIndex buckets resolution timestamp
-  timerId <- incrCounter timerIdSupply
-  mask_ do
-    atomicModifyArray buckets index (Bucket.insert timerId timestamp (OneShot1 action))
-    incrCounter_ numTimers
+  let ringsAt = now `Timestamp.plus` Nanoseconds.fromSeconds delay
+  alarmId <- incrCounter wheel.supply
+  insertAlarm wheel alarmId ringsAt (OneShot (action >> decrCounter_ wheel.count))
   coerce @(IO (IO Bool)) @(IO (Timer Bool)) do
     pure do
       mask_ do
-        deleted <- atomicMaybeModifyArray buckets index (Bucket.deleteExpectingHit timerId)
-        when deleted (decrCounter_ numTimers)
+        deleted <- AlarmBuckets.delete wheel.buckets wheel.resolution alarmId ringsAt
+        when deleted (decrCounter_ wheel.count)
         pure deleted
 
 -- | Like 'register', but for when you don't intend to cancel the timer.
@@ -206,20 +204,16 @@
   IO () ->
   -- | The timer
   IO (Timer ())
-recurring TimerWheel {buckets, numTimers, resolution, timerIdSupply} (Nanoseconds.fromSeconds -> delay) action = do
+recurring wheel (Nanoseconds.fromSeconds -> delay) action = do
   now <- Timestamp.now
-  let timestamp = now `Timestamp.plus` delay
-  let index = timestampToIndex buckets resolution timestamp
-  timerId <- incrCounter timerIdSupply
+  alarmId <- incrCounter wheel.supply
   canceledRef <- newIORef False
-  mask_ do
-    atomicModifyArray buckets index (Bucket.insert timerId timestamp (Recurring1 action delay canceledRef))
-    incrCounter_ numTimers
+  insertAlarm wheel alarmId (now `Timestamp.plus` delay) (Recurring action delay canceledRef)
   coerce @(IO (IO ())) @(IO (Timer ())) do
     pure do
       mask_ do
         writeIORef canceledRef True
-        decrCounter_ numTimers
+        decrCounter_ wheel.count
 
 -- | Like 'recurring', but for when you don't intend to cancel the timer.
 recurring_ ::
@@ -229,109 +223,16 @@
   -- | The action to fire repeatedly
   IO () ->
   IO ()
-recurring_ TimerWheel {buckets, numTimers, resolution, timerIdSupply} (Nanoseconds.fromSeconds -> delay) action = do
+recurring_ wheel (Nanoseconds.fromSeconds -> delay) action = do
   now <- Timestamp.now
-  let timestamp = now `Timestamp.plus` delay
-  let index = timestampToIndex buckets resolution timestamp
-  timerId <- incrCounter timerIdSupply
-  mask_ do
-    atomicModifyArray buckets index (Bucket.insert timerId timestamp (Recurring1_ action delay))
-    incrCounter_ numTimers
-
--- | A registered timer, parameterized by the result of attempting to cancel it:
---
---     * A one-shot timer may only be canceled if it has not already fired.
---     * A recurring timer can always be canceled.
---
--- __API summary__
---
--- +-------------+----------+
--- | Create      | Modify   |
--- +=============+==========+
--- | 'register'  | 'cancel' |
--- +-------------+----------+
--- | 'recurring' |          |
--- +-------------+----------+
-newtype Timer a
-  = Timer (IO a)
-
--- | Cancel a timer.
-cancel :: Timer a -> IO a
-cancel =
-  coerce
-
--- `timestampToIndex buckets resolution timestamp` figures out which index `timestamp` corresponds to in `buckets`,
--- where each bucket corresponds to `resolution` nanoseconds.
---
--- For example, consider a three-element `buckets` with resolution `1000000000`.
---
---   +--------------------------------------+
---   | 1000000000 | 1000000000 | 1000000000 |
---   +--------------------------------------+
---
--- Some timestamp like `1053298012387` gets binned to one of the three indices 0, 1, or 2, with quick and easy maffs:
---
---   1. Figure out which index the timestamp corresponds to, if there were infinitely many:
---
---        1053298012387 `div` 1000000000 = 1053
---
---   2. Wrap around per the actual length of the array:
---
---        1053 `rem` 3 = 0
-timestampToIndex :: MutableArray RealWorld bucket -> Nanoseconds -> Timestamp -> Int
-timestampToIndex buckets resolution timestamp =
-  -- This downcast is safe because there are at most `maxBound :: Int` buckets (not that anyone would ever have that
-  -- many...)
-  fromIntegral @Word64 @Int
-    (Timestamp.epoch resolution timestamp `rem` fromIntegral @Int @Word64 (Array.sizeofMutableArray buckets))
-
-data Timer0
-  = OneShot1 !(IO ())
-  | Recurring1 !(IO ()) !Nanoseconds !(IORef Bool)
-  | Recurring1_ !(IO ()) !Nanoseconds
-
-type TimerId =
-  Int
-
-------------------------------------------------------------------------------------------------------------------------
--- Atomic operations on arrays
-
-atomicModifyArray :: forall a. MutableArray RealWorld a -> Int -> (a -> a) -> IO ()
-atomicModifyArray array index f = do
-  ticket0 <- Atomics.readArrayElem array index
-  loop ticket0
-  where
-    loop :: Atomics.Ticket a -> IO ()
-    loop ticket = do
-      (success, ticket1) <- Atomics.casArrayElem array index ticket (f (Atomics.peekTicket ticket))
-      if success then pure () else loop ticket1
-
-atomicMaybeModifyArray :: forall a. MutableArray RealWorld a -> Int -> (a -> Maybe a) -> IO Bool
-atomicMaybeModifyArray buckets index doDelete = do
-  ticket0 <- Atomics.readArrayElem buckets index
-  loop ticket0
-  where
-    loop :: Atomics.Ticket a -> IO Bool
-    loop ticket =
-      case doDelete (Atomics.peekTicket ticket) of
-        Nothing -> pure False
-        Just bucket -> do
-          (success, ticket1) <- Atomics.casArrayElem buckets index ticket bucket
-          if success then pure True else loop ticket1
+  alarmId <- incrCounter wheel.supply
+  insertAlarm wheel alarmId (now `Timestamp.plus` delay) (Recurring_ action delay)
 
-atomicExtractExpiredTimersFromBucket :: MutableArray RealWorld (Bucket Timer0) -> Int -> Timestamp -> IO (Bucket Timer0)
-atomicExtractExpiredTimersFromBucket buckets index now = do
-  ticket0 <- Atomics.readArrayElem buckets index
-  loop ticket0
-  where
-    loop :: Atomics.Ticket (Bucket Timer0) -> IO (Bucket Timer0)
-    loop ticket = do
-      let Bucket.Pair expired bucket1 = Bucket.partition now (Atomics.peekTicket ticket)
-      if Bucket.isEmpty expired
-        then pure Bucket.empty
-        else do
-          (success, ticket1) <- Atomics.casArrayElem buckets index ticket bucket1
-          if success then pure expired else loop ticket1
+insertAlarm :: TimerWheel -> AlarmId -> Timestamp -> Alarm -> IO ()
+insertAlarm wheel alarmId ringsAt alarm =
+  mask_ do
+    incrCounter_ wheel.count
+    AlarmBuckets.insert wheel.buckets wheel.resolution alarmId ringsAt alarm
 
 ------------------------------------------------------------------------------------------------------------------------
 -- Timer reaper thread
@@ -426,8 +327,8 @@
 -- If the actual time is at or after the next ideal time, that's kind of bad - it means the reaper thread is behind
 -- schedule. The user's enqueued actions have taken too long, or their wheel resolution is too short. Anyway, it's not
 -- our problem, our behavior doesn't change per whether we are behind schedule or not.
-runTimerReaperThread :: MutableArray RealWorld (Bucket Timer0) -> Counter -> Nanoseconds -> IO void
-runTimerReaperThread buckets numTimers resolution = do
+runTimerReaperThread :: AlarmBuckets -> Nanoseconds -> IO v
+runTimerReaperThread buckets resolution = do
   -- Sleep until the very first bucket of timers expires
   --
   --     resolution                         = 100
@@ -449,54 +350,46 @@
   Nanoseconds.sleep remaining
   -- Enter the Loop™
   let idealTime = now `Timestamp.plus` remaining
-  theLoop idealTime (timestampToIndex buckets resolution now)
+  theLoop idealTime (AlarmBuckets.timestampToIndex buckets resolution now)
   where
     -- `index` could be derived from `thisTime`, but it's cheaper to just store it separately and bump by 1 as we go
-    theLoop :: Timestamp -> Int -> IO void
+    theLoop :: Timestamp -> Int -> IO v
     theLoop !idealTime !index = do
-      expired2 <- atomicExtractExpiredTimersFromBucket buckets index idealTime
-      fireTimerBucket expired2
-      let !nextIdealTime = idealTime `Timestamp.plus` resolution
+      expired <- AlarmBuckets.deleteExpiredAt buckets index idealTime
+      fireBucket expired
+
       now <- Timestamp.now
+      let !nextIdealTime = idealTime `Timestamp.plus` resolution
       when (nextIdealTime > now) (Nanoseconds.sleep (nextIdealTime `Timestamp.unsafeMinus` now))
+
       theLoop nextIdealTime ((index + 1) `rem` Array.sizeofMutableArray buckets)
       where
-        fireTimerBucket :: Bucket Timer0 -> IO ()
-        fireTimerBucket bucket0 =
+        fireBucket :: Bucket Alarm -> IO ()
+        fireBucket bucket0 =
           case Bucket.pop bucket0 of
             Bucket.PopNada -> pure ()
-            Bucket.PopAlgo timerId timestamp timer bucket1 -> do
-              expired2 <- fireTimer bucket1 timerId timestamp timer
-              fireTimerBucket expired2
+            Bucket.PopAlgo alarmId ringsAt timer bucket1 -> do
+              expired <- fireAlarm bucket1 alarmId ringsAt timer
+              fireBucket expired
 
-        fireTimer :: Bucket Timer0 -> TimerId -> Timestamp -> Timer0 -> IO (Bucket Timer0)
-        fireTimer bucket timerId timestamp timer =
-          case timer of
-            OneShot1 action -> do
+        fireAlarm :: Bucket Alarm -> AlarmId -> Timestamp -> Alarm -> IO (Bucket Alarm)
+        fireAlarm bucket alarmId ringsAt alarm =
+          case alarm of
+            OneShot action -> do
               action
-              decrCounter_ numTimers
               pure bucket
-            Recurring1 action delay canceledRef ->
+            Recurring action delay canceledRef ->
               readIORef canceledRef >>= \case
                 True -> pure bucket
-                False -> do
-                  action
-                  scheduleNextOccurrence (timestamp `Timestamp.plus` delay)
-            Recurring1_ action delay -> do
-              action
-              scheduleNextOccurrence (timestamp `Timestamp.plus` delay)
+                False -> fireRecurring action delay
+            Recurring_ action delay -> fireRecurring action delay
           where
-            scheduleNextOccurrence :: Timestamp -> IO (Bucket Timer0)
-            scheduleNextOccurrence nextOccurrence =
-              if nextOccurrence < idealTime
-                then pure $! insertNextOccurrence bucket
+            fireRecurring :: IO () -> Nanoseconds -> IO (Bucket Alarm)
+            fireRecurring action delay = do
+              action
+              let ringsAtNext = ringsAt `Timestamp.plus` delay
+              if ringsAtNext < idealTime
+                then pure $! Bucket.insert alarmId ringsAtNext alarm bucket
                 else do
-                  atomicModifyArray
-                    buckets
-                    (timestampToIndex buckets resolution nextOccurrence)
-                    insertNextOccurrence
+                  AlarmBuckets.insert buckets resolution alarmId ringsAtNext alarm
                   pure bucket
-              where
-                insertNextOccurrence :: Bucket Timer0 -> Bucket Timer0
-                insertNextOccurrence =
-                  Bucket.insert timerId nextOccurrence timer
diff --git a/src/TimerWheel/Internal/Alarm.hs b/src/TimerWheel/Internal/Alarm.hs
new file mode 100644
--- /dev/null
+++ b/src/TimerWheel/Internal/Alarm.hs
@@ -0,0 +1,12 @@
+module TimerWheel.Internal.Alarm
+  ( Alarm (..),
+  )
+where
+
+import TimerWheel.Internal.Nanoseconds (Nanoseconds)
+import TimerWheel.Internal.Prelude
+
+data Alarm
+  = OneShot !(IO ())
+  | Recurring !(IO ()) {-# UNPACK #-} !Nanoseconds !(IORef Bool)
+  | Recurring_ !(IO ()) {-# UNPACK #-} !Nanoseconds
diff --git a/src/TimerWheel/Internal/AlarmBuckets.hs b/src/TimerWheel/Internal/AlarmBuckets.hs
new file mode 100644
--- /dev/null
+++ b/src/TimerWheel/Internal/AlarmBuckets.hs
@@ -0,0 +1,102 @@
+module TimerWheel.Internal.AlarmBuckets
+  ( AlarmBuckets,
+    AlarmId,
+    insert,
+    delete,
+    deleteExpiredAt,
+    timestampToIndex,
+  )
+where
+
+import Data.Atomics qualified as Atomics
+import Data.Primitive.Array (MutableArray)
+import Data.Primitive.Array qualified as Array
+import GHC.Base (RealWorld)
+import TimerWheel.Internal.Alarm (Alarm (..))
+import TimerWheel.Internal.Bucket (Bucket)
+import TimerWheel.Internal.Bucket qualified as Bucket
+import TimerWheel.Internal.Nanoseconds (Nanoseconds)
+import TimerWheel.Internal.Prelude
+import TimerWheel.Internal.Timestamp (Timestamp)
+import TimerWheel.Internal.Timestamp qualified as Timestamp
+
+type AlarmBuckets =
+  MutableArray RealWorld (Bucket Alarm)
+
+type AlarmId =
+  Int
+
+insert :: AlarmBuckets -> Nanoseconds -> AlarmId -> Timestamp -> Alarm -> IO ()
+insert buckets resolution alarmId timestamp alarm = do
+  ticket <- Atomics.readArrayElem buckets index
+  loop ticket
+  where
+    loop :: Atomics.Ticket (Bucket Alarm) -> IO ()
+    loop ticket = do
+      (success, ticket1) <-
+        Atomics.casArrayElem
+          buckets
+          index
+          ticket
+          (Bucket.insert alarmId timestamp alarm (Atomics.peekTicket ticket))
+      if success then pure () else loop ticket1
+
+    index :: Int
+    index =
+      timestampToIndex buckets resolution timestamp
+
+delete :: AlarmBuckets -> Nanoseconds -> AlarmId -> Timestamp -> IO Bool
+delete buckets resolution alarmId timestamp = do
+  ticket <- Atomics.readArrayElem buckets index
+  loop ticket
+  where
+    loop :: Atomics.Ticket (Bucket Alarm) -> IO Bool
+    loop ticket =
+      case Bucket.deleteExpectingHit alarmId (Atomics.peekTicket ticket) of
+        Nothing -> pure False
+        Just bucket -> do
+          (success, ticket1) <- Atomics.casArrayElem buckets index ticket bucket
+          if success then pure True else loop ticket1
+
+    index :: Int
+    index =
+      timestampToIndex buckets resolution timestamp
+
+deleteExpiredAt :: AlarmBuckets -> Int -> Timestamp -> IO (Bucket Alarm)
+deleteExpiredAt buckets index now = do
+  ticket <- Atomics.readArrayElem buckets index
+  loop ticket
+  where
+    loop :: Atomics.Ticket (Bucket Alarm) -> IO (Bucket Alarm)
+    loop ticket = do
+      let Bucket.Pair expired bucket1 = Bucket.partition now (Atomics.peekTicket ticket)
+      if Bucket.isEmpty expired
+        then pure Bucket.empty
+        else do
+          (success, ticket1) <- Atomics.casArrayElem buckets index ticket bucket1
+          if success then pure expired else loop ticket1
+
+-- `timestampToIndex buckets resolution timestamp` figures out which index `timestamp` corresponds to in `buckets`,
+-- where each bucket corresponds to `resolution` nanoseconds.
+--
+-- For example, consider a three-element `buckets` with resolution `1000000000`.
+--
+--   +--------------------------------------+
+--   | 1000000000 | 1000000000 | 1000000000 |
+--   +--------------------------------------+
+--
+-- Some timestamp like `1053298012387` gets binned to one of the three indices 0, 1, or 2, with quick and easy maffs:
+--
+--   1. Figure out which index the timestamp corresponds to, if there were infinitely many:
+--
+--        1053298012387 `div` 1000000000 = 1053
+--
+--   2. Wrap around per the actual length of the array:
+--
+--        1053 `rem` 3 = 0
+timestampToIndex :: AlarmBuckets -> Nanoseconds -> Timestamp -> Int
+timestampToIndex buckets resolution timestamp =
+  -- This downcast is safe because there are at most `maxBound :: Int` buckets (not that anyone would ever have that
+  -- many...)
+  fromIntegral @Word64 @Int
+    (Timestamp.epoch resolution timestamp `rem` fromIntegral @Int @Word64 (Array.sizeofMutableArray buckets))
diff --git a/src/TimerWheel/Internal/Bucket.hs b/src/TimerWheel/Internal/Bucket.hs
--- a/src/TimerWheel/Internal/Bucket.hs
+++ b/src/TimerWheel/Internal/Bucket.hs
@@ -69,13 +69,13 @@
     --      a left-bound on the diffbit, that is, the true diffbit can be no left-er than `m`.
     --   5. No key in `l` has the `m` bit set
     --   6. All keys in `r` have the `m` bit set
-    Bin {-# UNPACK #-} !TimerId {-# UNPACK #-} !Timestamp !a {-# UNPACK #-} !Mask !(Bucket a) !(Bucket a)
-  | Tip {-# UNPACK #-} !TimerId {-# UNPACK #-} !Timestamp !a
+    Bin {-# UNPACK #-} !AlarmId {-# UNPACK #-} !Timestamp !a {-# UNPACK #-} !Mask !(Bucket a) !(Bucket a)
+  | Tip {-# UNPACK #-} !AlarmId {-# UNPACK #-} !Timestamp !a
   | Nil
 
 type Mask = Word64
 
-type TimerId = Int
+type AlarmId = Int
 
 -- | An empty bucket.
 empty :: Bucket a
@@ -110,7 +110,7 @@
 -- | Insert a new timer into a bucket.
 --
 -- If a timer with the given id is already in the bucket, behavior is undefined.
-insert :: forall a. TimerId -> Timestamp -> a -> Bucket a -> Bucket a
+insert :: forall a. AlarmId -> Timestamp -> a -> Bucket a -> Bucket a
 insert i p x bucket =
   case bucket of
     Nil -> Tip i p x
@@ -141,7 +141,7 @@
     linki = link i p x
 
 data Pop a
-  = PopAlgo {-# UNPACK #-} !TimerId {-# UNPACK #-} !Timestamp !a !(Bucket a)
+  = PopAlgo {-# UNPACK #-} !AlarmId {-# UNPACK #-} !Timestamp !a !(Bucket a)
   | PopNada
 
 pop :: Bucket a -> Pop a
@@ -152,7 +152,7 @@
 {-# INLINE pop #-}
 
 -- | Delete a timer from a bucket, expecting it to be there.
-deleteExpectingHit :: TimerId -> Bucket v -> Maybe (Bucket v)
+deleteExpectingHit :: AlarmId -> Bucket v -> Maybe (Bucket v)
 deleteExpectingHit i =
   go
   where
@@ -170,12 +170,12 @@
         | otherwise -> bin j p x m l <$> go r
 
 -- | 'Bin' smart constructor, respecting the invariant that both children can't be 'Nil'.
-bin :: TimerId -> Timestamp -> v -> Mask -> Bucket v -> Bucket v -> Bucket v
+bin :: AlarmId -> Timestamp -> v -> Mask -> Bucket v -> Bucket v -> Bucket v
 bin i p x _ Nil Nil = Tip i p x
 bin i p x m l r = Bin i p x m l r
 {-# INLINE bin #-}
 
-link :: TimerId -> Timestamp -> v -> TimerId -> Bucket v -> Bucket v -> Bucket v
+link :: AlarmId -> Timestamp -> v -> AlarmId -> Bucket v -> Bucket v -> Bucket v
 link i p x j t u
   | goleft j m = Bin i p x m t u
   | otherwise = Bin i p x m u t
@@ -272,7 +272,7 @@
 -- Bit fiddling
 
 -- | Is (or should) this timer be stored on the left of this bin, given its mask?
-goleft :: TimerId -> Mask -> Bool
+goleft :: AlarmId -> Mask -> Bool
 goleft i m =
   i2w i .&. m == 0
 {-# INLINE goleft #-}
@@ -282,7 +282,7 @@
 -- j = JJJJ???????????????????
 --
 -- prefixNotEqual m i j answers, is IIII not equal to JJJJ?
-prefixNotEqual :: Mask -> TimerId -> TimerId -> Bool
+prefixNotEqual :: Mask -> AlarmId -> AlarmId -> Bool
 prefixNotEqual (prefixMask -> e) i j =
   i2w i .&. e /= i2w j .&. e
 {-# INLINE prefixNotEqual #-}
@@ -297,7 +297,7 @@
 onlyHighestBit w = unsafeShiftL 1 (WORD_SIZE_IN_BITS - 1 - countLeadingZeros w)
 {-# INLINE onlyHighestBit #-}
 
-i2w :: TimerId -> Word64
+i2w :: AlarmId -> Word64
 i2w = fromIntegral
 {-# INLINE i2w #-}
 
diff --git a/src/TimerWheel/Internal/Prelude.hs b/src/TimerWheel/Internal/Prelude.hs
--- a/src/TimerWheel/Internal/Prelude.hs
+++ b/src/TimerWheel/Internal/Prelude.hs
@@ -1,16 +1,17 @@
 module TimerWheel.Internal.Prelude
   ( Seconds,
-    module X,
+    module Reexport,
   )
 where
 
-import Control.Monad as X (when)
-import Data.Coerce as X (coerce)
+import Control.Monad as Reexport (when)
+import Data.Coerce as Reexport (coerce)
 import Data.Fixed (E9, Fixed)
-import Data.IORef as X (IORef, newIORef, readIORef, writeIORef)
-import Data.Word as X (Word64)
-import GHC.Generics as X (Generic)
-import Prelude as X hiding (lookup, null)
+import Data.Functor as Reexport (void)
+import Data.IORef as Reexport (IORef, newIORef, readIORef, writeIORef)
+import Data.Word as Reexport (Word64)
+import GHC.Generics as Reexport (Generic)
+import Prelude as Reexport hiding (lookup, null)
 
 -- | A number of seconds, with nanosecond precision.
 --
diff --git a/src/TimerWheel/Internal/Timer.hs b/src/TimerWheel/Internal/Timer.hs
new file mode 100644
--- /dev/null
+++ b/src/TimerWheel/Internal/Timer.hs
@@ -0,0 +1,29 @@
+module TimerWheel.Internal.Timer
+  ( Timer (..),
+    cancel,
+  )
+where
+
+import TimerWheel.Internal.Prelude
+
+-- | A registered timer, parameterized by the result of attempting to cancel it:
+--
+--     * A one-shot timer may only be canceled if it has not already fired.
+--     * A recurring timer can always be canceled.
+--
+-- __API summary__
+--
+-- +-------------+----------+
+-- | Create      | Modify   |
+-- +=============+==========+
+-- | 'register'  | 'cancel' |
+-- +-------------+----------+
+-- | 'recurring' |          |
+-- +-------------+----------+
+newtype Timer a
+  = Timer (IO a)
+
+-- | Cancel a timer.
+cancel :: Timer a -> IO a
+cancel =
+  coerce
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -124,7 +124,7 @@
 class Assert a b where
   is :: a -> b -> IO ()
 
-instance (Eq e, Exception e) => Assert (IO void) (Exn e) where
+instance (Eq e, Exception e) => Assert (IO v) (Exn e) where
   is mx y = do
     try (void mx) >>= \case
       Left ex
diff --git a/timer-wheel.cabal b/timer-wheel.cabal
--- a/timer-wheel.cabal
+++ b/timer-wheel.cabal
@@ -1,24 +1,25 @@
 cabal-version: 3.0
 
-author: Mitchell Rosen
+author: Mitchell Dalvi Rosen
 bug-reports: https://github.com/awkward-squad/timer-wheel/issues
 build-type: Simple
 category: Data
+copyright: (c) 2018-2024 Mitchell Dalvi Rosen, Travis Staton
+homepage: https://github.com/awkward-squad/timer-wheel
+license-file: LICENSE
+license: BSD-3-Clause
+maintainer: Mitchell Dalvi Rosen <mitchellwrosen@gmail.com>, Travis Staton <hello@travisstaton.com>
+name: timer-wheel
+synopsis: A timer wheel
+tested-with: GHC == 9.6.5, GHC == 9.8.2, GHC == 9.10.1
+version: 1.0.0.1
+
 description:
   This library provides a timer wheel data structure for registering one-shot or recurring @IO@ actions to fire after a
   given amount of time.
   .
   It is similar to @TimerManager@ from @GHC.Event@, but supports recurring actions, and can scale to handle many more
   registered actions.
-copyright: (c) 2018-2023 Mitchell Rosen, Travis Staton
-homepage: https://github.com/awkward-squad/timer-wheel
-license-file: LICENSE
-license: BSD-3-Clause
-maintainer: Mitchell Rosen <mitchellwrosen@gmail.com>, Travis Staton <hello@travisstaton.com>
-name: timer-wheel
-synopsis: A timer wheel
-tested-with: GHC == 9.4.7, GHC == 9.6.3, GHC == 9.8.1
-version: 1.0.0
 
 extra-source-files:
   CHANGELOG.md
@@ -30,28 +31,19 @@
 
 common component
   build-depends:
-    base ^>= 4.12 || ^>= 4.13 || ^>= 4.14 || ^>= 4.15 || ^>= 4.16 || ^>= 4.17 || ^>= 4.18 || ^>= 4.19,
+    base ^>= 4.16 || ^>= 4.17 || ^>= 4.18 || ^>= 4.19 || ^>= 4.20,
   default-extensions:
-    BangPatterns
     BlockArguments
     DeriveAnyClass
-    DeriveGeneric
     DerivingStrategies
     DuplicateRecordFields
-    FlexibleInstances
-    GeneralizedNewtypeDeriving
     LambdaCase
-    MultiParamTypeClasses
     MultiWayIf
     NamedFieldPuns
-    NoImplicitPrelude
-    NumericUnderscores
-    OverloadedStrings
-    ScopedTypeVariables
-    TupleSections
-    TypeApplications
+    NoFieldSelectors
+    OverloadedRecordDot
     ViewPatterns
-  default-language: Haskell2010
+  default-language: GHC2021
   ghc-options:
     -Weverything
     -Wno-all-missed-specialisations
@@ -83,10 +75,13 @@
     TimerWheel
   hs-source-dirs: src
   other-modules:
+    TimerWheel.Internal.Alarm
+    TimerWheel.Internal.AlarmBuckets
     TimerWheel.Internal.Bucket
     TimerWheel.Internal.Counter
     TimerWheel.Internal.Nanoseconds
     TimerWheel.Internal.Prelude
+    TimerWheel.Internal.Timer
     TimerWheel.Internal.Timestamp
 
 test-suite tests
@@ -105,7 +100,7 @@
   build-depends:
     ki,
     random ^>= 1.2,
-    tasty-bench,
+    tasty-bench ^>= 0.3.5,
     timer-wheel,
   ghc-options: -fproc-alignment=64 -rtsopts -threaded "-with-rtsopts=-N2 -T -A32m"
   hs-source-dirs: bench
