diff --git a/.travis.yml b/.travis.yml
new file mode 100644
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,94 @@
+# This Travis job script has been generated by a script via
+#
+#   runghc make_travis_yml_2.hs 'cabal.project'
+#
+# For more information, see https://github.com/hvr/multi-ghc-travis
+#
+language: c
+sudo: false
+
+git:
+  submodules: false  # whether to recursively clone submodules
+
+cache:
+  directories:
+    - $HOME/.cabal/packages
+    - $HOME/.cabal/store
+
+before_cache:
+  - rm -fv $HOME/.cabal/packages/hackage.haskell.org/build-reports.log
+  # remove files that are regenerated by 'cabal update'
+  - rm -fv $HOME/.cabal/packages/hackage.haskell.org/00-index.*
+  - rm -fv $HOME/.cabal/packages/hackage.haskell.org/*.json
+  - rm -fv $HOME/.cabal/packages/hackage.haskell.org/01-index.cache
+  - rm -fv $HOME/.cabal/packages/hackage.haskell.org/01-index.tar
+  - rm -fv $HOME/.cabal/packages/hackage.haskell.org/01-index.tar.idx
+
+  - rm -rfv $HOME/.cabal/packages/head.hackage
+
+matrix:
+  include:
+    - compiler: "ghc-8.0.2"
+      addons: {apt: {packages: [ghc-ppa-tools,cabal-install-head,ghc-8.0.2], sources: [hvr-ghc]}}
+    - compiler: "ghc-8.2.2"
+      addons: {apt: {packages: [ghc-ppa-tools,cabal-install-head,ghc-8.2.2], sources: [hvr-ghc]}}
+    - compiler: "ghc-8.4.3"
+      addons: {apt: {packages: [ghc-ppa-tools,cabal-install-head,ghc-8.4.3], sources: [hvr-ghc]}}
+
+before_install:
+  - HC=${CC}
+  - HCPKG=${HC/ghc/ghc-pkg}
+  - unset CC
+  - ROOTDIR=$(pwd)
+  - mkdir -p $HOME/.local/bin
+  - "PATH=/opt/ghc/bin:/opt/ghc-ppa-tools/bin:$HOME/local/bin:$PATH"
+  - HCNUMVER=$(( $(${HC} --numeric-version|sed -E 's/([0-9]+)\.([0-9]+)\.([0-9]+).*/\1 * 10000 + \2 * 100 + \3/') ))
+  - echo $HCNUMVER
+
+install:
+  - cabal --version
+  - echo "$(${HC} --version) [$(${HC} --print-project-git-commit-id 2> /dev/null || echo '?')]"
+  - BENCH=${BENCH---enable-benchmarks}
+  - TEST=${TEST---enable-tests}
+  - HADDOCK=${HADDOCK-true}
+  - INSTALLED=${INSTALLED-true}
+  - GHCHEAD=${GHCHEAD-false}
+  - travis_retry cabal update -v
+  - "sed -i.bak 's/^jobs:/-- jobs:/' ${HOME}/.cabal/config"
+  - rm -fv cabal.project cabal.project.local
+  - grep -Ev -- '^\s*--' ${HOME}/.cabal/config | grep -Ev '^\s*$'
+  - "printf 'packages: \".\"\\n' > cabal.project"
+  - cat cabal.project
+  - if [ -f "./configure.ac" ]; then
+      (cd "." && autoreconf -i);
+    fi
+  - rm -f cabal.project.freeze
+  - cabal new-build -w ${HC} ${TEST} ${BENCH} --project-file="cabal.project" --dep -j2
+  - cabal new-build -w ${HC} --disable-tests --disable-benchmarks --project-file="cabal.project" --dep -j2
+  - rm -rf .ghc.environment.* "."/dist
+  - DISTDIR=$(mktemp -d /tmp/dist-test.XXXX)
+
+# Here starts the actual work to be performed for the package under test;
+# any command which exits with a non-zero exit code causes the build to fail.
+script:
+  # test that source-distributions can be generated
+  - (cd "." && cabal sdist)
+  - mv "."/dist/timer-wheel-*.tar.gz ${DISTDIR}/
+  - cd ${DISTDIR} || false
+  - find . -maxdepth 1 -name '*.tar.gz' -exec tar -xvf '{}' \;
+  - "printf 'packages: timer-wheel-*/*.cabal\\n' > cabal.project"
+  - cat cabal.project
+  # this builds all libraries and executables (without tests/benchmarks)
+  - cabal new-build -w ${HC} --disable-tests --disable-benchmarks all
+
+  # Build with installed constraints for packages in global-db
+  - if $INSTALLED; then echo cabal new-build -w ${HC} --disable-tests --disable-benchmarks $(${HCPKG} list --global --simple-output --names-only | sed 's/\([a-zA-Z0-9-]\{1,\}\) */--constraint="\1 installed" /g') all | sh; else echo "Not building with installed constraints"; fi
+
+  # build & run tests, build benchmarks
+  - cabal new-build -w ${HC} ${TEST} ${BENCH} all
+
+  # cabal check
+  - (cd timer-wheel-* && cabal check)
+
+# REGENDATA ["cabal.project"]
+# EOF
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,11 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](http://keepachangelog.com/)
+and this project adheres to the [Haskell Package Versioning Policy](https://pvp.haskell.org/).
+
+## [0.1.0] - 2018-07-18
+
+### Added
+- Initial release
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,11 @@
+Copyright 2018 Mitchell Rosen
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/src/Data/TimerWheel.hs b/src/Data/TimerWheel.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/TimerWheel.hs
@@ -0,0 +1,295 @@
+{-# language CPP                 #-}
+{-# language LambdaCase          #-}
+{-# language MagicHash           #-}
+{-# language NamedFieldPuns      #-}
+{-# language RecursiveDo         #-}
+{-# language ScopedTypeVariables #-}
+{-# language UnboxedTuples       #-}
+{-# language ViewPatterns        #-}
+
+{-# options_ghc -funbox-strict-fields #-}
+
+module Data.TimerWheel
+  ( -- * Timer wheel
+    TimerWheel
+  , new
+  , register
+  , register_
+  , recurring
+  ) where
+
+import Entries (Entries)
+import Supply (Supply)
+
+import qualified Entries as Entries
+import qualified Supply
+
+import Control.Concurrent
+import Control.Exception
+import Control.Monad
+import Data.Fixed (E6, Fixed(MkFixed))
+import Data.Foldable
+import Data.IORef
+import Data.Primitive.MutVar
+import Data.Primitive.UnliftedArray
+import Data.Word (Word64)
+import GHC.Base (IO(IO), mkWeak#)
+#if MIN_VERSION_base(4,11,0)
+import GHC.Clock (getMonotonicTimeNSec)
+#else
+import System.Clock (Clock(Monotonic), getTime, toNanoSecs)
+#endif
+import GHC.Prim (RealWorld)
+import GHC.Weak (Weak(Weak), deRefWeak)
+
+import qualified GHC.Event as GHC
+
+-- | A 'TimerWheel' is a vector-of-collections-of timers to fire. It is
+-- configured with a /spoke count/ and /resolution/. A timeout thread is spawned
+-- to step through the timer wheel and fire expired timers at regular intervals.
+--
+-- * The /spoke count/ determines the size of the timer vector.
+--
+--     * A __larger spoke count__ will result in __less insert contention__ at
+--       each spoke and will require __more memory__ to store the timer wheel.
+--
+--     * A __smaller spoke count__ will result in __more insert contention__ at
+--       each spoke and will require __less memory__ to store the timer wheel.
+--
+-- * The /resolution/ determines both the duration of time that each spoke
+--   corresponds to, and how often the timeout thread wakes. For example, with a
+--   resolution of __@1s@__, a timer that expires at __@2.5s@__ will not fire
+--   until the timeout thread wakes at __@3s@__.
+--
+--     * A __larger resolution__ will result in __more insert contention__ at
+--       each spoke, __less accurate__ timers, and will require
+--       __fewer wakeups__ by the timeout thread.
+--
+--     * A __smaller resolution__ will result in __less insert contention__ at
+--       each spoke, __more accurate__ timers, and will require __more wakeups__
+--       by the timeout thread.
+--
+-- * The timeout thread has three important properties:
+--
+--     * There is only one, and it fires expired timers synchronously. If your
+--       timer actions execute quicky, 'register' them directly. Otherwise,
+--       consider registering an action that enqueues the /real/ action to be
+--       performed on a job queue.
+--
+--     * Synchronous exceptions thrown by enqueued @IO@ actions will bring the
+--       thread down, and no more timeouts will ever fire. If you want to catch
+--       exceptions and log them, for example, you will have to bake this into
+--       the registered actions yourself.
+--
+--     * The life of the timeout thread is scoped to the life of the timer
+--       wheel. When the timer wheel is garbage collected, the timeout thread
+--       will automatically stop doing work, and die gracefully.
+--
+-- Below is a depiction of a timer wheel with @6@ timers inserted across @8@
+-- spokes, and a resolution of @0.1s@.
+--
+-- @
+--    0s   .1s   .2s   .3s   .4s   .5s   .6s   .7s   .8s
+--    +-----+-----+-----+-----+-----+-----+-----+-----+
+--    |     | A   |     | B,C | D   |     |     | E,F |
+--    +-----+-----+-----+-----+-----+-----+-----+-----+
+-- @
+data TimerWheel =  TimerWheel
+  { wheelResolution :: !Word64
+    -- ^ The length of time that each entry corresponds to, in nanoseconds.
+  , wheelSupply :: !Supply
+    -- ^ A supply of unique ints.
+  , wheelEntries :: !(UnliftedArray (MutVar RealWorld Entries))
+    -- ^ The array of collections of timers.
+  }
+
+-- | @new n s@ creates a 'TimerWheel' with __@n@__ spokes and a resolution of
+-- __@s@__ seconds.
+new :: Int -> Fixed E6 -> IO TimerWheel
+new slots (MkFixed (fromInteger -> resolution)) = do
+  wheel :: UnliftedArray (MutVar RealWorld Entries) <- do
+    wheel :: MutableUnliftedArray RealWorld (MutVar RealWorld Entries) <-
+      unsafeNewUnliftedArray slots
+    for_ [0..slots-1] $ \i ->
+      writeUnliftedArray wheel i =<< newMutVar Entries.empty
+    freezeUnliftedArray wheel 0 slots
+
+  supply :: Supply <-
+    Supply.new
+
+  weakWheel :: Weak (UnliftedArray (MutVar RealWorld Entries)) <-
+    case wheel of
+      UnliftedArray wheel# ->
+        IO $ \s ->
+          case mkWeak# wheel# wheel (\t -> (# t, () #)) s of
+            (# s', w #) ->
+              (# s', Weak w #)
+
+  (void . forkIO)
+    (reaper resolution (sizeofUnliftedArray wheel) weakWheel)
+
+  pure TimerWheel
+    { wheelResolution = fromIntegral resolution * 1000
+    , wheelSupply = supply
+    , wheelEntries = wheel
+    }
+
+reaper
+  :: Int
+  -> Int
+  -> Weak (UnliftedArray (MutVar RealWorld Entries))
+  -> IO ()
+reaper resolution len weakWheel = do
+  manager :: GHC.TimerManager <-
+    GHC.getSystemTimerManager
+  loop manager 0
+ where
+  -- Reaper loop: we haven't yet run the entries at index 'i'.
+  loop :: GHC.TimerManager -> Int -> IO ()
+  loop manager i = do
+    -- Sleep until the roughly the next bucket.
+    waitVar :: MVar () <-
+      newEmptyMVar
+
+    mask_ $ do
+      key :: GHC.TimeoutKey <-
+        GHC.registerTimeout manager resolution (putMVar waitVar ())
+      takeMVar waitVar `onException` GHC.unregisterTimeout manager key
+
+    now :: Word64 <-
+      getMonotonicTime
+
+    -- De-ref the wheel, and if it's gone, we go too.
+    deRefWeak weakWheel >>= \case
+      Nothing ->
+        pure ()
+      Just wheel -> do
+        -- Figure out which bucket we're in. Usually this will be 'i+1', but
+        -- maybe we were scheduled a bit early and ended up in 'i', or maybe
+        -- running the entries in bucket 'i-1' took a long time and we slept all
+        -- the way to 'i+2'. In any case, we should run the entries in buckets
+        -- up-to-but-not-including this one, beginning with bucket 'i'.
+
+        let
+          j :: Int
+          j =
+            fromIntegral (now `div` (fromIntegral resolution * 1000))
+              `mod` len
+
+        let
+          is :: [Int]
+          is =
+            if j >= i
+              then
+                [i .. j-1]
+              else
+                [i .. len - 1] ++ [0 .. j-1]
+
+        -- To actually run the entries in a bucket, partition them into expired
+        -- (count == 0) and alive (count > 0). Run the expired entries and
+        -- decrement the alive entries' counts by 1.
+
+        for_ is $ \k -> do
+          let
+            entriesRef :: MutVar RealWorld Entries
+            entriesRef =
+              indexUnliftedArray wheel k
+
+          join
+            (atomicModifyMutVar' entriesRef
+              (\entries ->
+                if Entries.null entries
+                  then
+                    (entries, pure ())
+                  else
+                    case Entries.squam entries of
+                      (expired, alive) ->
+                        (alive, sequence_ expired)))
+        loop manager j
+
+-- | @register n m w@ registers an action __@m@__ in timer wheel __@w@__ to fire
+-- after __@n@__ seconds.
+--
+-- Returns an action that, when called, attempts to cancel the timer, and
+-- returns whether or not it was successful (@False@ means the timer has already
+-- fired).
+register :: Fixed E6 -> IO () -> TimerWheel -> IO (IO Bool)
+register (MkFixed ((*1000) . fromIntegral -> delay)) action wheel = do
+  newEntryId :: Int <-
+    Supply.next (wheelSupply wheel)
+
+  entriesVar :: MutVar RealWorld Entries <-
+    entriesIn delay wheel
+
+  atomicModifyMutVar' entriesVar
+    (\entries ->
+      (Entries.insert newEntryId entryCount action entries, ()))
+
+  pure $ do
+    atomicModifyMutVar' entriesVar
+      (\entries ->
+        case Entries.delete newEntryId entries of
+          Nothing ->
+            (entries, False)
+          Just entries' ->
+            (entries', True))
+ where
+  entryCount :: Word64
+  entryCount =
+    delay `div`
+      (fromIntegral (sizeofUnliftedArray (wheelEntries wheel))
+        * wheelResolution wheel)
+
+-- | Like 'register', but for when you don't intend to cancel the timer.
+register_ :: Fixed E6 -> IO () -> TimerWheel -> IO ()
+register_ delay action wheel =
+  void (register delay action wheel)
+
+-- | @recurring n m w@ registers an action __@m@__ in timer wheel __@w@__ to
+-- fire every __@n@__ seconds.
+--
+-- Returns an action that, when called, cancels the recurring timer.
+recurring :: Fixed E6 -> IO () -> TimerWheel -> IO (IO ())
+recurring delay action wheel = mdo
+  cancel :: IO Bool <-
+    register delay (action' cancelRef) wheel
+  cancelRef :: IORef (IO Bool) <-
+    newIORef cancel
+  pure (untilTrue (join (readIORef cancelRef)))
+ where
+  action' :: IORef (IO Bool) -> IO ()
+  action' cancelRef = do
+    action
+    cancel :: IO Bool <-
+      register delay (action' cancelRef) wheel
+    writeIORef cancelRef cancel
+
+-- | @entriesIn delay wheel@ returns the bucket in @wheel@ that corresponds to
+-- @delay@ nanoseconds from now.
+entriesIn :: Word64 -> TimerWheel -> IO (MutVar RealWorld Entries)
+entriesIn delay TimerWheel{wheelResolution, wheelEntries} = do
+  now :: Word64 <-
+    getMonotonicTime
+  pure (index ((now+delay) `div` wheelResolution))
+ where
+  index :: Word64 -> MutVar RealWorld Entries
+  index i =
+    indexUnliftedArray wheelEntries
+      (fromIntegral i `rem` sizeofUnliftedArray wheelEntries)
+
+-- | Repeat an IO action until it returns 'True'.
+untilTrue :: IO Bool -> IO ()
+untilTrue action =
+  action >>= \case
+    True ->
+      pure ()
+    False ->
+      untilTrue action
+
+getMonotonicTime :: IO Word64
+getMonotonicTime =
+#if MIN_VERSION_base(4,11,0)
+  getMonotonicTimeNSec
+#else
+  fromIntegral . toNanoSecs <$> getTime Monotonic
+#endif
diff --git a/src/Entries.hs b/src/Entries.hs
new file mode 100644
--- /dev/null
+++ b/src/Entries.hs
@@ -0,0 +1,71 @@
+{-# language LambdaCase #-}
+{-# language TypeApplications #-}
+
+module Entries
+  ( Entries
+  , empty
+  , Entries.null
+  , size
+  , insert
+  , delete
+  , squam
+  ) where
+
+import Data.Coerce
+import Data.IntPSQ (IntPSQ)
+import Data.Word (Word64)
+
+import qualified Data.IntPSQ as IntPSQ
+
+newtype Entries
+  = Entries (IntPSQ Word64 (IO ()))
+
+-- | An empty collection.
+empty :: Entries
+empty =
+  Entries IntPSQ.empty
+{-# INLINABLE empty #-}
+
+null :: Entries -> Bool
+null =
+  coerce (IntPSQ.null @Word64 @(IO ()))
+{-# INLINABLE null #-}
+
+-- | The number of timers in the collection.
+size :: Entries -> Int
+size =
+  coerce (IntPSQ.size @Word64 @(IO ()))
+{-# INLINABLE size #-}
+
+-- | @insert i n m x@ inserts callback @m@ into collection @x@ with unique
+-- identifier @i@ and "count" @n@. The
+insert :: Int -> Word64 -> IO () -> Entries -> Entries
+insert i n m =
+  coerce (IntPSQ.unsafeInsertNew i n m)
+{-# INLINABLE insert #-}
+
+-- | Delete a timer by id. Returns 'Nothing' if the timer was not found.
+delete :: Int -> Entries -> Maybe Entries
+delete =
+  coerce delete_
+{-# INLINABLE delete #-}
+
+delete_ :: Int -> IntPSQ Word64 (IO ()) -> Maybe (IntPSQ Word64 (IO ()))
+delete_ i xs =
+  (\(_, _, ys) -> ys) <$> IntPSQ.deleteView i xs
+
+-- | Extract expired timers.
+squam :: Entries -> ([IO ()], Entries)
+squam (Entries entries) =
+  case IntPSQ.atMostView 0 entries of
+    (expired, alive) ->
+      (map f expired, Entries (IntPSQ.unsafeMapMonotonic g alive))
+ where
+  f :: (Int, Word64, IO ()) -> IO ()
+  f (_, _, m) =
+    m
+
+  g :: Int -> Word64 -> IO () -> (Word64, IO ())
+  g _ n m =
+    (n-1, m)
+{-# INLINABLE squam #-}
diff --git a/src/Supply.hs b/src/Supply.hs
new file mode 100644
--- /dev/null
+++ b/src/Supply.hs
@@ -0,0 +1,19 @@
+module Supply
+  ( Supply
+  , new
+  , next
+  ) where
+
+import Data.Atomics.Counter (AtomicCounter, incrCounter, newCounter)
+
+newtype Supply
+  = Supply AtomicCounter
+
+new :: IO Supply
+new =
+  Supply <$> newCounter 0
+
+next :: Supply -> IO Int
+next (Supply counter) =
+  incrCounter 1 counter
+{-# INLINE next #-}
diff --git a/timer-wheel.cabal b/timer-wheel.cabal
new file mode 100644
--- /dev/null
+++ b/timer-wheel.cabal
@@ -0,0 +1,76 @@
+cabal-version: 2.0
+
+name: timer-wheel
+version: 0.1.0
+category: Data
+description:
+  This library provides a timer wheel data structure for
+  .
+  * /O(1)/ registering @IO@ actions to fire after a given amount of time
+  * /O(1)/ canceling registered actions
+  .
+  It is similar to @TimerManager@ from "GHC.Event", but can scale much better
+  under concurrent access patterns.
+  .
+  An unreleased version of this library has a @backpack@-based signature for
+  selecting from a variety of different data structures used internally.
+  However, until @backpack@ has broader support on Hackage, and in Haddocks,
+  etc, I decided to just hard-code something decent (a priority search queue).
+  This makes the @O(1)@ claims above a bit of a lie, as these rely on using a
+  linked list data structure internally.
+synopsis: A timer wheel
+author: Mitchell Rosen
+maintainer: Mitchell Rosen <mitchellwrosen@gmail.com>
+homepage: https://github.com/mitchellwrosen/timer-wheel
+bug-reports: https://github.com/mitchellwrosen/timer-wheel/issues
+copyright: (c) 2018, Mitchell Rosen
+license: BSD3
+license-file: LICENSE
+build-type: Simple
+tested-with: GHC == 8.0.2, GHC == 8.2.2, GHC == 8.4.3
+
+extra-source-files:
+  .travis.yml
+  CHANGELOG.md
+
+source-repository head
+  type: git
+  location: git://github.com/mitchellwrosen/timer-wheel.git
+
+library
+  build-depends:
+      atomic-primops ^>= 0.8
+    , base ^>= 4.9 || ^>= 4.10 || ^>= 4.11
+    , ghc-prim ^>= 0.5
+    , primitive ^>= 0.6
+    , psqueues ^>= 0.2.7
+  if !impl(ghc >= 8.4)
+    build-depends:
+      clock ^>= 0.7
+  default-language:
+    Haskell2010
+  exposed-modules:
+    Data.TimerWheel
+  ghc-options:
+    -fprint-expanded-synonyms
+    -fprint-explicit-foralls
+    -fprint-explicit-kinds
+    -fprint-unicode-syntax
+    -Wall
+    -Wcompat
+    -Widentities
+    -Wincomplete-record-updates
+    -Wincomplete-uni-patterns
+    -Wmissing-local-signatures
+    -Wnoncanonical-monad-instances
+    -Wnoncanonical-monadfail-instances
+    -Wredundant-constraints
+  if impl(ghc >= 8.4)
+    ghc-options:
+      -Wpartial-fields
+      -Wmissing-export-lists
+  hs-source-dirs:
+    src
+  other-modules:
+    Entries
+    Supply
