async-timer
The missing Python async timer.
The problem
The obvious way to run something on an interval in asyncio:
async def refresh():
while True:
await do_the_thing()
await asyncio.sleep(5)
This works until it doesn't:
- It drifts.
sleep(5)runs afterdo_the_thing()finishes, so a 2-second call gives you a 7-second period. Drift compounds. - It has no cancellation story. Cancelling the wrapping task
during
do_the_thing()may interrupt mid-write; you need explicit shielding and cleanup to handle this safely. - The first call isn't observable. Want to wait until the cache is
populated before serving traffic? You have to bolt on an
Event. - There's nowhere for consumers to subscribe. If something else wants the latest value (or every value), you're hand-rolling a queue or a fanout.
- It doesn't compose. Running ten of these on startup and
cancelling them together on shutdown needs a
TaskGroupplus bookkeeping.
async-timer is what you actually want: a recurring async call with
proper lifecycle, two delivery models, and the operational knobs
(fixed-rate vs fixed-delay, jitter, trigger-now, cross-thread control)
that real production code ends up needing.
Zero runtime dependencies. Python 3.9+.
Install
pip install async-timer
The 30-second example: FastAPI cache warmup
import contextlib
import time
import uvicorn
from fastapi import FastAPI
import async_timer
DB_CACHE = {"initialised": False}
async def refresh_db():
DB_CACHE.update(initialised=True, cur_value=time.time())
@contextlib.asynccontextmanager
async def lifespan(_app: FastAPI):
async with async_timer.Timer(delay=5, target=refresh_db) as timer:
await timer.wait(hit_count=1) # block startup until the first refresh
yield # serve traffic; timer keeps refreshing
app = FastAPI(lifespan=lifespan)
@app.get("/")
async def root():
return {"db_cache": DB_CACHE}
Two lines do the heavy lifting:
async with Timer(...) as timerstarts on enter, cancels on exit (and awaits cleanup — no orphan task).await timer.wait(hit_count=1)gates startup on the first successful refresh.
More recipes: docs/recipes/. Runnable scripts: docs/examples/.
Features
- Zero runtime dependencies.
- Any callable shape. Sync or async functions, generators, async generators, or callables returning any of those.
- Two delivery models.
join()/wait()/async for selfis single-shot fan-out (latest value, may drop intermediate ticks under slow consumers).subscribe()gives each consumer a buffered queue (every tick, optionalmaxsizefor bounded drop-oldest). - Scheduling modes.
fixed_delay(default; next tick firesdelayafter the previous one finishes) orfixed_rate(anchored to wall clock; missed slots skipped + logged). Optionalinitial_delayandjitter. - Trigger on demand.
await timer.trigger()fires now and resumes the schedule. - Last-value cache.
timer.last_result/timer.last_tick_at— no blocking. - Cancel anytime. Explicit
cancel()or constructorcancel_aws(awaitables that stop the timer when they resolve).await cancel()waits for cleanup before returning; safe from inside the target/callbacks. - Restartable.
start()aftercancel()works (raisesTimerRestartErrorifcancel_awswas used — those are single-shot). - Decorator.
@async_timer.every(5)wraps a function into a Timer; original on.func. - Groups.
TimerGroup()starts/cancels a set of timers together. - Named.
name="db_refresh"shows inrepr()and scopes the logger. - Test-friendly.
mock_async_timer.MockTimerreplaces real sleeps with anAsyncMock.
When to use this — and when not to
async-timer is for in-process recurring work driven by asyncio:
cache refresh, periodic polling, metrics sampling, heartbeats,
fan-out feeds. Its model is one process, one loop, many timers.
| You want | Reach for |
|---|---|
| Periodic work in an asyncio app | async-timer |
| Cron-style wall-clock scheduling ("every Monday 9am") | APScheduler, aiocron |
| Background jobs with retries, queues, persistence | arq, dramatiq, Celery + beat |
| Recurring tasks across many processes | A scheduler + broker (Celery beat, arq cron) |
One-shot setTimeout-equivalent |
loop.call_later (stdlib) |
If you need durability or cross-process coordination, you need a
broker. async-timer doesn't try to be that.
More examples
join()
import asyncio
import async_timer
async def main():
timer = async_timer.Timer(12, target=lambda: 42)
timer.start()
val = await timer.join() # 42, after the first tick
await timer.cancel()
asyncio.run(main())
async for
import asyncio, time
import async_timer
async def main():
async with async_timer.Timer(14, target=time.time) as timer:
async for t in timer:
print(t) # current time every 14 seconds
asyncio.run(main())
Decorator
import async_timer
@async_timer.every(5, mode="fixed_rate", name="db_refresh")
async def refresh_db():
...
await refresh_db.func() # call the undecorated fn (tests)
async def main():
refresh_db.start()
await refresh_db.join()
await refresh_db.cancel()
TimerGroup
import async_timer
async def lifespan():
async with async_timer.TimerGroup(name="caches") as group:
group.add(async_timer.Timer(5, target=refresh_db))
group.add(async_timer.Timer(60, target=prune_cache))
# Block until every cache has been populated at least once,
# then serve traffic.
await group.wait(hit_count=1)
yield # both running; both cancelled on exit
TimerGroup mirrors Timer's surface across a set of timers; each
group method fans out to its members (AND-combined) and returns
[(timer, rv), ...] in iteration order:
group.wait(hit_count=...)/wait(hits=...)— block until every member satisfies the condition.group.trigger()— fire every member's target now (cache-invalidate-all).group.is_running()— True iff active and every member is running.group.start()/await group.cancel_all()— explicit lifecycle for use outsideasync with.group.cancel_threadsafe(timeout=5.0)— cancel from a non-loop thread (signal handler, sync REST endpoint, worker thread).
Each of wait() and trigger() accepts timeout= (whole-group
wall-clock bound) and return_exceptions=True (per-member errors
appear in the result list instead of propagating, mirroring
asyncio.gather).
Trigger now
async def force_refresh(timer):
return await timer.trigger()
Latest value, no blocking
@async_timer.every(5)
async def refresh_db():
return await db.fetch()
def get_cached():
return refresh_db.last_result # None until the first tick
Every-tick delivery via subscribe()
join() / async for self drop ticks under slow consumers (single-shot
fan-out). Use subscribe() when you need every tick:
async with timer.subscribe() as feed:
async for value in feed:
await log_it(value) # never misses a tick from subscribe-time
await asyncio.sleep(3.0) # even though the consumer is slow
Bounded queue (drop oldest + log when full):
async with timer.subscribe(maxsize=10, name="metrics-sink") as feed:
async for value in feed:
await slow_export(value)
Multiple subscribers each get an independent copy:
async with timer.subscribe() as a, timer.subscribe() as b:
...
Consumer-side load shedding:
async with timer.subscribe() as feed:
async for value in feed:
if feed.qsize > 100:
feed.drop_oldest(feed.qsize - 1) # keep only the newest
log.warning("shed %d ticks", feed.dropped_count)
await slow_export(value)
drop_oldest() never swallows end-of-stream / exception sentinels.
Target exceptions re-raise from the subscriber's iteration.
Tests with MockTimer
mock_async_timer.MockTimer is a drop-in Timer subclass that
replaces the real sleep with an AsyncMock, so ticks fire as fast as
the loop can schedule them — no wall-clock waits in tests:
from mock_async_timer import MockTimer
async def test_periodic_refresh():
calls = 0
def tick():
nonlocal calls
calls += 1
async with MockTimer(0.1, tick) as t:
await t.wait(hits=3)
assert calls == 3
Same surface as Timer — join, wait, trigger, subscribe,
TimerGroup, decorator wrapping all work the same way.
Exceptions
All library-raised errors derive from async_timer.TimerError, which
itself inherits from RuntimeError for back-compat with existing
except RuntimeError clauses:
| Exception | Raised when |
|---|---|
TimerAlreadyRunningError |
start() called on a running timer |
TimerNotRunningError |
trigger() / join() on a stopped timer |
TimerRestartError |
start() after cancel() on a Timer built with cancel_aws (single-shot) |
ThreadsafeDispatchError |
*_threadsafe called from the bound loop thread, before start, or after the loop closed |
Catch TimerError to filter only library-originated errors; catch a
specific subclass for finer control.
Thread safety
A Timer runs in a single asyncio event loop. Most state-mutating
operations must be called from the loop's thread. The following are
explicitly safe to use from any thread:
Read-only attributes (atomic under CPython's GIL):
timer.last_result,timer.last_tick_at,timer.hit_counttimer.is_running(),timer.delay,timer.namesubscription.qsize,subscription.dropped_count
set_delay(new_delay) is a single attribute write — safe from any
thread; takes effect on the next sleep.
Cross-thread control methods — marshal the operation back to the timer's loop and block for completion:
# From a sync REST handler, signal handler, worker thread, etc.:
timer.cancel_threadsafe(timeout=5.0) # raises TimeoutError if exceeded
result = timer.trigger_threadsafe(timeout=5.0)
feed.close_threadsafe()
These raise ThreadsafeDispatchError (a RuntimeError subclass) with
a clear message if called from the timer's own loop thread (use
await cancel() / await trigger() instead), or if the timer has
not been started yet, or if the bound event loop has been closed.
Anything else (subscribe(), awaiting join() / wait(), iterating
async for over the timer or a subscription, reading from a
subscription queue) must happen on the loop's thread. From other
threads, use asyncio.run_coroutine_threadsafe(coro, loop) to
dispatch.
License
MIT.
Metadata
Release files for async-timer 1.3.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| async_timer-1.3.2.tar.gz | 57.3 kB | With provenance | Details for async_timer-1.3.2.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| async_timer-1.3.2-py3-none-any.whl | Python 3 | none | any | Details for async_timer-1.3.2-py3-none-any.whl |
Total release size: 84.4 kB
Release files / async_timer-1.3.2.tar.gz
| Download URL | async_timer-1.3.2.tar.gz |
|---|---|
| Size | 57.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
5dddc377363723e7460397a1574fca0b49a5a9a9cc1d1ced4d664aad36f9c48f
|
|
BLAKE2b-256 checksum How to use checksums |
bcf864b9c1be063a9f093ba6b43a2b0e4589ef8a81086ec110d64bc65935cc9b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jun 7, 2026.
Transparency logRelease files / async_timer-1.3.2-py3-none-any.whl
| Download URL | async_timer-1.3.2-py3-none-any.whl |
|---|---|
| Size | 27.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
9501e213933e9095dbcdb92e0f98084dce5e8cad3da3bc3ee05c6bcab712d974
|
|
BLAKE2b-256 checksum How to use checksums |
6ef9c2d2289daae030993ad6b802daa3d602c4cafc38fce338d68fe6eb4972ca
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jun 7, 2026.
Transparency logSecurity for async-timer 1.3.2
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for async_timer-1.3.2.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jun 7, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.