ticker(Dispatchers.IO) { readStats() } // 1 Hz, on IO
ticker(Dispatchers.Default, period = 250.milliseconds) { it } // 4 HzOne function. It asks the Linux kernel to fire exactly on the wall-clock boundary and wires that signal into the main thread's existing event loop. No threads, no drift, no wakelocks.
It started with a clock app. The status bar clock flips to 12:00:01… and my
clock flips a beat later. Or early. Or — worst of all — sometimes in sync,
so the jitter is what your eye catches.
Every timer Android hands you schedules a relative delay and hopes it lands near the time you wanted:
delay(1000)in a loop measures from when the coroutine resumed, so every lap adds queue latency to the last — classic accumulating drift.Handler.postDelayedruns onuptimeMillis, which isn't even the wall clock — pausing during deep sleep and never noticing the user changed the time.CountDownTimerandTimerinherit both problems, andTimerspawns a whole thread just to be late.
You can fight it — re-align after every tick, listen for ACTION_TIME_CHANGED
broadcasts, do modular arithmetic on System.currentTimeMillis() — and every
clock app ends up with that same pile of corrective code. The kernel, however,
has had the right primitive since Linux 2.6.25: a timer you arm absolutely
("fire at 12:00:02.000000000 REALTIME"), that tells you when the user changes
the clock. Android just never exposed it as an API.
This repo is that missing API.
| Ghost | Known as | Haunting behavior |
|---|---|---|
| 🔴 Blinky | CountDownTimer |
drifts, and dies with its Handler |
| 🩷 Pinky | Handler.postDelayed |
queue-dependent, always a few ms behind you |
| 🩵 Inky | Timer / TimerTask |
spawns a thread just to be late |
| 🟠 Clyde | coroutine delay loop |
wanders, accumulates drift, needs re-alignment |
ticker is the power pellet. The ghosts turn blue and the boundary is yours.
A timerfd — a Linux kernel timer exposed as a file descriptor — is armed
absolutely on CLOCK_REALTIME boundaries and registered into the main
thread's Looper via ALooper_addFd. The main thread's own epoll_wait —
the same one already servicing input and vsync — wakes when the kernel fires.
- No thread is created. The tick arrives on the main thread's existing wait.
- No
Messageis allocated. Per-tick cost is an 8-byteread()and a channel send. - No drift is possible. Expiries are absolute timestamps, not stacked delays.
This is the same primitive Chromium uses internally for its Android message pump — exposed here as a one-line ticker API. Same maze, secret warp tunnel.
👻 postDelayed / delay loop |
🟡 ticker | |
|---|---|---|
| Boundary accuracy | 1–10ms late, queue-dependent | 36–924µs (measured, Pixel 6 Pro) |
| Clock domain | uptimeMillis, faked to wall time |
CLOCK_REALTIME, native |
| User sets the clock | wrong until you wire up broadcasts | kernel reports it (TFD_TIMER_CANCEL_ON_SET); corrective tick fires immediately |
| Accumulated drift | yes, needs re-alignment logic | none — absolute expiries |
| Threads created | 0–1 depending on flavor | 0 |
| Idle cost when nobody collects | n/a | zero — the fd doesn't exist |
From Maven Central:
dependencies {
implementation("tech.thessemaj:ticker:1.0.0")
}Also on GitHub Packages
(needs a GitHub token with read:packages), or grab ticker-1.0.0.aar
straight from the
release:
implementation(files("libs/ticker-1.0.0.aar"))// In a ViewModel — per-tick work runs on IO, timer stays on the main looper:
val ticks: StateFlow<Stats?> =
ticker(Dispatchers.IO) { epochSeconds -> sample(epochSeconds) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)The flow is:
- cold — each collector gets its own kernel timer (one player, one machine); cancellation releases it.
- conflated — a slow collector skips to the newest boundary instead of replaying missed dots.
- fail-fast — if the kernel timer can't be created it throws; it never silently degrades to a weaker mechanism. No continues on a broken cabinet.
period is any positive Duration (default 1 second). Ticks fire on exact
wall-clock multiples of the period — a 250ms ticker lands on
:000/:250/:500/:750 — and each tick carries the exact boundary timestamp in
epoch millis, so consecutive ticks differ by exactly the period — or by a
multiple of it, if the process was stalled or frozen across a boundary.
Shorter periods mean proportionally more wake-ups; regardless of period, the
ticker never holds a wakelock and cannot wake a suspended device (plain
CLOCK_REALTIME, not _ALARM — the machine sleeps when the arcade closes).
See GAME PAUSED below for what that means across a freeze or a suspend.
The transform lambda is part of the API on purpose: it's the only way to
guarantee your per-tick work runs on the dispatcher you named. A .map added
after the flow would run in the collector's context — the classic Flow
threading footgun, made unrepresentable. That ghost can't enter this maze.
Need the raw callback instead of a Flow? TickerFd(onTick) with
start()/stop() (main thread only) is the escape hatch.
Two different things can stop your process from running, and they get confused a lot:
- Device suspend — the whole SoC sleeps and nothing runs anywhere. A
PowerManagerwakelock is what prevents this. ticker never takes one, and arms plainCLOCK_REALTIMErather thanCLOCK_REALTIME_ALARM, so it deliberately cannot wake a sleeping device. - Cached process freezing — the device is awake, but your process is
moved into the cgroup freezer and gets
zero CPU time.
The main thread's
epoll_waitsimply never returns. A wakelock does not prevent this, and neither can any app-level library — it's the system's call.
Freezing applies to processes in the cached state, and per
AOSP an app
with a visible Activity or a running foreground service doesn't enter that
state. The exposure is at the edges: after an FGS times out or stops and the
process falls to cached.
That's AOSP behaviour, though — vendor power management can be considerably more aggressive, and none of this is a promise about every device. It doesn't change the outcome either way: however your process stopped running, a gap costs you ticks, not accuracy.
Either way, what a gap costs you is ticks, not accuracy:
0s 1s 2s 3s 4s ──── frozen ──── 8s 9s 10s 11s
grid | | | | |░░░░░░░░░░░░░░| | | | |
ticker ● ● ● ● ● ◍ ● ● ● ●
delay() ● ● ● ● ● ◍ ● ● ●
↑
thaw
● tick on the grid
◍ catch-up tick at the thaw — carries the last boundary's timestamp
ticker → one off-grid catch-up tick, then back on the grid for good
delay() → restarts its countdown from the thaw, so every tick stays late
Freezes don't end on boundaries, so the first tick back is delivered whenever
the thaw happens. What it carries is still an exact boundary: the kernel
banks expiries as absolute timestamps while you're frozen, and on thaw
read() returns
how many expirations occurred
so the dispatcher skips the backlog to the newest one. That value is always a
whole multiple of the period, it's at most one period old, and it doesn't
re-phase anything — the kernel timer is never re-armed, so every later tick is
on the grid. A relative delay(1000) loop folds the thaw lateness into its
next arming and stays late forever; that's the whole difference.
The ticks inside the gap are simply gone. Nothing was executing, and no mechanism can conjure them back.
Device suspend behaves the same from your side — no ticks while the device sleeps, and a correct on-grid tick shortly after it wakes.
So: use the boundary timestamp each tick carries, and never count ticks to measure elapsed time. GAME PAUSED, not GAME OVER — the maze is exactly where you left it.
- Zero threads — verified: ticks arrive on the thread that registered (tid == pid in the demo), and the library never spawns anything.
- Zero leaks — instrumented test runs 100 start/stop cycles and asserts the process fd table is unchanged. Every dot eaten, none left behind.
- ~7KB per ABI — no C++ stdlib (
ANDROID_STL=none), no exceptions/RTTI, hidden symbol visibility,-Oz. The only dependency is kotlinx-coroutines. - R8-safe — consumer keep rules ship inside the AAR; the JNI name lookups survive minified release builds.
- Lifecycle-correct by construction — collection stopping (screen off,
WhileSubscribedtimeout) closes the fd; there is nothing to forget. GAME OVER actually means game over.
:ticker— the library (Kotlin API + ~150 lines of C).:app— demo: a clock whose digits flip on the true second, with the tick-transform's thread shown live under it.
The demo is the cabinet itself. One lap of the corridor = one minute:
Pac-Man's position, his chomp, the eaten dots, the score (10 pts per tick),
and even the INSERT COIN blink are pure functions of the tick — there is
no continuous animation anywhere. If Pac-Man chomps, a timerfd fired on the
boundary. A frozen Pac-Man means no ticks. Two consecutive seconds on a
Pixel 6 Pro:
minSdk 24, NDK/CMake toolchain for building from source. No Compose, no lifecycle, no transitive baggage — bring your own UI layer.

